feat: implement broker credential repository with upsert and list operations
- Add BrokerCredential to domain models and repositories - Implement upsert_broker_credential() for credential storage and rotation - Implement list_broker_credentials() for credential retrieval - Implement delete_broker_credential() for credential removal - Add broker_credentials table schema with (user_id, broker) composite key - Implement credential version tracking and last_rotated_at timestamps - Add FakeRepo support for broker credential CRUD operations - Add comprehensive test coverage for broker credential lifecycle
This commit is contained in:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
@@ -42,6 +42,19 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def update_user_last_login(self, user_id: str) -> None: ...
|
||||
|
||||
def upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
) -> BrokerCredential: ...
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: ...
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str) -> bool: ...
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -331,6 +331,19 @@ class PostgresPortfolioRepository:
|
||||
daemon_id=row[11] if len(row) > 11 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_broker_credential(row: Any) -> BrokerCredential:
|
||||
if row is None:
|
||||
raise ValueError("브로커 인증 정보를 찾을 수 없습니다")
|
||||
return BrokerCredential(
|
||||
user_id=row[0],
|
||||
broker=row[1],
|
||||
account_ref=row[2],
|
||||
encrypted_payload=row[3],
|
||||
credential_version=int(row[4]) if len(row) > 4 else 1,
|
||||
last_rotated_at=row[5] if len(row) > 5 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_portfolio(row: Any) -> Portfolio:
|
||||
if row is None:
|
||||
@@ -552,6 +565,60 @@ class PostgresPortfolioRepository:
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
def upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
) -> BrokerCredential:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, broker) DO UPDATE
|
||||
SET account_ref = EXCLUDED.account_ref,
|
||||
encrypted_payload = EXCLUDED.encrypted_payload,
|
||||
credential_version = broker_credentials.credential_version + 1,
|
||||
last_rotated_at = NOW()
|
||||
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
""",
|
||||
(user_id, broker, account_ref, encrypted_payload),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_broker_credential(row)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
FROM broker_credentials
|
||||
WHERE user_id = %s
|
||||
ORDER BY broker ASC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_broker_credential(row) for row in rows]
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id",
|
||||
(user_id, broker),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row is not None
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
Reference in New Issue
Block a user