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:
2026-06-28 00:45:46 +09:00
co-authored by Claude Sonnet 4.6
parent fc99f507a5
commit 51b7cbee35
16 changed files with 1878 additions and 86 deletions
+35 -17
View File
@@ -31,41 +31,59 @@ def get_user_info(
auth: AuthContext = Depends(_require_service_token),
settings: Settings = Depends(get_settings),
) -> UserInfoResponse:
# REQ-049, REQ-051: daemon_id 없으면 403 (daemon-scoped 인가 규율)
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id binding required",
)
repo = get_portfolio_repository_from_settings(settings)
try:
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
# Fetch broker credentials and decrypt
brokers: list[BrokerItem] = []
# REQ-049: daemon_id로 단일 broker 인증정보 조회 및 복호화
broker_item: BrokerItem | None = None
try:
credentials = repo.list_broker_credentials(auth.user_id) # type: ignore[arg-type]
enc_key: str | None = None
for cred in credentials:
credentials = repo.list_broker_credentials( # type: ignore[call-arg]
auth.user_id, # type: ignore[arg-type]
daemon_id=auth.daemon_id,
)
if credentials:
cred = credentials[0]
credential_payload = None
try:
if enc_key is None:
enc_key = settings.require_credential_encryption_key()
enc_key = settings.require_credential_encryption_key()
raw = decrypt_payload(enc_key, cred.encrypted_payload)
credential_payload = json.loads(raw)
except (InvalidToken, RuntimeError, Exception) as exc:
logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc)
brokers.append(BrokerItem(
# REQ-051 AC-8: 로그에 app_key, app_secret 값 미포함
# exc는 Fernet 토큰 오류 메시지만 포함하며 credential 값을 담지 않는다.
logger.warning(
"credential decryption failed for broker %s daemon %s: %s",
cred.broker,
auth.daemon_id,
type(exc).__name__,
)
broker_item = BrokerItem(
broker=cred.broker,
account_ref=cred.account_ref,
credential_payload=credential_payload,
credential_version=cred.credential_version,
last_rotated_at=cred.last_rotated_at,
))
)
except AttributeError:
# repo does not implement list_broker_credentials yet (mock or older impl)
pass
except Exception as exc: # pragma: no cover - defensive
# Broker credentials are supplementary. A failure here (e.g. missing
# broker_credentials table, DB error) must not crash /v1/user_info —
# the critical fields (daemon_id, user_id, permission) do not depend
# on broker credentials. Degrade gracefully with an empty broker list.
logger.warning("broker credential lookup failed; returning empty brokers: %s", exc)
brokers = []
# REQ-049, REQ-051: broker 인증정보 조회 실패는 broker=null로 graceful degrade.
# daemon_id, trading_enabled 필수 필드는 영향 없음.
logger.warning(
"broker credential lookup failed for daemon %s; returning broker=null: %s",
auth.daemon_id,
type(exc).__name__,
)
broker_item = None
finally:
if callable(getattr(type(repo), "close", None)):
@@ -80,7 +98,7 @@ def get_user_info(
daemon_id=auth.daemon_id,
trading_enabled=user.is_active,
disabled_reason=disabled_reason,
brokers=brokers,
broker=broker_item,
user_id=user.id,
username=user.username,
email=user.email,
+111 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from typing import Optional
import typer
@@ -28,10 +29,25 @@ def _with_service(ctx: typer.Context, callback):
repository.close()
def _resolve_daemon_id(repository, owner_user_id: str, daemon_id_input: str) -> str:
"""daemon_slug 또는 UUID로 daemon.id(UUID)를 반환한다. 없으면 ValueError."""
# 먼저 owner + slug로 시도
daemon = None
if hasattr(repository, "get_daemon_by_slug"):
daemon = repository.get_daemon_by_slug(owner_user_id, daemon_id_input)
# slug로 못 찾으면 UUID 직접 시도
if daemon is None and hasattr(repository, "get_daemon_by_id"):
daemon = repository.get_daemon_by_id(daemon_id_input)
if daemon is None:
raise ValueError(f"데몬을 찾을 수 없습니다: {daemon_id_input}")
return daemon.id
@credential_app.command("set")
def set_credential(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon slug or ID. If omitted, credential is stored without daemon binding (backfill required later)."),
broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."),
account_ref: str = typer.Option(..., "--account-ref", help="Account reference (e.g. account number)."),
app_key: str = typer.Option(..., "--app-key", help="Broker app_key."),
@@ -45,6 +61,16 @@ def set_credential(
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
else:
app_context.console.print(
"[yellow]WARN: --daemon-id 미지정. daemon_id 없이 등록된 인증정보는 "
"REQ-047 Step2 PK 전환 후 backfill이 필요합니다.[/yellow]"
)
enc_key = app_context.settings.require_credential_encryption_key()
payload = json.dumps({"app_key": app_key, "app_secret": app_secret})
encrypted = encrypt_payload(enc_key, payload)
@@ -53,6 +79,7 @@ def set_credential(
broker,
account_ref=account_ref,
encrypted_payload=encrypted,
daemon_id=resolved_daemon_id,
)
result = _with_service(ctx, _callback)
@@ -62,6 +89,7 @@ def set_credential(
ctx.obj.output_format,
{
"user_id": result.user_id,
"daemon_id": result.daemon_id,
"broker": result.broker,
"account_ref": result.account_ref,
"credential_version": result.credential_version,
@@ -74,6 +102,7 @@ def set_credential(
def list_credentials(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Filter by daemon slug or ID (optional)."),
) -> None:
"""List broker credentials for a user (payload not shown)."""
@@ -81,9 +110,15 @@ def list_credentials(
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
records = repository.list_broker_credentials(owner.id)
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
records = repository.list_broker_credentials(owner.id, daemon_id=resolved_daemon_id)
return [
{
"daemon_id": r.daemon_id,
"broker": r.broker,
"account_ref": r.account_ref,
"credential_version": r.credential_version,
@@ -107,6 +142,7 @@ def list_credentials(
def remove_credential(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon slug or ID. If provided, deletes by (daemon_id, broker). Otherwise, deletes by (user_id, broker)."),
broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
@@ -117,7 +153,12 @@ def remove_credential(
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
deleted = repository.delete_broker_credential(owner.id, broker)
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
deleted = repository.delete_broker_credential(owner.id, broker, daemon_id=resolved_daemon_id)
if not deleted:
raise ValueError(f"인증 정보를 찾을 수 없습니다: user={user} broker={broker}")
return {"deleted": True, "user": user, "broker": broker}
@@ -125,3 +166,71 @@ def remove_credential(
result = _with_service(ctx, _callback)
if result is not None:
render_output(ctx.obj.console, ctx.obj.output_format, result)
@credential_app.command("backfill-daemon-id")
def backfill_daemon_id(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: str = typer.Option(..., "--daemon-id", help="Daemon slug or ID to assign to this user's NULL daemon_id credentials."),
broker: Optional[str] = typer.Option(None, "--broker", help="Specific broker to backfill (default: all NULL daemon_id credentials for the user)."),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be updated without making changes."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
"""Backfill daemon_id for existing user-scoped broker credentials (REQ-048).
Sets daemon_id on broker_credentials rows where daemon_id IS NULL.
Run after REQ-047 Step1 migration and before Step2 PK conversion.
"""
def _callback(repository, app_context):
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
# daemon_id IS NULL인 인증정보 조회 (list_broker_credentials는 daemon_id 필터 없이 전체 조회)
# NULL인 행을 찾기 위해 전체 조회 후 필터
all_creds = repository.list_broker_credentials(owner.id)
null_creds = [c for c in all_creds if c.daemon_id is None]
if broker is not None:
null_creds = [c for c in null_creds if c.broker == broker]
if not null_creds:
app_context.console.print("이미 완료됨: daemon_id IS NULL인 인증정보가 없습니다.")
return {"status": "already_complete", "updated": 0}
if dry_run:
app_context.console.print(f"[dry-run] 대상 {len(null_creds)}건:")
for c in null_creds:
app_context.console.print(
f" user_id={c.user_id} broker={c.broker} → daemon_id={resolved_daemon_id}"
)
return {"status": "dry_run", "would_update": len(null_creds)}
_confirm(force, f"user '{user}'의 {len(null_creds)}건 인증정보에 daemon_id={resolved_daemon_id}를 설정하시겠습니까?")
updated = 0
for c in null_creds:
if hasattr(repository, "update_broker_credential_daemon_id"):
ok = repository.update_broker_credential_daemon_id(c.user_id, c.broker, resolved_daemon_id)
else:
# fallback: 직접 upsert (기존 payload 유지)
repository.upsert_broker_credential(
c.user_id,
c.broker,
account_ref=c.account_ref,
encrypted_payload=c.encrypted_payload,
daemon_id=resolved_daemon_id,
)
ok = True
if ok:
updated += 1
app_context.console.print(f"백필 완료: {updated}건 업데이트됨")
return {"status": "complete", "updated": updated}
result = _with_service(ctx, _callback)
if result is not None:
render_output(ctx.obj.console, ctx.obj.output_format, result)
+1
View File
@@ -182,4 +182,5 @@ class BrokerCredential:
encrypted_payload: str
credential_version: int = 1
last_rotated_at: datetime | None = None
daemon_id: str | None = None
+111 -26
View File
@@ -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
+1 -1
View File
@@ -96,7 +96,7 @@ class UserInfoResponse(BaseModel):
daemon_id: str | None = None
trading_enabled: bool
disabled_reason: str | None = None
brokers: list[BrokerItem] = []
broker: BrokerItem | None = None
# Additional informational fields
user_id: str
username: str