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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user