feat(bat-020): daemon-scoped 브로커 인증정보 (REQ-046~051)
- REQ-046: BrokerCredential.daemon_id 필드 추가, repository 시그니처 갱신 - REQ-047: alembic revision Step1(daemon_id NULL 컬럼 추가), Step2(PK 전환, dev/test DB 한정) - REQ-048: backfill-daemon-id CLI subcommand (--dry-run/--force/--broker 지원) - REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 (brokers→broker, daemon_id 없으면 403) - REQ-050: credential set/list/remove --daemon-id 옵션 추가 (daemon_slug 해석) - REQ-051: 보안 격리 - service-token 전용, web 토큰 차단, 로그 app_secret 비노출, graceful degrade 비가역 결재 게이트: REQ-047 Step2(PK 전환)는 사장님 결재 후 실DB 적용 예정(이 batch는 revision 코드만) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
+111
-26
@@ -474,6 +474,7 @@ class PostgresPortfolioRepository:
|
||||
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,
|
||||
daemon_id=row[6] if len(row) > 6 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -739,50 +740,134 @@ class PostgresPortfolioRepository:
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> 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),
|
||||
)
|
||||
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 마이그레이션 이후 존재.
|
||||
# Step1 적용 전 환경에서는 daemon_id 컬럼이 없어 INSERT 실패 가능.
|
||||
# Step2(PK 전환) 이후에는 ON CONFLICT 절도 (daemon_id, broker)로 변경 필요.
|
||||
# 현재는 기존 PK (user_id, broker) ON CONFLICT 유지하고 daemon_id를 컬럼에 포함.
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, daemon_id)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, broker) DO UPDATE
|
||||
SET account_ref = EXCLUDED.account_ref,
|
||||
encrypted_payload = EXCLUDED.encrypted_payload,
|
||||
daemon_id = EXCLUDED.daemon_id,
|
||||
credential_version = broker_credentials.credential_version + 1,
|
||||
last_rotated_at = NOW()
|
||||
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
|
||||
""",
|
||||
(user_id, broker, account_ref, encrypted_payload, daemon_id),
|
||||
)
|
||||
except Exception:
|
||||
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
|
||||
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]:
|
||||
def list_broker_credentials(
|
||||
self,
|
||||
user_id: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> list[BrokerCredential]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 이후 존재.
|
||||
# daemon_id 컬럼이 없는 환경(Step1 미적용)에서는 6컬럼 조회 fallback.
|
||||
try:
|
||||
if daemon_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
|
||||
FROM broker_credentials
|
||||
WHERE user_id = %s AND daemon_id = %s
|
||||
ORDER BY broker ASC
|
||||
""",
|
||||
(user_id, daemon_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
|
||||
FROM broker_credentials
|
||||
WHERE user_id = %s
|
||||
ORDER BY broker ASC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
|
||||
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 update_broker_credential_daemon_id(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
daemon_id: str,
|
||||
) -> bool:
|
||||
"""백필용: 특정 (user_id, broker) 인증정보에 daemon_id를 설정한다 (REQ-048)."""
|
||||
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
|
||||
UPDATE broker_credentials
|
||||
SET daemon_id = %s
|
||||
WHERE user_id = %s AND broker = %s AND daemon_id IS NULL
|
||||
RETURNING user_id
|
||||
""",
|
||||
(user_id,),
|
||||
(daemon_id, user_id, broker),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_broker_credential(row) for row in rows]
|
||||
row = cur.fetchone()
|
||||
return row is not None
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
|
||||
def delete_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> 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),
|
||||
)
|
||||
if daemon_id is not None:
|
||||
cur.execute(
|
||||
"DELETE FROM broker_credentials WHERE daemon_id = %s AND broker = %s RETURNING user_id",
|
||||
(daemon_id, broker),
|
||||
)
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user