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:
2026-05-14 07:56:37 +00:00
parent f4663f202e
commit d194873616
3 changed files with 181 additions and 4 deletions
+14 -1
View File
@@ -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,
+68 -1
View File
@@ -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,