"""Broker credential CLI commands.""" from __future__ import annotations import json from typing import Optional import typer from app.cli.commands.portfolio import _confirm from app.cli.core.utils import execute_command, render_output from app.core.encryption import encrypt_payload credential_app = typer.Typer(help="Broker credential management commands.") def _with_service(ctx: typer.Context, callback): app_context = ctx.obj repository = app_context.repository_factory(app_context.settings) try: return execute_command(app_context.console, callback=lambda: callback(repository, app_context)) except SystemExit: raise except RuntimeError as exc: app_context.console.print(str(exc)) raise typer.Exit(code=1) from exc finally: if callable(getattr(type(repository), "close", None)): 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."), app_secret: str = typer.Option(..., "--app-secret", help="Broker app_secret."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: """Register or update a broker credential (upsert).""" _confirm(force, f"Set {broker} credential for user '{user}'?") def _callback(repository, app_context): 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) return repository.upsert_broker_credential( owner.id, broker, account_ref=account_ref, encrypted_payload=encrypted, daemon_id=resolved_daemon_id, ) result = _with_service(ctx, _callback) if result is not None: render_output( ctx.obj.console, 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, "last_rotated_at": str(result.last_rotated_at), }, ) @credential_app.command("list") 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).""" def _callback(repository, app_context): 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) 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, "last_rotated_at": str(r.last_rotated_at), } for r in records ] result = _with_service(ctx, _callback) if result is not None: if ctx.obj.output_format == "json": render_output(ctx.obj.console, ctx.obj.output_format, result) else: for item in result if isinstance(result, list) else [result]: ctx.obj.console.print(item) if not result: ctx.obj.console.print("No broker credentials found.") @credential_app.command("remove") 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: """Remove a broker credential.""" _confirm(force, f"Remove {broker} credential for user '{user}'? This cannot be undone.") def _callback(repository, app_context): 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) 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} 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)