diff --git a/app/api/v1_user_info.py b/app/api/v1_user_info.py index d2f507f..e49bf13 100644 --- a/app/api/v1_user_info.py +++ b/app/api/v1_user_info.py @@ -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, diff --git a/app/cli/commands/credential.py b/app/cli/commands/credential.py index b0ed43b..01c0214 100644 --- a/app/cli/commands/credential.py +++ b/app/cli/commands/credential.py @@ -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) diff --git a/app/domain/models.py b/app/domain/models.py index 15d4213..d4d6372 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -182,4 +182,5 @@ class BrokerCredential: encrypted_payload: str credential_version: int = 1 last_rotated_at: datetime | None = None + daemon_id: str | None = None diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 7f86af3..e250e76 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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 diff --git a/app/schemas/v1.py b/app/schemas/v1.py index b2971dd..6aa948c 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md new file mode 100644 index 0000000..d0829b1 --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md @@ -0,0 +1,89 @@ +# BAT-020 Design + +## Design 위임 2건 확정 + +### D-1: REQ-048 백필 구현방식 — CLI subcommand vs alembic data migration revision + +**결정: CLI subcommand (`pfhub credential backfill-daemon-id`)** + +근거: +1. **운영자 제공 매핑 필요**: 어떤 user가 어떤 daemon_id에 매핑되는가는 인프라/운영 도메인 지식이 필요하며 자동화 불가 (REQ-048 Notes). alembic data migration revision은 매핑을 하드코딩하거나 env 변수에서 읽어야 해 유연성이 낮다. +2. **--dry-run 필수 (REQ-048 AC-3)**: CLI subcommand는 typer option으로 dry-run을 자연스럽게 표현하고, 운영자가 실행 전 확인 후 적용 가능. alembic revision에 dry-run을 구현하면 비표준 패턴이 된다. +3. **기존 CLI 패턴과 일관성**: 현재 `pfhub credential set/list/remove`가 이미 CLI로 구현돼 있으며, `--user` → user_id 변환 패턴을 그대로 활용 가능 (`get_user_by_username`). +4. **반복 실행 가능**: 1회성 운영 작업이지만 개발 환경에서 반복 실행 가능하도록 CLI가 더 적합. alembic은 이미 적용된 revision을 재실행하지 않아 개발 테스트에 불편. + +구현: `pfhub credential backfill-daemon-id --user --daemon-id [--dry-run] [--force]` + +--- + +### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책 + +**결정 (1): `--daemon-id`는 daemon_slug를 받는다 (UUID도 허용, 내부에서 해석)** + +근거: +- 기존 `daemons` 테이블에 `daemon_slug` (TEXT, UNIQUE per owner)와 `id` (UUID TEXT) 두 식별자가 있다. +- 기존 CLI 패턴: `--user`가 username(사람이 읽기 쉬운 식별자)을 받아 내부에서 `get_user_by_username()`으로 user_id 변환. 이 패턴과 동일하게 `--daemon-id`도 daemon_slug를 우선 받는다. +- `get_daemon_by_slug(owner_user_id, daemon_slug)` 메서드가 이미 존재함 (postgres.py:2204). `--user`로 먼저 owner user를 조회하고, 그 user_id + daemon_slug로 daemon을 조회한다. +- UUID 형식이면 `get_daemon_by_id()`로도 조회 시도 가능하나, 이 batch에서는 **daemon_slug 우선 + user 소유 검증**으로 단순화한다. UUID 직접 지정은 미지원(오운 user에서 slug로 찾지 못하면 에러). +- 입력 파라미터 이름은 `--daemon-id`로 유지(REQ-050 명세)하되, 값은 daemon_slug로 해석. + +**결정 (2): REQ-047 과도기(daemon_id nullable 구간)의 --daemon-id 필수/선택 정책** + +- `pfhub credential set` — REQ-047 Step1 이후(nullable 구간): `--daemon-id` 미제공 시에도 upsert 허용 (기존 user-scoped 동작 유지). **단, 경고 메시지 출력**: "daemon_id 없이 등록된 인증정보는 REQ-047 Step2 PK 전환 후 backfill이 필요합니다." + - 이유: 단계적 마이그레이션 과도기에 운영자가 기존 방식으로도 인증정보를 등록할 수 있어야 함. Step2 PK 전환 이후에는 daemon_id NOT NULL 제약이 DB 레벨에서 강제된다. + - REQ-050 AC-6 ("--daemon-id 미제공 시 exit 1")은 REQ-047 Step2 PK 전환 완료 이후 상태 기준. 이 batch에서는 미제공 시 경고만 출력하고 허용. (AC-6은 Step2 이후 환경에서 DB 제약으로 자동 강제됨) +- `pfhub credential remove` — `--daemon-id` 제공 시 (daemon_id, broker) 기반 삭제, 미제공 시 기존 (user_id, broker) 기반 삭제 (하위 호환). +- `pfhub credential list` — `--daemon-id` 제공 시 해당 daemon_id 필터, 미제공 시 user 전체 (기존 동작). + +--- + +## 주요 설계 결정 + +### DS-1: UserInfoResponse.brokers → broker 단일화 (REQ-049) + +- `brokers: list[BrokerItem]` 필드 제거, `broker: BrokerItem | None = None` 추가 +- 기존 소비자(trex-core hub_read.whoami)가 `daemon_id`만 읽고 `brokers` 미참조 → 파괴적 변경이나 실질 영향 없음 (dcy-010 OQ-2) +- 이 batch의 기존 테스트 중 `body["brokers"]`를 참조하는 테스트는 `body["broker"]`로 갱신 필요 + +### DS-2: daemon_id 없음 → 403 (REQ-049, REQ-051) + +- `auth.daemon_id is None`이면 HTTP 403 Forbidden 반환 +- 기존 `_require_service_token()` 함수는 token_type 검사만 함 → daemon_id 검사는 엔드포인트 핸들러 초입에서 추가 + +### DS-3: graceful degrade 계층 (REQ-049, REQ-051) + +- `list_broker_credentials` DB 오류 → except → `broker=None` 반환 (기존 패턴 유지, 확장) +- 복호화 실패 → `credential_payload=None`인 BrokerItem 반환 (기존 패턴 유지) +- 어느 단계에서도 `app_key`, `app_secret` 값은 로그에 출력하지 않음 + +### DS-4: _row_to_broker_credential 하위호환 (REQ-046 AC-4) + +- 기존 DB(daemon_id 컬럼 없음): 6컬럼 행 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) +- REQ-047 Step1 이후: 7컬럼 행 (위 + daemon_id) +- `_row_to_broker_credential(row)` — `len(row) > 6`이면 `row[6]`을 daemon_id로 읽고, 없으면 None. 단, SELECT 쿼리에서 daemon_id를 명시적으로 포함하는 것이 더 안전. 따라서 쿼리에 daemon_id 컬럼을 추가하되, 컬럼 미존재 시 에러가 나지 않도록 Step1 revision 이후에만 활성화. + - 실용적 접근: 현재 `_SELECT_*` 상수 방식이 아니라 inline SQL이므로, `list_broker_credentials`에서 daemon_id를 선택적으로 포함하는 것이 복잡. 대신 `_row_to_broker_credential`에서 인덱스 기반 조회 시 `len(row) > 6`으로 분기. + - 단, `list_broker_credentials` 쿼리에 daemon_id 컬럼이 없으면 row에 포함되지 않아 항상 6컬럼 → daemon_id 항상 None. 이는 Step1 이전에는 올바른 동작이나 Step1 이후엔 정보 손실. + - **최종 결정**: `list_broker_credentials` 및 `upsert_broker_credential` 쿼리에 `daemon_id` 컬럼을 포함하고, `_row_to_broker_credential`이 7컬럼 행을 처리. 단 ensure_schema의 broker_credentials CREATE TABLE은 수정하지 않음 (Step2 이후 ensure_schema 갱신은 별도 주석). + +### DS-5: upsert_broker_credential ON CONFLICT 처리 (REQ-046 Notes) + +- REQ-047 Step1 이후 daemon_id 컬럼 존재하나 PK는 여전히 (user_id, broker) → ON CONFLICT 대상 불변 +- REQ-047 Step2 이후 PK가 (daemon_id, broker)로 변경 → ON CONFLICT 절이 달라짐 +- 이 batch에서는 Step2 이후 ON CONFLICT 절은 TODO 주석으로 표시하고, 현재는 기존 (user_id, broker) ON CONFLICT 유지 + daemon_id INSERT 컬럼 추가 + +### DS-6: backfill CLI `get_daemon` 조회 방식 (REQ-048) + +- `--daemon-id `: daemon_slug로 먼저 시도 (`get_daemon_by_slug(owner_user_id, value)`), 실패 시 `get_daemon_by_id(value)` 재시도 + - `--user `으로 user_id를 먼저 조회한 후 get_daemon_by_slug 호출 + - 단, backfill은 user-scoped 인증정보를 특정 daemon에 할당하는 작업이므로 `--user`와 `--daemon-id`를 함께 받음 + +## 기존 테스트 영향 분석 + +| 테스트 | 영향 | 조치 | +| --- | --- | --- | +| `test_v1.py::test_v1_user_info_returns_user` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 | +| `test_v1.py::test_v1_user_info_returns_decrypted_broker_credentials` | `body["brokers"]` 리스트 참조 | `body["broker"]` 단일 필드 검증으로 변경 | +| `test_v1.py::test_v1_user_info_degrades_when_broker_credentials_lookup_fails` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 | +| `test_cli.py::FakeRepo.upsert_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 | +| `test_cli.py::FakeRepo.list_broker_credentials` | daemon_id 필터 없음 | daemon_id 필터 추가 | +| `test_cli.py::FakeRepo.delete_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 | diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/implementation.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/implementation.md new file mode 100644 index 0000000..b16fcb3 --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/implementation.md @@ -0,0 +1,30 @@ +# BAT-020 Implementation + +## 변경 파일 요약 + +| 파일 | 변경 | REQ | +| --- | --- | --- | +| `app/domain/models.py` | BrokerCredential.daemon_id: str | None = None 추가 | REQ-046 | +| `app/repositories/postgres.py` | _row_to_broker_credential 7컬럼 지원, upsert/list/delete daemon_id 파라미터, update_broker_credential_daemon_id 메서드 신규 | REQ-046, REQ-048 | +| `app/schemas/v1.py` | UserInfoResponse: brokers list 제거, broker: BrokerItem | None = None 추가 | REQ-049 | +| `app/api/v1_user_info.py` | daemon_id=None → 403, daemon_id 기반 단일 broker 조회, 로그 개선(app_secret 미노출) | REQ-049, REQ-051 | +| `app/cli/commands/credential.py` | set/list/remove --daemon-id 옵션, backfill-daemon-id subcommand | REQ-048, REQ-050 | +| `migrations/versions/20260628_0026_*.py` | broker_credentials daemon_id TEXT NULL 컬럼 추가 (Step1) | REQ-047 | +| `migrations/versions/20260628_0027_*.py` | broker_credentials PK 전환 (Step2, 사장님 결재 후 적용) | REQ-047 | +| `tests/test_bat020_broker_credential_domain.py` | REQ-046 AC-1~6 단위 테스트 신규 | REQ-046 | +| `tests/test_bat020_user_info_daemon_scoped.py` | REQ-049/051 AC 전체 테스트 신규 | REQ-049, REQ-051 | +| `tests/test_bat020_credential_cli.py` | REQ-048/050 AC 전체 테스트 신규 | REQ-048, REQ-050 | +| `tests/test_v1.py` | brokers→broker 반영, daemon_id=None → 403 반영, 기존 테스트 갱신 | REQ-049 | +| `tests/test_cli.py` | FakeRepo 시그니처 갱신, credential 플로우 테스트 --daemon-id 포함 | REQ-050 | +| `tests/test_daemon_assignment.py` | daemon_id=None → 403 정책 갱신 | REQ-049 | + +## Design 위임 확정 결과 + +### D-1: REQ-048 구현방식 → CLI subcommand 선택 +- `pfhub credential backfill-daemon-id --user --daemon-id [--dry-run] [--force] [--broker ]` +- 근거: 운영자 제공 매핑 필요, --dry-run 자연스러운 구현, 기존 CLI 패턴과 일관성 + +### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책 +- daemon_slug 우선 (`get_daemon_by_slug(owner_user_id, slug)`) + UUID 폴백 (`get_daemon_by_id`) +- 과도기(Step1 완료, Step2 미완료): `--daemon-id` 미제공 시 경고 출력 + 허용 +- Step2 PK 전환 후: DB NOT NULL 제약으로 자동 강제 diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md new file mode 100644 index 0000000..4be71bc --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md @@ -0,0 +1,39 @@ +# BAT-020 — daemon-scoped 브로커 인증정보 + +- Batch ID: BAT-020 +- Profile: standard +- Status: in-progress +- Discovery: dcy-010 +- Included REQ: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 +- Folder: docs/batches/bat-020_20260628_daemon-scoped-broker-credential/ +- Created: 2026-06-28 + +## Summary +dcy-010(daemon-scoped 브로커 인증정보 소유·전달)을 portfolio-hub에 구현한다. +BrokerCredential 도메인 모델에 daemon_id 필드 추가, broker_credentials 테이블 PK 전환 마이그레이션(Step1/Step2), 백필 CLI, GET /v1/user_info daemon-scoped 단일 broker 전달, CLI --daemon-id 옵션, 보안 격리 검증. + +## Included REQ + +| REQ | 제목 | Type | 의존 | +| --- | --- | --- | --- | +| REQ-046 | BrokerCredential daemon_id 필드 추가 | Data | 없음 | +| REQ-047 | broker_credentials PK 전환 마이그레이션 | Migration | REQ-046 | +| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | REQ-047 Step1 | +| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달 | Interface | REQ-046, REQ-047 | +| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | REQ-046, REQ-047 | +| REQ-051 | 브로커 인증정보 보안 격리 | Non-Functional | REQ-049 | + +## DoR (진입 조건) +- [x] 6개 REQ 전건 Approved 상태 확인 +- [x] dcy-010 confirmed Discovery 확인 +- [x] 구현 대상 파일 존재 확인(app/domain/models.py, app/repositories/postgres.py, app/api/v1_user_info.py, app/schemas/v1.py, app/cli/commands/credential.py, migrations/versions/) +- [x] design 위임 2건 식별 완료(REQ-048 구현방식, REQ-050 --daemon-id 입력 타입·과도기 정책) + +## DoD (완료 조건) +- [ ] REQ-046~051 전 AC 테스트 통과 +- [ ] alembic revision Step1/Step2 작성 완료(dev DB 검증) +- [ ] 기존 테스트 회귀 없음 +- [ ] draft-batch-verification 문서에 evidence 기록 + +## 비가역 결재 게이트 +REQ-047 Step2(PK 전환) 실DB 적용은 사장님 결재 대상. 이 batch에서는 revision 코드 작성 + dev/test DB 검증까지만 수행. diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md new file mode 100644 index 0000000..13402b8 --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md @@ -0,0 +1,73 @@ +# BAT-020 Planning + +## 구현 의존 순서 + +1. **REQ-046** — BrokerCredential 도메인 모델 + repository 시그니처 변경 (daemon_id 필드) +2. **REQ-047 Step1** — alembic revision: broker_credentials에 daemon_id TEXT NULL 컬럼 추가 +3. **REQ-048** — backfill-daemon-id CLI subcommand 추가 +4. **REQ-047 Step2** — alembic revision: PK (user_id,broker) → (daemon_id,broker) 전환 (dev/test DB만) +5. **REQ-049** — GET /v1/user_info daemon-scoped 단일 broker 전달 (스키마 + 엔드포인트) +6. **REQ-050** — credential CLI --daemon-id 옵션 추가 (set/list/remove) +7. **REQ-051** — 보안 격리 테스트: service-token 전용, web 토큰 차단, 로그 비노출 검증 + +## Task Breakdown + +### T-1: REQ-046 모델·repository 변경 +- `app/domain/models.py` — `BrokerCredential.daemon_id: str | None = None` 추가 +- `app/repositories/postgres.py` — `_row_to_broker_credential` daemon_id 처리 (7컬럼/6컬럼 하위호환) +- `app/repositories/postgres.py` — `upsert_broker_credential(daemon_id=None)` 파라미터 추가 +- `app/repositories/postgres.py` — `list_broker_credentials(daemon_id=None)` 파라미터 + 필터 추가 +- `app/repositories/postgres.py` — `delete_broker_credential(daemon_id=None)` 파라미터 추가 +- Tests: REQ-046 AC-1~6 단위 테스트 신규 + +### T-2: REQ-047 Step1 alembic revision +- `migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py` +- 내용: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가 + downgrade(컬럼 제거, PK 복원) +- Tests: AC-1 (컬럼 존재), AC-2 (기존 데이터 NULL 보존), AC-6 (downgrade) + +### T-3: REQ-048 backfill CLI +- `app/cli/commands/credential.py` — `backfill-daemon-id` subcommand 추가 +- Tests: AC-1~6 단위 테스트 (mock repo 기반) + +### T-4: REQ-047 Step2 alembic revision +- `migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py` +- 내용: PK 전환 (user_id,broker) → (daemon_id,broker), daemon_id NOT NULL 제약, downgrade 안전장치 +- `app/repositories/postgres.py` — `ensure_schema()` broker_credentials 정의 최종 스키마로 갱신 (Step2 이후 활성화 주석) +- Tests: AC-3~5 (dev DB에서 Step1→백필→Step2 순서 검증), AC-7 (downgrade 안전장치) + +### T-5: REQ-049 user_info daemon-scoped 확장 +- `app/schemas/v1.py` — `UserInfoResponse.broker: BrokerItem | None = None`, `brokers: list` 제거 +- `app/api/v1_user_info.py` — daemon_id 없으면 403, daemon_id 기반 list_broker_credentials 호출, 단일 broker 반환 +- Tests: REQ-049 AC-1~9 (daemon_id 있음/없음, broker 있음/없음, 2데몬 격리, 복호화 실패, 회귀) + +### T-6: REQ-050 credential CLI --daemon-id +- `app/cli/commands/credential.py` — set/list/remove 에 `--daemon-id` 옵션 추가 +- `tests/test_cli.py` FakeRepo — upsert/list/delete에 daemon_id 파라미터 반영 +- Tests: REQ-050 AC-1~7 + +### T-7: REQ-051 보안 격리 테스트 +- web 토큰 403 (AC-1), 비인증 401 (AC-2), daemon_id=None 403 (AC-3), 타데몬 null (AC-4) +- 타 엔드포인트 credential 미노출 (AC-5) +- DB 오류 graceful degrade (AC-6), 복호화 실패 degrade (AC-7) +- 로그 비노출 (AC-8, caplog fixture) + +## 파일 변경 대상 요약 + +| 파일 | 변경 내용 | +| --- | --- | +| `app/domain/models.py` | BrokerCredential.daemon_id 추가 | +| `app/repositories/postgres.py` | _row_to_broker_credential, upsert/list/delete 시그니처, ensure_schema broker_credentials | +| `app/schemas/v1.py` | UserInfoResponse brokers→broker 단일화 | +| `app/api/v1_user_info.py` | daemon-scoped 조회, daemon_id 없음 403, 로그 비노출 확인 | +| `app/cli/commands/credential.py` | set/list/remove --daemon-id, backfill-daemon-id subcommand | +| `migrations/versions/20260628_0026_*.py` | Step1 revision | +| `migrations/versions/20260628_0027_*.py` | Step2 revision | +| `tests/test_v1.py` | user_info 테스트 갱신 + 신규(daemon-scoped, 보안) | +| `tests/test_cli.py` | credential CLI 테스트 갱신 + 신규 | +| `tests/test_bat020_broker_credential_domain.py` | REQ-046 단위 테스트 신규 | +| `tests/test_bat020_broker_credential_migration.py` | REQ-047 마이그레이션 테스트 신규 | + +## 위험 +- REQ-049: `brokers` → `broker` 응답 변경으로 기존 `test_v1.py`의 `brokers` 필드 참조 테스트가 실패할 수 있음 → 갱신 필수 +- REQ-047 Step2: 실 DB 적용 금지(게이트). dev/test DB에서만 검증. +- `get_daemon_by_slug`은 `owner_user_id` + `daemon_slug` 조합으로 조회 → REQ-050에서 daemon_slug로 받으면 user 조회 먼저 필요 diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/verification.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/verification.md new file mode 100644 index 0000000..89e0592 --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/verification.md @@ -0,0 +1,116 @@ +# BAT-020 Verification + +## 실행 결과 + +``` +python3 -m pytest tests/ -v +509 passed, 0 failed, 11 warnings in 26.39s +``` + +신규 테스트: +- tests/test_bat020_broker_credential_domain.py: 6 passed +- tests/test_bat020_user_info_daemon_scoped.py: 13 passed +- tests/test_bat020_credential_cli.py: 11 passed + +기존 테스트 회귀: 0건 + +## AC 커버리지 + +### REQ-046: BrokerCredential daemon_id 필드 추가 + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: daemon_id=None 기본값 | test_broker_credential_daemon_id_defaults_to_none | PASS | +| AC-2: daemon_id 값 지정 | test_broker_credential_daemon_id_set | PASS | +| AC-3: 7컬럼 행 변환 daemon_id 세팅 | test_row_to_broker_credential_with_daemon_id | PASS | +| AC-4: 6컬럼 행 변환 daemon_id=None (하위호환) | test_row_to_broker_credential_without_daemon_id | PASS | +| AC-5: daemon_id 필터 시 해당 행만 반환 | test_list_broker_credentials_daemon_id_filter | PASS | +| AC-6: daemon_id 미지정 시 전체 반환 | test_list_broker_credentials_no_daemon_id_filter_returns_all | PASS | + +### REQ-047: broker_credentials PK 전환 마이그레이션 + +| AC | 검증 방법 | 결과 | +| --- | --- | --- | +| AC-1: Step1 후 daemon_id TEXT NULL 컬럼 존재 | alembic revision 코드 확인 + ADD COLUMN 명시 | 코드 검증 완료 | +| AC-2: Step1 후 기존 데이터 daemon_id=NULL 보존 | 컬럼 추가 시 기존 행 NULL 유지 (DB 동작) | 코드 검증 완료 | +| AC-3: Step2 후 PK (daemon_id, broker) 전환 | revision 코드 + 사전 검증 로직 확인 | 코드 검증 완료 | +| AC-4: user_id 컬럼 유지 | Step2 revision에서 user_id 삭제 없음 | 코드 검증 완료 | +| AC-5: ensure_schema Step2 이후 정합 | ensure_schema broker_credentials TODO 주석 | (Step2 이후 활성화 예정) | +| AC-6: Step1 downgrade 동작 | downgrade 함수에서 컬럼/인덱스 제거 명시 | 코드 검증 완료 | +| AC-7: Step2 downgrade 안전장치 | 중복 (user_id,broker) 시 RuntimeError 발생 | 코드 검증 완료 | + +NOTE: REQ-047 마이그레이션은 dev/test DB 검증 대상이나 실제 DB 인프라 미연결 상태. 코드 수준 검증. + +### REQ-048: daemon_id 백필 CLI + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: daemon_id IS NULL 행 UPDATE | test_backfill_daemon_id_updates_null_rows | PASS | +| AC-2: 백필 후 NULL 0건 | test_backfill_daemon_id_updates_null_rows | PASS | +| AC-3: --dry-run DB 변경 없음 | test_backfill_daemon_id_dry_run_no_change | PASS | +| AC-4: encrypted_payload 동등 보존 | test_backfill_preserves_encrypted_payload | PASS | +| AC-5: NULL 없으면 "이미 완료됨" | test_backfill_daemon_id_already_complete | PASS | +| AC-6: 존재하지 않는 user → exit 1 | test_backfill_daemon_id_nonexistent_user_exits_1 | PASS | +| AC-7: Step2 적용 가능 상태 검증 | (Step2 revision 사전 검증 코드에서 NULL=0 확인) | 코드 검증 완료 | + +### REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: daemon_id 있음 + broker 있음 → 단일 BrokerItem | test_user_info_daemon_scoped_broker_returned | PASS | +| AC-2: daemon_id 있음 + broker 없음 → broker=null | test_user_info_daemon_no_broker_returns_null | PASS | +| AC-3: daemon_id=None → 403 | test_user_info_no_daemon_id_returns_403 | PASS | +| AC-4: daemon A → daemon A account_ref | test_user_info_daemon_isolation_two_daemons | PASS | +| AC-5: daemon B → daemon B account_ref (격리) | test_user_info_daemon_isolation_two_daemons | PASS | +| AC-6: broker 단일 키, brokers 키 없음 | test_user_info_response_has_broker_not_brokers | PASS | +| AC-7: 복호화 실패 → broker.credential_payload=null | test_user_info_decryption_failure_returns_null_payload | PASS | +| AC-8: service 아닌 token → 403 | test_user_info_web_token_returns_403 | PASS | +| AC-9: 기존 필드 회귀 없음 | test_user_info_regression_existing_fields_unchanged | PASS | + +### REQ-050: credential CLI --daemon-id 옵션 + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: set --daemon-id → daemon_id 포함 upsert | test_credential_set_with_daemon_id | PASS | +| AC-2: list --daemon-id → 해당 daemon만 출력 | test_credential_list_daemon_id_filter | PASS | +| AC-3: list daemon-id 미지정 → 전체 | test_credential_list_no_daemon_id_returns_all | PASS | +| AC-4: remove --daemon-id → (daemon_id,broker) 삭제 | test_credential_remove_with_daemon_id | PASS | +| AC-5: 존재하지 않는 daemon → exit 1 | test_credential_set_nonexistent_daemon_exits_1 | PASS | +| AC-6: --daemon-id 미제공 시 경고 출력 + 허용 (과도기 정책) | test_cli_credential_set_list_and_remove_flow 갱신 | PASS | +| AC-7: 출력에 daemon_id 포함 | test_credential_set_output_includes_daemon_id | PASS | + +NOTE: AC-6은 design 결정에 따라 Step2 PK 전환 이전 과도기에는 경고만 출력하고 허용. REQ-047 Step2 후 DB 레벨 NOT NULL 제약으로 자동 강제. + +### REQ-051: 보안 격리 + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: web 토큰 → 403 | test_user_info_web_token_returns_403 | PASS | +| AC-2: 비인증 → 401 | test_user_info_unauthenticated_returns_401 | PASS | +| AC-3: daemon_id=None → 403 | test_user_info_no_daemon_id_returns_403 | PASS | +| AC-4: 다른 daemon 인증정보 → null | test_user_info_different_daemon_credential_not_returned | PASS | +| AC-5: 타 엔드포인트 credential 미노출 | test_other_endpoints_do_not_expose_credential_payload | PASS | +| AC-6: DB 오류 → broker=null (500 없음) | test_user_info_db_error_returns_null_broker | PASS | +| AC-7: 복호화 실패 → credential_payload=null | test_user_info_decryption_failure_returns_null_payload | PASS | +| AC-8: 로그에 app_secret 미노출 | test_user_info_decryption_failure_log_does_not_expose_secret | PASS | + +## 기존 테스트 회귀 분석 + +갱신한 기존 테스트: +- `tests/test_v1.py`: brokers→broker 응답 변경 반영, daemon_id=None → 403 반영 +- `tests/test_cli.py`: FakeRepo 시그니처 갱신(daemon_id 추가), credential 플로우 테스트 --daemon-id 포함 +- `tests/test_daemon_assignment.py`: daemon_id=None → 200 기대를 403으로 갱신 (REQ-049 정책 변경) + +## 비가역 결재 게이트 + +REQ-047 Step2(PK 전환) revision 코드가 작성되었으나 실 운영 DB 적용은 사장님 결재 게이트 대상. +이 batch에서는 revision 코드만 작성. 적용 순서: +1. `alembic upgrade 20260628_0026` (Step1 - nullable daemon_id 추가) +2. `pfhub credential backfill-daemon-id --user --daemon-id ` (REQ-048) +3. `alembic upgrade 20260628_0027` (Step2 - PK 전환, 사장님 결재 후) + +## 미해결/위험 사항 + +1. **ensure_schema() broker_credentials 정의 미갱신**: Step2 PK 전환 이후 `ensure_schema()` 내 `broker_credentials` CREATE TABLE은 여전히 구 스키마(PK=(user_id,broker)). Step2 결재·적용 후 별도 갱신 필요. 현재 코드에 TODO 주석 없음 — 팀장에게 Step2 적용 시 함께 갱신 요청 필요. +2. **upsert ON CONFLICT 절**: REQ-047 Step2 이후 PK가 (daemon_id,broker)로 변경되면 `upsert_broker_credential`의 `ON CONFLICT (user_id, broker)` 절이 맞지 않음. Step2 결재·적용 시 해당 쿼리도 함께 갱신 필요. +3. **REQ-047 Step2 dev DB 통합 검증**: 실제 PostgreSQL에서 Step1→backfill→Step2 순서 검증을 완료하지 못했음(DB 인프라 미연결). 코드 수준 검증만 완료. diff --git a/docs/batches/index.md b/docs/batches/index.md index 8b8c583..e0fbc12 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -27,3 +27,4 @@ | BAT-017 | standard | released | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) | | BAT-018 | batch-lite | release-candidate | REQ-043 | dcy-008 | [bat-018](./bat-018_20260625_holdings-cost-basis-endpoint/) | | BAT-019 | standard | confirmed | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) | +| BAT-020 | standard | draft-batch-verification | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) | diff --git a/docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md b/docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md new file mode 100644 index 0000000..9c63a81 --- /dev/null +++ b/docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md @@ -0,0 +1,194 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-010_20260627_daemon-scoped-broker-credential-provisioning +- 이슈명: daemon-scoped 브로커(KIS) 인증정보 소유·전달 — 다중 인스턴스 trex-core가 daemon별 다른 계좌로 거래 (교차 발원 trex-core 중장기 멀티 인스턴스) +- 작성 시각(KST): 2026-06-27 +- 마지막 갱신 시각(KST): 2026-06-28 +- 대체됨: 없음 +- 후속 Discovery 참조: trex-core 소비자측 day-start fetch + fail-safe 로드 = 별도 trex-core Discovery로 분리 **확정**(2026-06-28). trex-core repo에 신규 Discovery 생성 예정(미생성). +- 생성된 REQ 참조: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: TREX 워크스페이스 M3 메인 목표(중장기 trex-core 인스턴스 추가). GitHub org 보드 fourblocks2020/trex #7. 교차 발원 = trex-core 멀티 인스턴스(instance_key) + dcy-008(WAC cost-basis seed) +- 해석: + - 사장님이 "다중 인스턴스 trex-core를 완벽히 지원하려면 KIS API 인증정보를 portfolio-hub에서 받아야 하고, 그 인증정보는 universe와 동일하게 daemon_id로 매칭되어야 한다"고 방향을 확정했다. 현재 portfolio-hub는 broker 인증정보를 `user_id` 단위로만 소유해(`broker_credentials` PK=(user_id,broker)) 같은 user의 두 데몬에 서로 다른 계좌를 줄 수 없다. + - 이번 Discovery에서 판단할 핵심: 브로커 인증정보를 daemon_id 차원으로 확장(스키마 PK 변경 + daemon-scoped 전달 엔드포인트)하고, "1 데몬 = 1 계좌" 불변식 하에 응답을 단일 `broker`로 정규화하는 범위·리스크·마이그레이션을 확정한다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 기존 `broker_credentials` + `/v1/user_info`(dcy-004 토큰 정비 계열)는 user-scoped 인증정보 소유·전달을 다뤘으나, daemon_id 차원의 스코프·1:1 불변식·daemon 부트스트랩 전달은 정의된 바 없다. + - 변경이 테이블 PK와 전달 엔드포인트 응답 형태(brokers 리스트→단일 broker)를 바꾸는 구조 변경이라 기존 Discovery의 미세 조정이 아닌 독립 주기로 추적한다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. + +- 현재 상태 + - `broker_credentials` 테이블 PK=(`user_id`, `broker`) — 인증정보가 **user 단위**. 컬럼: user_id, broker, account_ref, encrypted_payload(Fernet 암호화 JSON `{app_key, app_secret}`), credential_version, last_rotated_at. (`migrations/versions/20260502_0014_v1_api_schema.py`, `app/domain/models.py:178`) + - 전달: `GET /v1/user_info` — **service token 전용**(token이 `daemon_id`/`runtime_id` 바인딩), 토큰의 `user_id`로 조회 → 서버에서 Fernet 복호화 → `brokers: list[BrokerItem]`(`credential_payload`, `account_ref`, `credential_version`) + `trading_enabled`(user.is_active) 반환. (`app/api/v1_user_info.py`) + - 반면 **universe(포트폴리오)는 이미 daemon-scoped** — `Portfolio.managed_by_daemon_id`, `/v1/portfolios`가 service token의 `auth.daemon_id == managed_by_daemon_id` 일치로 필터·인가. (`app/api/v1_portfolios.py:254,260,554`) + - 결과 비대칭: 토큰은 daemon_id를 들고 universe는 daemon으로 거르는데, **인증정보만 user로 걸러** 같은 user의 두 데몬이 같은 계좌를 공유하게 된다. + - trex-core 측은 현재 KIS를 `TREX_KIS_BROKER_*` env로만 주입(`trex-core/runtime/config.py:257`)하며 portfolio-hub에서 인증정보를 받지 않는다. 멀티 인스턴스 토대(instance_key, longterm.example.toml)는 존재. +- 기대 상태 + - 한 trex-core 데몬 인스턴스가 자기 service token(daemon_id 바인딩)으로 부트스트랩 시 **그 daemon에 매칭된 단일 KIS 계좌 인증정보**를 받는다. + - 같은 user가 단기·중장기 데몬을 동시에 소유해도 **데몬마다 다른 KIS 계좌**로 거래할 수 있다. + - 인증정보는 universe와 동일하게 daemon_id로 인가·스코프되어, 계좌-인스턴스 매칭이 단일 출처(portfolio-hub)에서 결정된다. +- 이해관계자 + - 사장님(제품 방향·계좌/인증 정책 결정·결재) + - 팀장(Discovery·거버넌스·검수) + - portfolio-hub-dev(스키마·엔드포인트·CLI 구현), trex-core-dev(소비자 어댑터 — 별도 Discovery) + - 운영자(계좌 인증정보 등록/회전, 데몬-계좌 바인딩 관리) +- 제약사항 + - KIS app_secret = 시스템 최민감 시크릿(실계좌=실자금). 복호화 평문이 응답 본문으로 네트워크 전송됨 → 내부망/TLS·service-token 게이트 유지 필수. + - portfolio-hub는 M3에서 trex-web 프론트로 노출될 서비스 — 인증정보 경로를 web 노출 표면과 격리 유지(service-token 전용). + - PK 변경은 기존 데이터·제약·인덱스에 영향(마이그레이션 비가역성 주의). + +## 주요 사용자 시나리오 +- 운영자가 단기 전략 데몬 A와 중장기 전략 데몬 B를 같은 user 소유로 등록하고, 각각 **다른 KIS 계좌** 인증정보를 portfolio-hub에 등록한다. A·B는 각자 자기 계좌로 거래한다. +- trex-core 중장기 인스턴스가 그날 시작(아침 스냅샷) 시 service token으로 부트스트랩을 호출해 자기 daemon의 universe + KIS 인증정보를 한 번에 받아 그날 거래를 개시한다. +- 운영자가 데몬 B의 KIS app_secret을 회전(rotate)하면, B만 새 버전을 받고 A는 영향받지 않는다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목. + +- (C1) `broker_credentials`에 **`daemon_id` 차원 도입** — 인증정보를 daemon-scope로 소유. PK를 `(user_id, broker)` → daemon 포함 형태로 변경. +- (C2) **daemon-scoped 전달** — service token의 `auth.daemon_id`로 해당 데몬의 인증정보만 복호화·반환. "1 데몬 = 1 계좌" 불변식 하에 응답을 **단일 `broker`**(리스트 아님)로 정규화. +- (C3) **인증정보 관리 경로(CLI/관리 API)에 daemon_id 바인딩 추가** — 등록·회전·삭제 시 데몬-계좌 매칭 지정. + +## 영향 범위 +- 코드 영향: + - `app/domain/models.py`(BrokerCredential), `app/repositories/*`(upsert/list/delete 시그니처 + 쿼리 daemon_id 필터), `migrations/versions/*`(PK·컬럼 마이그레이션) + - `app/api/v1_user_info.py`(기존 `/v1/user_info` daemon-scoped 확장 — 결정 2026-06-28, 신규 엔드포인트 없음), `app/schemas/v1.py`(UserInfoResponse: brokers 리스트→단일 broker) + - `app/cli/commands/credential.py`(`--daemon-id` 추가) + - **교차 서비스(별도 Discovery)**: trex-core — KISConfig를 env-only → portfolio-hub fetch 소스에서 구성하는 소비자 어댑터 + 그날-시작 fail-safe 로드 게이트(`trex-core/runtime/config.py`, daemon 부트스트랩) +- 문서 영향: + - `docs/srs/`(신규 REQ), `docs/discovery/index.md`(본 문서 등록) + - baseline 런타임 흐름에 "daemon 부트스트랩 시 인증정보 전달" 추가 검토(§ runtime-flows 갱신은 NFR/후속에서 판단) +- 운영 영향: + - 운영자 인증정보 등록 절차에 daemon_id 지정 추가. 기존 user-scoped 인증정보 마이그레이션(데몬 매핑) 1회 필요. + - `CREDENTIAL_ENCRYPTION_KEY` 관리·회전 정책(기존 의존 유지). + +# 5. 요구사항 목록 + +## 기능 요구사항 +- FR-1: `broker_credentials`가 `daemon_id`로 인증정보를 스코프한다. 한 (daemon, broker) 조합당 활성 인증정보는 정확히 1건이다. **PK = `(daemon_id, broker)`, daemon-scoped 강제(user-기본 폴백 없음 — 공유계좌 footgun 차단). 결정 2026-06-28.** 마이그레이션은 nullable `daemon_id` 컬럼 추가 → 백필(데몬 매핑) → PK 전환의 단계적 경로. +- FR-2: **기존 `GET /v1/user_info`를 daemon-scoped로 확장**한다(신규 엔드포인트 미신설 — confirm 2026-06-28). service token 호출 시 토큰의 `daemon_id`에 매칭된 **단일 broker 인증정보**(account_ref + 복호화된 credential_payload + credential_version)와 `trading_enabled`를 반환한다. 토큰에 daemon_id가 없으면 거부(403)한다(universe 인가와 동일 규율). 1데몬1계좌 불변식(A-2 confirmed)상 응답은 리스트가 아닌 단일 broker로 정규화한다. 기존 소비자는 trex-core `hub_read.whoami()` 하나뿐이며 `daemon_id`만 읽으므로 broker 필드 reshape는 무영향(안전). +- FR-3: 인증정보 등록/회전/삭제 경로(CLI 및/또는 관리 API)가 `daemon_id`를 받아 데몬-계좌 매칭을 지정한다. +- FR-4: 기존 user-scoped 인증정보를 daemon-scoped로 이전하는 마이그레이션 경로를 제공한다(데이터 손실 없이). + +## 비기능 요구사항 +- NFR-1 (보안): 복호화된 app_secret은 service-token 전용·내부망/TLS 경로로만 전달된다. trex-web 등 user 토큰 경로로는 접근 불가해야 한다. at-rest는 기존 Fernet 암호화 유지. +- NFR-2 (일관성): 인증정보 스코프(daemon_id)가 universe 스코프(`managed_by_daemon_id`)와 동일한 인가 규율을 따른다(불일치/누락 → 403). +- NFR-3 (가용성/degrade): 인증정보 조회·복호화 실패가 부트스트랩 응답의 필수 필드(daemon_id, trading_enabled)를 크래시시키지 않는다(기존 graceful degrade 원칙 유지). 단, 인증정보 부재는 그날 거래 개시 차단 신호로 전달된다. + +## 범위 경계 +- In Scope + - portfolio-hub: daemon_id 차원 인증정보 소유(PK 변경) + daemon-scoped 단일 broker 전달 + 관리 경로 daemon_id 바인딩 + 마이그레이션. + - "1 데몬 = 1 계좌" 불변식 확정 및 응답 단일화. +- Out of Scope + - trex-core 소비자 어댑터(KISConfig fetch) + 그날-시작 fail-safe 로드 게이트 — **별도 trex-core Discovery**로 분리(본 문서는 계약·전달 형태만 확정). + - {{DECIDE_SCOPE_BOOTSTRAP_UNIFY:부트스트랩 응답에 universe까지 한 엔드포인트로 묶을지(단일 부트스트랩) vs 인증정보만 별도 경로로 둘지 — 사장님/팀장 확정 필요}} + - 시크릿 매니저(Vault 등) 외부화 — 현 단계는 portfolio-hub 내부 처리 유지(가정 A-3). + +# 6. 리스크/가정 목록 + +## 리스크 +- R-1 (High): `broker_credentials` PK 변경 마이그레이션이 기존 데이터·제약을 깨거나 롤백이 어려움. + - 완화 방안: nullable `daemon_id` 컬럼 추가 → 백필(데몬 매핑) → 새 PK/유니크 제약 전환의 단계적 마이그레이션. 사전 백업·드라이런·롤백 스크립트. 비가역 단계는 사장님 결재. +- R-2 (High): 최민감 시크릿(app_secret) 평문이 네트워크 전송되며, portfolio-hub가 web 노출 서비스라 표면이 큼. + - 완화 방안: service-token 전용 + daemon_id 인가 + 내부망/TLS 한정. web의 user 토큰 경로와 라우트/권한 분리. 감사 로깅. +- R-3 (Medium): `/v1/user_info` 응답 형태(brokers 리스트→단일)의 파괴적 변경이 기존 소비자를 깸. + - 완화 방안: 기존 소비자 인벤토리 확인 후, 신규 daemon-scoped 경로 신설(기존 user-level 리스트는 admin/UI용으로 유지) 또는 버전드 전환. + +## 가정 +- A-1: portfolio-hub가 universe·전략·인증정보의 단일 진실원천이다. (사장님 확정) +- A-2: "1 데몬(trex-core 인스턴스) = 1 브로커 계좌" 불변식이 성립한다. trex-core `KISConfig`가 현재 단일인 점과 정합. (**사장님 confirmed 2026-06-28** → 단일 `broker` 응답 근거) +- A-3: 현 단계는 portfolio-hub 내부 인증정보 소유(기존 Fernet + `CREDENTIAL_ENCRYPTION_KEY`)를 유지하고 외부 시크릿 매니저로 가지 않는다. +- A-4: 데몬-계좌 매칭은 service token의 daemon_id 바인딩(`AuthToken.daemon_id`)을 통해 결정된다. + +## 오픈 질문 +- OQ-1: PK 전환 형태 + 폴백 정책. + - 상태: **Answered (2026-06-28)** + - 결정: **PK = `(daemon_id, broker)`, daemon-scoped 강제(폴백 없음)**. 마이그레이션 = nullable `daemon_id` 컬럼 추가 → 백필 → PK 전환 단계적. 본문 FR-1·R-1에 반영. + - 반영 위치: §5 FR-1, §6 R-1 완화 방안. +- OQ-2: 전달 형태 — 기존 `/v1/user_info` reshape vs daemon-scoped 신규 엔드포인트. + - 상태: **Answered (2026-06-28)** + - 결정: **기존 `/v1/user_info`를 daemon-scoped로 확장**(신규 엔드포인트 미신설, 사장님). 소비자 인벤토리 조사 결과 유일 소비자=trex-core `adapters/portfolio/hub_read.py::whoami()`이며 `daemon_id`만 읽음(`brokers` 미참조) → 단일 broker reshape 무영향(안전). 본문 FR-2에 반영. + - 반영 위치: §4 영향범위, §5 FR-2, §7 S-1/S-2. +- OQ-3: 부트스트랩 통합 범위 — universe + 인증정보 + trading_enabled를 단일 부트스트랩 응답으로 묶을지. + - 상태: Deferred + - 처리 방안: trex-core 소비자 Discovery와 함께 결정(전달 계약 공동 설계). 본 문서는 인증정보 전달 형태만 확정. + - 종료 조건: trex-core 소비자 Discovery 착수 시점. +- OQ-4: 그날-시작 fail-safe 로드 의미론(진입 게이트 vs 청산 보존, 장중 무중단)의 trex-core측 구현 책임 분배. + - 상태: Deferred + - 처리 방안: 별도 trex-core Discovery로 인계(아래 §10 참조). 본 문서엔 전달측 가정으로만 기록. + - 종료 조건: trex-core 소비자 Discovery 생성. + +# 7. 초기 성공 기준 +- S-1: 같은 user가 소유한 두 데몬이 각각 다른 KIS `account_ref`/`app_key`를 부트스트랩으로 수신함을 검증할 수 있다. +- S-2: service token에 daemon_id가 없거나 인증정보가 daemon에 매칭되지 않으면 전달이 403/차단됨이 테스트로 확인된다. +- S-3: 기존 user-scoped 인증정보가 마이그레이션 후 손실 없이 daemon-scoped로 조회된다. + +## 측정 방식 및 데이터 출처 +- S-1 측정 방식: 두 데몬용 service token으로 부트스트랩 호출 → 응답 account_ref/credential_payload 상이 확인 / 데이터 출처: 통합테스트 + `broker_credentials` 행 +- S-2 측정 방식: daemon_id 누락/불일치 토큰 요청의 403 단위·통합 테스트 / 데이터 출처: 엔드포인트 테스트 +- S-3 측정 방식: 마이그레이션 전후 인증정보 복호화 결과 동등성 비교 / 데이터 출처: 마이그레이션 드라이런 로그 + 테스트 + +# 8. REQ로 넘기기 전 확인 체크 +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: trex-core 소비자측 분리 확정(별도 Discovery), 부트스트랩 통합(OQ-3) Deferred 처리 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-3 측정 가능성 검토 완료 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(마이그레이션 — 단계적 PK 전환·롤백, portfolio-hub-dev)·R-2(시크릿 표면 — service-token+내부망) 완화안 확정 +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1·OQ-2 Answered, OQ-3·OQ-4 Deferred 처리 완료 +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: 사장님+팀장 confirm-discovery (DECIDE/CONFIRM 전건 해소) + +# 9. 파일 처리 결과 +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md +- 참조 문서: + - docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (service token·daemon 바인딩) + - docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md (교차 발원 trex-core 중장기 cold-start) + - app/api/v1_user_info.py / app/domain/models.py(BrokerCredential) / app/api/v1_portfolios.py(daemon-scope 선례) + +# 10. 사용자 결정 필요 항목 요약 + +## DECIDE +- ~~PK 전환 형태 (OQ-1)~~ → **RESOLVED 2026-06-28: PK `(daemon_id, broker)` + daemon-scoped 강제(폴백 없음), 단계적 마이그레이션** +- ~~엔드포인트 형태 (OQ-2)~~ → **RESOLVED 2026-06-28: 기존 `/v1/user_info` daemon-scoped 확장** +- ~~부트스트랩 통합 (OQ-3)~~ → **Deferred: trex-core 소비자 Discovery 공동 결정** + +## CONFIRM +- ~~1데몬1계좌 불변식~~ → **RESOLVED 2026-06-28: 확정(A-2 confirmed) → 단일 broker 응답** +- ~~trex-core 소비자측 분리~~ → **RESOLVED 2026-06-28: 별도 trex-core Discovery로 분리 확정** + +## DATA +- ~~기존 /v1/user_info 소비자 인벤토리~~ → **RESOLVED 2026-06-28: 유일 소비자=trex-core hub_read.whoami(daemon_id만 읽음), brokers 미참조 → reshape 안전** + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: REQ Drafting 진행 가능 +- Handoff Rationale: confirm-discovery 완료(2026-06-28). DECIDE 전건 해소 — OQ-1(PK `(daemon_id, broker)`+폴백없음), OQ-2(기존 `/v1/user_info` 확장), OQ-3(Deferred). CONFIRM 전건 — A-2(1데몬1계좌→단일 broker), trex-core 소비자측 별도 Discovery 분리. DATA(소비자 인벤토리) 해소. 잔여 Open 0건 → REQ 초안 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: 팀장(Claude) +- Confirmed At (KST): 2026-06-28 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 9c7e3f0..574ffac 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -15,3 +15,4 @@ | dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api | 20260616 | 일일 거래내역 + 실현 PnL 조회 API (운영 리포트/프론트 데이터 소스, issue #11) | [Discovery](./dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md) | confirmed | | dcy-008_20260625_current-holding-wac-cost-basis-read | 20260625 | 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존(교차 발원 trex-core dcy-034 FR-7). GET /v1/portfolios/{id}/holdings/cost-basis | [Discovery](./dcy-008_20260625_current-holding-wac-cost-basis-read.md) | confirmed | | dcy-009_20260627_user-read-asset-catalog-and-daemons | 20260627 | 사용자용 read 2종 — GET /v1/assets require_read 완화 + 사용자 가용 데몬 목록 신규(교차 발원 trex-web 포트폴리오 관리 dcy-007) | [Discovery](./dcy-009_20260627_user-read-asset-catalog-and-daemons.md) | confirmed | +| dcy-010_20260627_daemon-scoped-broker-credential-provisioning | 20260627 | daemon-scoped 브로커(KIS) 인증정보 소유·전달 — 다중 인스턴스 trex-core가 daemon별 다른 계좌로 거래 (교차 발원 trex-core 중장기 멀티 인스턴스) | [Discovery](./dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md) | confirmed | diff --git a/docs/srs/Data/req-046_broker-credential-daemon-id-field.md b/docs/srs/Data/req-046_broker-credential-daemon-id-field.md new file mode 100644 index 0000000..d25244b --- /dev/null +++ b/docs/srs/Data/req-046_broker-credential-daemon-id-field.md @@ -0,0 +1,88 @@ +# REQ-046: BrokerCredential 도메인 모델 daemon_id 필드 추가 + +- Status: Approved +- Type: Data +- Priority: High +- Owner: Justin Ko + +## Intent +- `BrokerCredential` 도메인 엔티티가 현재 `user_id` 기반으로만 인증정보를 식별한다. daemon-scoped 소유로 전환하기 위해 `daemon_id` 필드를 모델에 추가하고, repository 조회/행 변환 로직이 daemon_id 차원을 인식하도록 한다. +- 이 REQ는 도메인 모델·repository 계층의 데이터 구조 변경을 담당하며, 마이그레이션(REQ-047/REQ-048) 및 API 전달(REQ-049)·CLI(REQ-050)의 선행 조건이다. + +## Requirement +- `app/domain/models.py`의 `BrokerCredential` dataclass에 `daemon_id: str | None = None` 필드를 추가한다. +- `app/repositories/postgres.py`의 `_row_to_broker_credential` 정적 메서드가 DB 행에서 `daemon_id`를 읽어 `BrokerCredential` 인스턴스를 구성해야 한다. +- `upsert_broker_credential` 메서드 시그니처에 `daemon_id: str | None = None` 파라미터를 추가하고, INSERT/ON CONFLICT 쿼리에 `daemon_id` 컬럼 처리를 반영한다. 단, 실제 테이블 컬럼은 REQ-047 마이그레이션이 선행 적용된 후 활성화되므로 이 REQ 단독으로는 DB 컬럼이 존재하지 않는 환경에서도 하위 호환을 고려한다. +- `list_broker_credentials` 메서드에 `daemon_id: str | None = None` 파라미터를 추가하여, 값이 제공되면 daemon_id로 필터링한 결과를 반환하도록 한다. +- `delete_broker_credential` 메서드 시그니처에 `daemon_id: str | None = None` 파라미터를 추가한다. + +## Acceptance Criteria +1. Given `BrokerCredential` 인스턴스를 생성할 때 `daemon_id=None`이 기본값이면, When 인스턴스를 생성, Then `daemon_id` 필드가 `None`으로 존재한다. +2. Given `BrokerCredential` 인스턴스를 생성할 때 `daemon_id="daemon-abc"` 값을 주면, When 인스턴스를 생성, Then `daemon_id` 필드가 `"daemon-abc"`로 존재한다. +3. Given `_row_to_broker_credential` 메서드가 6컬럼 행(daemon_id 포함)을 받으면, When 변환, Then 반환 `BrokerCredential.daemon_id`가 해당 값으로 세팅된다. +4. Given `_row_to_broker_credential` 메서드가 기존 6컬럼 행(daemon_id 없음)을 받으면, When 변환, Then `BrokerCredential.daemon_id`가 `None`으로 세팅된다(하위 호환). +5. Given `list_broker_credentials(user_id="u1", daemon_id="d1")`를 호출하면, When DB에 `daemon_id="d1"` 행과 `daemon_id="d2"` 행이 있으면, Then `daemon_id="d1"` 행만 반환된다. +6. Given `list_broker_credentials(user_id="u1")` (daemon_id 미지정)를 호출하면, When DB에 여러 행이 있으면, Then 모든 행을 반환한다(기존 동작 유지, 하위 호환). + +## Impacted Area +- Docs: + - `docs/data-model.md` — `BrokerCredential` 엔티티에 `daemon_id` 필드 추가, PK 변경 예고(`REQ-047 선행 마이그레이션 후 PK=(daemon_id, broker)`) 명시. +- Modules: + - `app/domain/models.py` — `BrokerCredential` dataclass에 `daemon_id: str | None = None` 필드 추가. + - `app/repositories/postgres.py` — `_row_to_broker_credential`, `upsert_broker_credential`, `list_broker_credentials`, `delete_broker_credential` 시그니처 및 쿼리 갱신. +- Tests: + - `BrokerCredential` dataclass 필드 단위 테스트. + - `_row_to_broker_credential` 행 변환 테스트(daemon_id 포함/미포함 두 케이스). + - `list_broker_credentials` daemon_id 필터 테스트(mock 또는 메모리 repo 기반). + +## Notes +- REQ-047(마이그레이션) 선행 없이는 실제 DB 컬럼이 없으므로, 이 REQ의 구현은 DB 컬럼 유무에 관계없이 모델/메서드 시그니처만 정합화하는 것을 목표로 한다. 실제 DB 컬럼 처리는 REQ-047 이후 통합 시점에 완성된다. +- `upsert_broker_credential` 쿼리의 ON CONFLICT 절 수정은 PK 변경(REQ-047) 완료 이후 정합화한다. 따라서 이 REQ에서 upsert 쿼리의 ON CONFLICT 처리는 설계 위임(REQ-047 delivery 연계). +- `list_broker_credentials` daemon_id 필터는 `WHERE daemon_id = %s` 조건을 추가하는 방식으로 구현하며, NULL 처리는 `WHERE daemon_id IS NULL` 또는 파라미터 없음(전체 조회)으로 구분한다. +- `data-model.md`의 `BrokerCredential` 엔티티 갱신(daemon_id 필드, PK 변경 예고)은 이 REQ 구현 시 함께 반영한다. +- Source: dcy-010 FR-1, §4 C1. + +## Change Log + +### CHG-20260628-01 +- Date: 2026-06-28 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-010 FR-1 산출물. + +#### Intent +- dcy-010 confirmed Discovery의 FR-1(daemon_id 차원 도입)을 도메인 모델·repository 계층 변경으로 분리하여 REQ로 정규화한다. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-6 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Data/req-046_broker-credential-daemon-id-field.md` 신규 생성 + - `docs/data-model.md` 갱신 예고 +- Modules: + - `app/domain/models.py`, `app/repositories/postgres.py` 최초 지정 +- Tests: + - `BrokerCredential` 단위·행 변환·필터 테스트 신규 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-047 마이그레이션과 delivery 연계. + +#### Validation Plan +- pytest로 AC-1~6 각 케이스 확인. `_row_to_broker_credential` 하위호환(AC-4) 포함. + +### CHG-20260628-02 +- Date: 2026-06-28 +- Author: 팀장(Claude) +- confirm-req: `Proposed` → `Approved`. dcy-010 FR-1 추적 확인, AC testable, 하위호환(AC-4) 적절. REQ-047 선행 의존 명시됨. blocker 없음. diff --git a/docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md b/docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md new file mode 100644 index 0000000..d0608a9 --- /dev/null +++ b/docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md @@ -0,0 +1,94 @@ +# REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달로 확장 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: Justin Ko + +## Intent +- 기존 `GET /v1/user_info`가 `user_id` 기반으로 `brokers: list[BrokerItem]`을 반환한다. 이를 service token의 `auth.daemon_id`로 매칭된 단일 broker 인증정보를 반환하는 daemon-scoped 전달로 확장한다. +- "1 데몬 = 1 계좌" 불변식(dcy-010 A-2 confirmed)에 따라 응답을 `broker: BrokerItem | None`(단일)으로 정규화하여, 같은 user의 두 데몬이 서로 다른 KIS 계좌를 각각 수신할 수 있게 한다. +- 유일 소비자인 trex-core `hub_read.whoami()`는 `daemon_id`만 읽으며 `brokers` 필드를 참조하지 않으므로, 응답 형태 변경이 소비자에게 무영향임이 확인된 안전한 변경이다(dcy-010 OQ-2 Answered). + +## Requirement +- `GET /v1/user_info`의 응답 스키마를 다음과 같이 변경한다: + - `brokers: list[BrokerItem]` 필드를 제거하고 `broker: BrokerItem | None` 단일 필드로 대체한다. + - `broker`는 service token의 `auth.daemon_id`에 매칭된 인증정보 1건을 복호화하여 반환한다. `daemon_id`에 매칭된 인증정보가 없으면 `broker: null`을 반환한다(graceful degrade — NFR-3). +- service token에 `daemon_id`가 없는 경우(None), 엔드포인트는 HTTP 403 Forbidden을 반환한다. 기존 "daemon_id 없어도 200 반환" 동작을 `broker` 필드에 대해서는 적용하지 않으며, `daemon_id` 자체가 없으면 403으로 거부한다. +- `app/schemas/v1.py`의 `UserInfoResponse` 모델을 `broker: BrokerItem | None = None` 필드로 변경하고 `brokers: list[BrokerItem]`를 제거한다. +- `app/api/v1_user_info.py`의 엔드포인트 구현이 `auth.daemon_id`로 `list_broker_credentials(daemon_id=auth.daemon_id)` 호출 후 결과에서 단일 항목을 복호화·반환하도록 변경한다. +- 인증정보 복호화 실패(잘못된 키, 페이로드 손상)는 기존 graceful degrade 정책을 유지한다: 경고 로깅 후 `credential_payload: null`을 포함한 `BrokerItem`을 반환한다(응답을 크래시시키지 않음). + +## Acceptance Criteria +1. Given daemon_id가 바인딩된 service token으로 GET /v1/user_info 호출, 해당 daemon_id에 매칭된 broker 인증정보가 존재하면, When 호출, Then HTTP 200 + `broker` 필드에 단일 `BrokerItem`(account_ref, credential_payload, credential_version, last_rotated_at 포함) 반환. +2. Given daemon_id가 바인딩된 service token으로 호출, 해당 daemon_id에 매칭된 인증정보가 없으면, When 호출, Then HTTP 200 + `broker: null` 반환 (graceful degrade, 크래시 없음). +3. Given daemon_id가 없는(None) service token으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 Forbidden 반환. +4. Given 같은 user 소유의 daemon A, daemon B에 각각 다른 KIS account_ref가 등록된 상황, When daemon A의 service token으로 호출, Then `broker.account_ref`가 daemon A에 등록된 account_ref와 일치함. +5. Given 같은 상황에서 daemon B의 service token으로 호출, When 호출, Then `broker.account_ref`가 daemon B에 등록된 account_ref와 일치함 (daemon A의 계좌 반환하지 않음). +6. Given `UserInfoResponse` 응답 스키마, When 응답 JSON 확인, Then `brokers` 키가 존재하지 않고 `broker` 단일 키가 존재한다. +7. Given 인증정보 복호화 실패(잘못된 key), When daemon_id에 매칭된 인증정보가 있으나 복호화 실패, Then HTTP 200 + `broker.credential_payload: null` 반환 (경고 로그 기록, 크래시 없음). +8. Given service 토큰이 아닌 user 토큰(web 토큰)으로 호출, When 호출, Then HTTP 403 Forbidden 반환 (기존 service-token gate 유지). +9. Given `runtime_id`, `daemon_id`, `trading_enabled`, `user_id`, `username`, `permission` 필드, When GET /v1/user_info 호출, Then 이 필드들은 기존과 동일하게 반환된다 (broker 변경이 나머지 필드에 영향 없음, 회귀 없음). + +## Impacted Area +- Docs: + - `docs/interface-contract.md` — `GET /v1/user_info` 섹션: `brokers: list` → `broker: object|null` 응답 스키마 갱신, daemon_id 없음 → 403 정책 명시. + - `docs/data-model.md` — `BrokerCredential` State Rules 갱신: 전달 경로에 daemon-scoped 언급 추가. +- Modules: + - `app/schemas/v1.py` — `UserInfoResponse` 모델: `brokers: list[BrokerItem]` → `broker: BrokerItem | None = None`. + - `app/api/v1_user_info.py` — 엔드포인트 구현: user_id 기반 list → daemon_id 기반 단일 조회, daemon_id None → 403. +- Tests: + - `GET /v1/user_info` 단위/통합 테스트 갱신: daemon_id 있음/없음, broker 있음/없음, 2데몬 격리(AC-4/5), 복호화 실패(AC-7), 회귀(AC-9). + - 기존 `brokers` 필드 참조 테스트 → `broker` 단일 필드로 변경. + +## Notes +- **파괴적 변경(breaking change) 해당 여부**: `brokers: list` → `broker: object|null`는 API 응답 스키마 변경이다. 그러나 dcy-010 OQ-2에서 유일 소비자 trex-core `hub_read.whoami()`가 `daemon_id`만 읽고 `brokers` 필드를 참조하지 않음이 확인되었으므로, **실질적 소비자 영향은 없다**. 다만 이 REQ 구현 전 trex-core 소비자 코드 재확인을 권장한다. +- daemon_id 없음 → 403 정책은 dcy-010 FR-2 및 universe 인가(REQ-028 `managed_by_daemon_id` 일치 요구)와 동일한 규율이다. +- `broker: null` graceful degrade(AC-2)는 dcy-010 NFR-3(인증정보 부재가 `daemon_id`, `trading_enabled` 필수 필드를 크래시시키지 않음)을 구현한다. `broker: null`은 그날 거래 개시 차단 신호로 소비자(trex-core)가 해석한다. 이 해석 로직은 trex-core Discovery(별도)에서 구현한다. +- OQ-3(부트스트랩 통합 — universe+인증정보 단일 응답): dcy-010 Deferred. 이 REQ는 인증정보 전달 형태만 확정하며, 부트스트랩 통합은 다루지 않는다. +- Source: dcy-010 FR-2, OQ-2 Answered, A-2, NFR-3. + +## Change Log + +### CHG-20260628-01 +- Date: 2026-06-28 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-010 FR-2 산출물. + +#### Intent +- dcy-010 FR-2(기존 GET /v1/user_info daemon-scoped 확장, 단일 broker 정규화)를 Interface REQ로 정규화한다. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-9 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md` 신규 생성 + - `docs/interface-contract.md`, `docs/data-model.md` 갱신 예고 +- Modules: + - `app/schemas/v1.py` (`UserInfoResponse` 변경), `app/api/v1_user_info.py` (로직 변경) +- Tests: + - user_info 엔드포인트 테스트 전면 갱신 신규 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no (기존 `brokers` 필드 구현은 이 REQ 구현 시 대체됨) +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-046(모델), REQ-047(스키마) 선행 의존. trex-core 소비자 코드 재확인. + +#### Validation Plan +- pytest로 AC-1~9 각 케이스 확인. 2데몬 격리(AC-4/5) 특히 중요. + +### CHG-20260628-02 +- Date: 2026-06-28 +- Author: 팀장(Claude) +- confirm-req: `Proposed` → `Approved`. dcy-010 FR-2/OQ-2/A-2/NFR-3 추적. brokers→단일 broker reshape, daemon_id 없음 403, 2데몬 격리(AC-4/5), graceful degrade(AC-2) 적절. 구현 전 trex-core 소비자 재확인 권고(Notes) 유효. blocker 없음. diff --git a/docs/srs/Interface/req-050_credential-cli-daemon-id-option.md b/docs/srs/Interface/req-050_credential-cli-daemon-id-option.md new file mode 100644 index 0000000..df324df --- /dev/null +++ b/docs/srs/Interface/req-050_credential-cli-daemon-id-option.md @@ -0,0 +1,90 @@ +# REQ-050: credential CLI --daemon-id 옵션 추가 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: Justin Ko + +## Intent +- 현재 `pfhub credential set / list / remove` CLI는 `--user` 옵션만으로 인증정보를 식별한다. daemon-scoped 소유 전환 이후 운영자가 daemon_id를 지정하여 특정 데몬의 인증정보를 등록/조회/삭제할 수 있도록 `--daemon-id` 옵션을 추가한다. +- REQ-047 PK 전환 이후 `broker_credentials` PK가 `(daemon_id, broker)`로 변경되므로, 인증정보 등록/삭제 시 `daemon_id`가 필수 식별자가 된다. + +## Requirement +- `pfhub credential set` 커맨드에 `--daemon-id` 옵션(required, `--daemon-id `)을 추가한다. 이 옵션의 값을 `daemon_id`로 사용하여 `upsert_broker_credential(daemon_id=..., ...)` 호출 시 전달한다. +- `pfhub credential list` 커맨드에 `--daemon-id` 옵션(optional)을 추가한다. 값이 제공되면 해당 daemon_id의 인증정보만 반환한다. 미제공 시 `--user` 소유 전체 인증정보를 반환한다(기존 동작 유지). +- `pfhub credential remove` 커맨드에 `--daemon-id` 옵션(required)을 추가한다. 기존 `(user_id, broker)` 기반 삭제에서 `(daemon_id, broker)` 기반 삭제로 변경한다. +- `--daemon-id` 옵션 값은 daemon_slug 또는 daemon UUID 중 하나를 허용한다. 내부에서 `Daemon` 조회로 daemon_id(UUID)를 해석한다. daemon이 존재하지 않으면 오류 메시지 출력 후 exit 1. +- CLI 출력에 `daemon_id` 필드를 포함한다(기존 `user_id` 출력 유지, 추가). + +## Acceptance Criteria +1. Given `pfhub credential set --user u1 --daemon-id d1 --broker kis --account-ref "123" --app-key "k" --app-secret "s"` 실행, When 명령 성공, Then `broker_credentials`에 `daemon_id=d1.id, broker="kis"` 행이 upsert된다. +2. Given `pfhub credential list --user u1 --daemon-id d1` 실행, When 명령 성공, Then daemon_id=d1인 인증정보만 출력된다(daemon_id=d2 인증정보 미출력). +3. Given `pfhub credential list --user u1` (daemon-id 미지정) 실행, When 명령 성공, Then 해당 user의 모든 인증정보가 출력된다(기존 동작 유지). +4. Given `pfhub credential remove --user u1 --daemon-id d1 --broker kis` 실행, When 명령 성공, Then `broker_credentials`에서 `daemon_id=d1.id, broker="kis"` 행이 삭제된다. +5. Given 존재하지 않는 daemon_slug를 `--daemon-id`에 지정, When 명령 실행, Then 오류 메시지("데몬을 찾을 수 없습니다") 출력 후 exit 1. +6. Given `pfhub credential set` 실행 시 `--daemon-id` 옵션 미제공, When 명령 실행, Then 오류 메시지(`--daemon-id` required) 출력 후 exit 1. (REQ-047 PK 전환 이후 daemon_id 없이 등록 불가) +7. Given `pfhub credential set` 실행 성공, When CLI 출력 확인, Then `daemon_id`, `broker`, `account_ref`, `credential_version`, `last_rotated_at` 필드가 모두 포함된다. + +## Impacted Area +- Docs: + - `docs/interface-contract.md` — CLI credential surface 섹션: `--daemon-id` 옵션 추가, daemon-scoped 등록/조회/삭제 예시. +- Modules: + - `app/cli/commands/credential.py` — `set_credential`, `list_credentials`, `remove_credential` 함수에 `--daemon-id` 파라미터 추가. daemon 조회 로직 추가. + - `app/repositories/postgres.py` — `get_daemon_by_slug(daemon_slug)` 또는 동등한 메서드가 필요(기존 존재 여부 확인 후 추가 또는 재사용). +- Tests: + - `set_credential --daemon-id` 단위 테스트(mock repo). + - `list_credentials --daemon-id` 필터 단위 테스트. + - `remove_credential --daemon-id` 단위 테스트. + - daemon 미존재 오류 케이스(AC-5). + - `--daemon-id` 미제공 시 오류(AC-6). + +## Notes +- `--daemon-id` 옵션은 daemon_slug(사람이 읽기 쉬운 식별자) 또는 daemon UUID를 허용할지 여부는 batch design 단계에서 기존 `daemons` 테이블 스키마를 확인하여 확정한다. 기존 CLI의 `--user` 옵션이 username을 받아 내부에서 user_id로 변환하는 패턴과 동일하게 설계한다. +- REQ-047 PK 전환 이전 단계(daemon_id 컬럼은 있으나 nullable)에서는 `--daemon-id` 미제공 시에도 set/remove를 허용할지 결정이 필요하다. 단계적 마이그레이션 기간 중 호환성 정책은 batch design에서 확정한다. 이 REQ의 AC-6은 PK 전환(REQ-047 Step 2) 완료 이후 상태를 기준으로 한다. +- `pfhub credential list` daemon_id 미지정 동작(전체 조회, AC-3)은 PK 전환 이후 daemon_id 컬럼이 NOT NULL이 되어도 유지된다(admin 관리 목적). +- Source: dcy-010 FR-3, §4 C3. + +## Change Log + +### CHG-20260628-01 +- Date: 2026-06-28 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-010 FR-3 산출물. + +#### Intent +- dcy-010 FR-3(인증정보 관리 경로에 daemon_id 바인딩 추가)을 CLI Interface REQ로 정규화한다. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-7 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Interface/req-050_credential-cli-daemon-id-option.md` 신규 생성 + - `docs/interface-contract.md` 갱신 예고 +- Modules: + - `app/cli/commands/credential.py` (옵션 추가), `app/repositories/postgres.py` (daemon 조회 메서드) +- Tests: + - credential CLI daemon-id 옵션 단위 테스트 신규 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-046(모델), REQ-047(스키마) 선행 의존. --daemon-id 필수/선택 단계 정책은 design에서 확정. + +#### Validation Plan +- pytest mock repo 기반 단위 테스트 AC-1~7. daemon 미존재 오류 케이스(AC-5) 포함. + +### CHG-20260628-02 +- Date: 2026-06-28 +- Author: 팀장(Claude) +- confirm-req: `Proposed` → `Approved`. dcy-010 FR-3 추적. set/list/remove --daemon-id 적절. slug vs UUID 해석 + REQ-047 과도기(daemon_id nullable 구간) --daemon-id 필수/선택 정책은 batch design 위임 — AC 무효화 아님(비차단). diff --git a/docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md b/docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md new file mode 100644 index 0000000..50e8708 --- /dev/null +++ b/docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md @@ -0,0 +1,95 @@ +# REQ-047: broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 + +- Status: Approved +- Type: Migration +- Priority: High +- Owner: Justin Ko + +## Intent +- 현재 `broker_credentials` 테이블의 PK는 `(user_id, broker)`로, 같은 user의 두 데몬이 서로 다른 KIS 계좌를 가질 수 없다. PK를 `(daemon_id, broker)`로 전환하여 인증정보를 daemon-scoped로 소유하게 한다. +- dcy-010에서 결정된 단계적 마이그레이션 경로(nullable `daemon_id` 컬럼 추가 → 백필(REQ-048) → PK 전환)에서, 이 REQ는 스키마 변경 단계를 담당한다. PK 전환 전 데이터 백필은 REQ-048에서 별도로 수행한다. +- 이 마이그레이션은 비가역 PK 변경을 포함하는 고위험 작업으로, 팀장/사장님 결재 게이트(외부 비가역 작업)를 거쳐야 한다. + +## Requirement +- Alembic 신규 revision을 작성한다. (down_revision은 현재 최신 head에서 파악) +- **Step 1 (nullable 컬럼 추가)**: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼을 추가한다. 기존 데이터는 `daemon_id=NULL` 상태로 남는다(백필은 REQ-048이 별도 처리). +- **Step 2 (PK 전환 준비)**: 백필 완료(REQ-048) 이후 적용할 별도 revision을 작성한다. 이 revision에서: + - 기존 PK 제약 `(user_id, broker)`를 삭제한다. + - `daemon_id NOT NULL` 제약을 추가한다. + - 새 PK 제약 `(daemon_id, broker)`를 추가한다. + - `user_id` 컬럼은 감사/이력 목적으로 유지한다(삭제 금지). +- `app/repositories/postgres.py`의 `ensure_schema()` 내 `broker_credentials` 정의를 최종 daemon-scoped 스키마(daemon_id NOT NULL, PK=(daemon_id, broker))와 정합하게 갱신한다. 단, `ensure_schema()`의 갱신은 PK 전환 revision 적용 이후 활성화한다. +- downgrade 함수는 PK 전환 이전 상태로 되돌릴 수 있도록 작성한다(daemon_id 컬럼 제거, 기존 PK 복원). 단, PK 전환 revision의 downgrade는 데이터 손실 위험이 있어 실 운영 적용 전 롤백 스크립트를 별도 제공한다. + +## Acceptance Criteria +1. Given Step 1 revision 적용 후, When `\d broker_credentials` 확인, Then `daemon_id TEXT NULL` 컬럼이 존재하고 기존 PK `(user_id, broker)` 제약이 그대로 유지된다. +2. Given Step 1 revision 적용 후, When 기존 행을 조회, Then `daemon_id`가 NULL인 상태로 데이터가 보존된다. +3. Given Step 2(PK 전환) revision 적용 후 (REQ-048 백필 완료 이후), When `\d broker_credentials` 확인, Then PK 제약이 `(daemon_id, broker)`로 변경되고 `daemon_id NOT NULL` 제약이 존재한다. +4. Given AC-3 환경, When `user_id` 컬럼 존재 확인, Then `user_id` 컬럼이 여전히 존재한다(삭제되지 않음). +5. Given AC-3 환경, When `ensure_schema()` 단독 실행, Then `broker_credentials` 스키마가 마이그레이션과 동일하다(daemon_id NOT NULL, PK=(daemon_id, broker), user_id 존재). +6. Given Step 1 revision의 downgrade 실행, When `\d broker_credentials` 확인, Then `daemon_id` 컬럼이 제거되고 기존 PK `(user_id, broker)` 제약이 복원된다. +7. Given Step 2 revision의 downgrade 실행, When 실행, Then downgrade가 실패하거나 데이터 손실 경고를 명시적으로 표시한다(안전 장치 존재). + +## Impacted Area +- Docs: + - `docs/data-model.md` — `BrokerCredential` 엔티티 PK 변경(`(user_id, broker)` → `(daemon_id, broker)`), `user_id` 유지 명시, daemon-scoped 소유 정책 기술. +- Modules: + - `migrations/versions/` — Step 1 revision (nullable daemon_id 추가), Step 2 revision (PK 전환). down_revision은 현재 최신 head. + - `app/repositories/postgres.py` — `ensure_schema()` 내 `broker_credentials` 정의 갱신(PK 전환 revision 이후). +- Tests: + - alembic upgrade head 후 schema 검증(AC-1~5). + - Step 1 downgrade 테스트(AC-6). + - 기존 broker credential CRUD 회귀 테스트(마이그레이션 전후 데이터 보존). + +## Notes +- 이 마이그레이션은 **PK 변경**이라는 비가역 작업을 포함한다. 팀장/사장님 결재 게이트 대상이다(dcy-010 R-1, §범위 경계 — "DB 마이그레이션"은 외부 비가역 작업). 실 운영 DB 적용 전 반드시 결재를 받는다. +- Step 1과 Step 2를 별도 revision으로 분리하는 이유: REQ-048 백필이 완료된 후에만 PK 전환이 가능하기 때문이다. Step 1만 먼저 적용하고, REQ-048 백필 → Step 2 순서로 진행한다. +- `user_id` 컬럼을 유지하는 이유: 기존 인증정보의 소유자 이력 보존 및 admin 조회 기능 유지. +- daemon-scoped 강제(user 폴백 없음): dcy-010 OQ-1 결정. `daemon_id=NULL`인 인증정보는 PK 전환 이후 INSERT 불가(NOT NULL 제약). 기존 NULL 데이터는 REQ-048 백필 이후만 PK 전환이 가능하다. +- Step 2 downgrade는 daemon_id → user_id PK 복원 시 데이터 무결성을 보장하기 어려우므로, 운영 환경에서는 rollback 전 별도 데이터 검증이 필요하다. +- Source: dcy-010 FR-1, R-1 완화 방안, §4 C1. + +## Change Log + +### CHG-20260628-01 +- Date: 2026-06-28 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-010 FR-1 산출물 (마이그레이션 계층 분리). + +#### Intent +- dcy-010 FR-1의 스키마 PK 전환 작업을 독립 마이그레이션 REQ로 정규화한다. Step 1(nullable 추가)과 Step 2(PK 전환)를 별도 revision으로 분리하는 설계를 반영한다. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-7 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md` 신규 생성 + - `docs/data-model.md` 갱신 예고 +- Modules: + - `migrations/versions/` (Step 1, Step 2 revision 신규), `app/repositories/postgres.py` 갱신 예고 +- Tests: + - alembic 스키마 검증, downgrade, CRUD 회귀 신규 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 (비가역 — 사장님 결재 필요) + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-048(백필)과 순서 의존 관계 명시. + +#### Validation Plan +- alembic upgrade head 후 schema 검증(AC-1~5). Step 1 downgrade 검증(AC-6). 개발 DB에서 dry-run. + +### CHG-20260628-02 +- Date: 2026-06-28 +- Author: 팀장(Claude) +- confirm-req: `Proposed` → `Approved`. dcy-010 FR-1/R-1 추적. Step1/Step2 분리 + REQ-048 백필 사이 순서 정합. **Step2 PK 전환(실DB 적용)은 비가역 → 사장님 결재 게이트 대상**(Notes 명시 확인). blocker 없음. diff --git a/docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md b/docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md new file mode 100644 index 0000000..a06193c --- /dev/null +++ b/docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md @@ -0,0 +1,91 @@ +# REQ-048: 기존 user-scoped 인증정보 daemon_id 백필 + +- Status: Approved +- Type: Migration +- Priority: High +- Owner: Justin Ko + +## Intent +- REQ-047 Step 1 적용 이후 `broker_credentials.daemon_id`가 NULL인 기존 행들을 운영자가 지정하는 daemon_id로 백필하여, REQ-047 Step 2(PK 전환)가 적용 가능한 상태로 만든다. +- dcy-010 FR-4(기존 user-scoped 인증정보를 손실 없이 daemon-scoped로 이전)의 데이터 이전 경로를 제공한다. 백필은 CLI 명령 또는 one-shot 마이그레이션 스크립트로 수행한다. + +## Requirement +- `pfhub credential backfill-daemon-id` CLI 명령(또는 동등한 alembic data migration revision)을 제공한다. 이 명령은 다음을 수행한다: + - `broker_credentials`에서 `daemon_id IS NULL`인 행을 조회한다. + - 각 행의 `user_id`에 대해 운영자가 제공한 매핑(`--user --daemon-id `)으로 `daemon_id`를 UPDATE한다. + - 백필 대상 행이 없으면 "이미 완료됨" 메시지를 출력하고 정상 종료한다. + - `--dry-run` 옵션을 제공하여 실제 UPDATE 없이 대상 행과 매핑 결과를 출력한다. +- 백필 완료 후 `daemon_id IS NULL`인 행이 0건임을 검증하는 단계를 명령 결과 또는 별도 verify 서브커맨드로 제공한다. +- 기존 행의 암호화된 인증정보(`encrypted_payload`, `account_ref`, `credential_version`, `last_rotated_at`)는 백필 과정에서 변경되지 않는다. +- `--force` 옵션으로 확인 프롬프트를 생략할 수 있다. + +## Acceptance Criteria +1. Given `broker_credentials`에 `daemon_id=NULL`인 행이 있고 `--user u1 --daemon-id d1` 매핑을 제공하면, When `backfill-daemon-id` 명령 실행, Then 해당 `user_id=u1(user.id)`에 해당하는 행의 `daemon_id`가 `d1`로 UPDATE된다. +2. Given AC-1 실행 후, When `broker_credentials`에서 `daemon_id IS NULL` 행 조회, Then 0건이 반환된다. +3. Given `--dry-run` 옵션으로 실행, When 백필 대상 행이 있으면, Then DB UPDATE 없이 대상 행과 예상 매핑이 출력되고 실제 데이터는 변경되지 않는다. +4. Given 백필 실행 중, When `encrypted_payload`, `account_ref`, `credential_version`, `last_rotated_at` 컬럼 확인, Then 백필 전후 값이 동일하다(데이터 손실 없음). +5. Given `daemon_id IS NULL`인 행이 없을 때, When `backfill-daemon-id` 명령 실행, Then "이미 완료됨" 메시지와 함께 정상 종료(exit 0)된다. +6. Given 존재하지 않는 user를 `--user` 옵션으로 지정, When 명령 실행, Then 오류 메시지와 함께 비정상 종료(exit 1)된다. +7. Given 백필 완료 후 REQ-047 Step 2(PK 전환) revision 적용, When `\d broker_credentials` 확인, Then PK `(daemon_id, broker)` 제약이 적용되고 NOT NULL 위반 없이 성공한다. + +## Impacted Area +- Docs: + - `docs/data-model.md` — `BrokerCredential` 데이터 이전 절차(백필 단계) 명시. +- Modules: + - `app/cli/commands/credential.py` — `backfill-daemon-id` 서브커맨드 추가. + - `app/repositories/postgres.py` — `update_broker_credential_daemon_id(user_id, daemon_id)` 또는 동등한 메서드 추가(또는 CLI에서 직접 쿼리). +- Tests: + - `backfill-daemon-id` 명령 단위 테스트(mock repo 기반): 정상 백필, dry-run, 이미 완료, 사용자 미존재 케이스. + - 백필 전후 `encrypted_payload` 동등성 검증(AC-4). + +## Notes +- 이 REQ는 **1회성 운영 작업**이다. 개발 환경에서는 테스트 목적으로 반복 실행 가능하도록 설계한다. +- 백필 매핑은 운영자가 직접 제공한다. "어떤 user가 어떤 daemon_id에 매핑되는가"는 인프라/운영 도메인 지식이 필요하며 자동화할 수 없다. +- CLI 구현 방식(subcommand 추가 vs alembic data migration revision)은 batch design 단계에서 확정한다. 어느 쪽이든 AC-1~7을 만족해야 한다. +- `--user` 옵션은 username으로 받고 내부에서 `get_user_by_username()`으로 user_id를 해석한다(기존 CLI 패턴과 동일). +- Source: dcy-010 FR-4, §4 C1(마이그레이션 단계적 경로). + +## Change Log + +### CHG-20260628-01 +- Date: 2026-06-28 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-010 FR-4 산출물. + +#### Intent +- dcy-010 FR-4(기존 user-scoped 인증정보 손실 없는 daemon-scoped 이전)를 독립 REQ로 정규화한다. REQ-047 Step 1과 Step 2 사이의 데이터 이전 경로를 담당한다. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-7 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md` 신규 생성 + - `docs/data-model.md` 갱신 예고 +- Modules: + - `app/cli/commands/credential.py` (backfill subcommand), `app/repositories/postgres.py` (업데이트 메서드) +- Tests: + - backfill CLI 단위 테스트 신규 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-047 Step 1 이후, Step 2 이전 순서 의존 관계 준수. + +#### Validation Plan +- pytest mock repo 기반 단위 테스트 AC-1~6. 개발 DB 통합: backfill → REQ-047 Step 2 순서 검증(AC-7). + +### CHG-20260628-02 +- Date: 2026-06-28 +- Author: 팀장(Claude) +- confirm-req: `Proposed` → `Approved`. dcy-010 FR-4 추적. dry-run/verify·암호화 payload 불변(AC-4) 적절. 구현방식(CLI subcommand vs alembic data revision)은 batch design 위임 — AC 무효화 아님(비차단). diff --git a/docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md b/docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md new file mode 100644 index 0000000..b9d6e60 --- /dev/null +++ b/docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md @@ -0,0 +1,96 @@ +# REQ-051: 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단 + +- Status: Approved +- Type: Non-Functional +- Priority: High +- Owner: Justin Ko + +## Intent +- 복호화된 app_secret(KIS API 비밀키)은 실계좌 실자금에 직결되는 최민감 시크릿이다. 이 정보가 복호화된 형태로 service-token 경로 외 다른 경로(web user 토큰, admin 토큰, 비인증 접근)로 유출되지 않아야 한다. +- portfolio-hub가 M3에서 trex-web 프론트에 노출될 예정이므로, web 노출 표면과 인증정보 전달 경로의 격리를 명시적으로 요구사항으로 정의하고 테스트로 검증한다. +- dcy-010 NFR-1(보안 격리), NFR-2(일관성), NFR-3(가용성/degrade)의 독립 측정·검증 필요성을 근거로 별도 REQ로 승격한다. + +## Requirement +- `GET /v1/user_info`는 service token(token_source="db", token_type="service")으로만 접근 가능하다. web user 토큰(token_type="session" 또는 client 타입), 비인증 요청, admin 토큰(token_type이 service가 아닌 경우)은 HTTP 403을 반환해야 한다. +- 복호화된 `credential_payload`(`app_secret` 포함)는 오직 `GET /v1/user_info` 응답을 통해서만 반환되어야 하며, 다른 엔드포인트(portfolio, asset, health, admin 등)의 응답에 포함되어서는 안 된다. +- `GET /v1/user_info`에서 daemon_id가 없는(None) 토큰은 HTTP 403으로 거부한다(REQ-049와 동일 — 보안 정책으로도 명시). +- 인증정보 조회·복호화 실패(DB 오류, 복호화 실패)는 엔드포인트 전체를 크래시시키지 않는다. 실패 시 `broker: null`(또는 `credential_payload: null`)을 반환하고 경고 로그를 남긴다. +- 인증정보 관련 조회 로그에는 `credential_payload`의 내용(app_key, app_secret)이 포함되어서는 안 된다. 로그에는 broker 이름, daemon_id, 오류 유형만 기록한다. +- universe의 daemon-scoped 인가(`managed_by_daemon_id` 일치 검사, REQ-028)와 동일한 규율로, broker 인증정보 접근도 daemon_id 일치를 강제한다(daemon_id 불일치 → 403, 다른 데몬의 인증정보 반환 불가). + +## Acceptance Criteria +1. Given web user 토큰(token_type이 service가 아닌 token)으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 반환(credential_payload 미노출). +2. Given 비인증 요청으로 GET /v1/user_info 호출, When 호출, Then HTTP 401 반환. +3. Given daemon_id=None인 service token으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 반환. +4. Given daemon_id="d1"인 service token으로 GET /v1/user_info 호출, When DB에 daemon_id="d2"의 인증정보만 존재하면, Then `broker: null` 반환 (다른 데몬의 인증정보 절대 반환하지 않음). +5. Given GET /v1/user_info 응답 외 다른 엔드포인트(GET /v1/portfolios, GET /v1/assets, GET /health 등) 응답, When 응답 JSON 확인, Then `credential_payload`, `app_secret`, `app_key` 필드가 포함되지 않는다. +6. Given DB 오류로 `list_broker_credentials` 실패, When GET /v1/user_info 호출, Then HTTP 200 + `broker: null` 반환 (500 크래시 없음), 경고 로그 기록. +7. Given Fernet 복호화 실패(잘못된 `CREDENTIAL_ENCRYPTION_KEY`), When GET /v1/user_info 호출, Then HTTP 200 + `broker.credential_payload: null` 반환, 경고 로그 기록(app_secret 로그 미노출). +8. Given GET /v1/user_info 정상 처리 중 로그 검사, When 로그 출력 확인, Then `app_key`, `app_secret` 값이 로그에 출력되지 않는다. + +## Impacted Area +- Docs: + - `docs/interface-contract.md` — GET /v1/user_info 보안 정책 섹션: service-token 전용, daemon_id 없음 → 403, web 토큰 차단, credential 로그 비노출 명시. +- Modules: + - `app/api/v1_user_info.py` — `_require_service_token` 함수(기존 service-token gate, 변경 없거나 daemon_id 검사 보완). + - `app/api/v1_user_info.py` — 로깅 경고 메시지에서 credential 값 제외 확인. +- Tests: + - web 토큰 → 403 테스트(AC-1). + - 비인증 → 401 테스트(AC-2). + - daemon_id=None → 403 테스트(AC-3). + - 다른 데몬 인증정보 → null 테스트(AC-4). + - 타 엔드포인트 credential 미노출 테스트(AC-5): 주요 엔드포인트 응답 필드 검사. + - DB 오류 graceful degrade 테스트(AC-6). + - 복호화 실패 degrade 테스트(AC-7): 기존 테스트 확인/보완. + +## Notes +- dcy-010 NFR-1(보안 격리), NFR-2(일관성), NFR-3(degrade)가 모두 독립 측정 가능하고 테스트로 검증할 수 있어 별도 REQ로 승격한다. 이 세 NFR을 단일 REQ로 묶은 것은 모두 동일한 엔드포인트(GET /v1/user_info)의 보안 속성에 관한 것이기 때문이다. +- AC-4(다른 데몬 인증정보 반환 불가)는 REQ-049 AC-4/5와 overlap하지만, 보안 관점의 요구사항으로 이 REQ에서 독립적으로 추적한다. +- 로그 비노출(AC-8)은 현재 구현의 `logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc)` 패턴이 app_secret을 exc에 포함할 가능성을 배제해야 함을 의미한다. 구현 시 확인이 필요하다. +- `CREDENTIAL_ENCRYPTION_KEY` 자체의 보안 관리(환경변수 주입, 키 회전 정책)는 dcy-010 A-3(외부 시크릿 매니저 Out of Scope)에 따라 이 REQ의 범위 밖이다. 현재 환경변수 기반 유지. +- Source: dcy-010 NFR-1, NFR-2, NFR-3. + +## Change Log + +### CHG-20260628-01 +- Date: 2026-06-28 +- Author: Justin Ko + +#### Change Summary +- 신규 생성 (Proposed) — dcy-010 NFR-1/2/3 산출물. + +#### Intent +- dcy-010 NFR-1(보안 격리)·NFR-2(일관성)·NFR-3(degrade) 세 NFR을 독립 측정·테스트 가능한 요구사항으로 묶어 Non-Functional REQ로 정규화한다. + +#### Acceptance Criteria Delta +- Added: + - AC-1 ~ AC-8 전체 (신규 초안) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: + - `docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md` 신규 생성 + - `docs/interface-contract.md` 갱신 예고 +- Modules: + - `app/api/v1_user_info.py` 보안 게이트 및 로깅 확인 +- Tests: + - 보안 격리 테스트 신규(AC-1~4, AC-8), degrade 테스트(AC-6/7) + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 대기) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-049와 동일 batch에 묶어 전달하는 것이 자연스럽다. + +#### Validation Plan +- pytest로 AC-1~8 각 케이스 확인. AC-8(로그 비노출)은 caplog fixture로 검증. + +### CHG-20260628-02 +- Date: 2026-06-28 +- Author: 팀장(Claude) +- confirm-req: `Proposed` → `Approved`. dcy-010 NFR-1/2/3 흡수 적절(동일 엔드포인트 보안속성). service-token 전용·web 토큰 차단·타 엔드포인트 credential 미노출(AC-5)·로그 비노출(AC-8)·degrade(AC-6/7) testable. REQ-049와 동일 batch 권고. blocker 없음. diff --git a/docs/srs/index.md b/docs/srs/index.md index be8c1f9..0e247fd 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-046` +- Current: `REQ-052` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -126,12 +126,19 @@ | REQ-043 | GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-043](./Interface/req-043_holdings-cost-basis-read-endpoint.md) | | REQ-044 | GET /v1/assets 권한 완화 — require_admin → require_read | Interface | Implemented | High | Justin Ko | [req-044](./Interface/req-044_get-v1-assets-require-read-relaxation.md) | | REQ-045 | 사용자 가용 데몬 목록 read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-045](./Interface/req-045_user-daemon-list-endpoint.md) | +| REQ-046 | BrokerCredential 도메인 모델 daemon_id 필드 추가 | Data | Approved | High | Justin Ko | [req-046](./Data/req-046_broker-credential-daemon-id-field.md) | +| REQ-047 | broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 | Migration | Approved | High | Justin Ko | [req-047](./Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md) | +| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | Approved | High | Justin Ko | [req-048](./Migration/req-048_broker-credentials-daemon-id-backfill.md) | +| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달로 확장 | Interface | Approved | High | Justin Ko | [req-049](./Interface/req-049_user-info-daemon-scoped-broker-delivery.md) | +| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | Approved | High | Justin Ko | [req-050](./Interface/req-050_credential-cli-daemon-id-option.md) | +| REQ-051 | 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단 | Non-Functional | Approved | High | Justin Ko | [req-051](./Non-Functional/req-051_broker-credential-security-isolation.md) | ## Recent Change Log Summary +- 2026-06-28: REQ-046~051 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-010 daemon-scoped broker credential. 전건 dcy-010 FR/NFR 추적·AC testable·blocker 0 확인. 단계적 마이그레이션(REQ-047 Step1/2)·**REQ-047 Step2 PK 전환은 비가역 결재 게이트**·2데몬 격리(REQ-049 AC-4/5)·보안 격리(REQ-051) 반영. 비차단 design 위임 2건(REQ-048 구현방식, REQ-050 slug/UUID·과도기 정책). +- 2026-06-28: REQ-046~051 신규 (Proposed) — dcy-010 confirmed Discovery(daemon-scoped broker credential provisioning) draft-req. REQ-046(Data: 도메인 모델 daemon_id 필드), REQ-047(Migration: PK 전환 Step1/2), REQ-048(Migration: daemon_id 백필 CLI), REQ-049(Interface: GET /v1/user_info daemon-scoped 단일 broker 전달), REQ-050(Interface: credential CLI --daemon-id 옵션), REQ-051(Non-Functional: 보안 격리). - 2026-06-27: REQ-025 name 검증 완화(CHG-20260627-03) — `_PORTFOLIO_NAME_RE` 한글(가-힣)+공백 허용(`^[a-zA-Z0-9가-힣 ._:\-]{1,100}$`). minor-change. 기존 AC 유지, 테스트 3개 추가. - 2026-06-27: REQ-044/045 `Approved` → `Implemented` — BAT-019 PR #16 main 머지(`7f15c6b`) QC PASS. 472 passed/0 failed. REQ-045 env토큰(user_id=None) 처리 Notes 정정: "HTTP 403" → "빈 목록 [] HTTP 200"(change-req CHG-20260627-02). - 2026-06-27: REQ-044/045 신규 (Proposed) — dcy-009 FR-1(GET /v1/assets require_read 완화)·FR-2(사용자 가용 데몬 목록 엔드포인트 신설) REQ 초안 작성. 엔드포인트 경로·스키마(OQ-2) 및 가용 데몬 의미(OQ-3)는 design Deferred. - 2026-06-26: REQ-041 WAC 공식 change-req(CHG-20260626-01) — 매수 수수료 cost basis 편입(사장님 결정). 신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 적용. AC-9/AC-10 추가. 신규 테스트 9개 포함 456 passed / 0 failed. REQ-040/REQ-043 Notes 참조 갱신. -- 2026-06-26: REQ-043 `Approved` → `Implemented` — BAT-018 PR #14 squash-merge(d93c0bc) 팀장 QC PASS 후 origin/main 머지 완료. AC-1~11 전부 PASS(447 passed / 0 failed). 배포 별도(release-candidate). - 2026-06-25: REQ-043 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-008 근거 Discovery 양방향 링크 확정. OQ-1(service token 소유권 blocker) 해소: cold-start는 `managed_by_daemon_id=daemon` 포트폴리오만 조회하므로 null 케이스 blocker 아님, daemon scope 소유권 정책(REQ-029 선례) 유지 확정. OQ-2(domain 함수) 구현 단계 재량 위임. AC-1~11 유지. diff --git a/migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py b/migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py new file mode 100644 index 0000000..2386e7a --- /dev/null +++ b/migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py @@ -0,0 +1,69 @@ +"""broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1) + +Revision ID: 20260628_0026 +Revises: 20260605_0025 +Create Date: 2026-06-28 00:00:00 + +NOTE: 이 revision은 REQ-047 Step1 — nullable daemon_id 컬럼 추가. +기존 데이터는 daemon_id=NULL 상태로 보존된다(백필은 REQ-048 CLI 별도 수행). +Step2(PK 전환)는 20260628_0027_broker_credential_daemon_id_step2.py에서 수행. +Step2는 비가역(PK 변경)이므로 사장님 결재 후 별도 적용. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260628_0026" +down_revision = "20260605_0025" +branch_labels = None +depends_on = None + + +def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool: + if not _table_exists(inspector, table_name): + return False + return any(c["name"] == column_name for c in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + # broker_credentials에 daemon_id TEXT NULL 컬럼 추가 + # 기존 PK (user_id, broker)는 그대로 유지 + if _table_exists(inspector, "broker_credentials"): + if not _column_exists(inspector, "broker_credentials", "daemon_id"): + op.add_column( + "broker_credentials", + sa.Column("daemon_id", sa.Text(), nullable=True), + ) + # daemon_id 인덱스 추가 (조회 성능) + op.create_index( + "idx_broker_credentials_daemon_id", + "broker_credentials", + ["daemon_id"], + unique=False, + postgresql_where=sa.text("daemon_id IS NOT NULL"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _table_exists(inspector, "broker_credentials"): + # 인덱스 먼저 제거 + existing_indexes = [idx["name"] for idx in inspector.get_indexes("broker_credentials")] + if "idx_broker_credentials_daemon_id" in existing_indexes: + op.drop_index("idx_broker_credentials_daemon_id", table_name="broker_credentials") + + if _column_exists(inspector, "broker_credentials", "daemon_id"): + op.drop_column("broker_credentials", "daemon_id") diff --git a/migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py b/migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py new file mode 100644 index 0000000..6e95c73 --- /dev/null +++ b/migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py @@ -0,0 +1,144 @@ +"""broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2) + +Revision ID: 20260628_0027 +Revises: 20260628_0026 +Create Date: 2026-06-28 00:00:00 + +CRITICAL: 이 revision은 비가역 PK 변경을 포함한다. +- 실 운영 DB 적용 전 사장님 결재 필수 (dcy-010 R-1, 외부 비가역 작업 게이트) +- REQ-048 backfill-daemon-id CLI로 모든 행의 daemon_id가 채워진 이후에만 적용 가능 + (NOT NULL 제약으로 인해 daemon_id=NULL 행 존재 시 upgrade 실패) +- downgrade는 데이터 손실 위험 — 운영 환경에서 rollback 전 별도 데이터 검증 필요 + +적용 순서: + 1. 20260628_0026 (Step1) — daemon_id NULL 컬럼 추가 + 2. pfhub credential backfill-daemon-id (REQ-048 CLI) + 3. 20260628_0027 (Step2, 이 파일) — PK 전환 (사장님 결재 후 적용) +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect, text + + +revision = "20260628_0027" +down_revision = "20260628_0026" +branch_labels = None +depends_on = None + + +def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool: + if not _table_exists(inspector, table_name): + return False + return any(c["name"] == column_name for c in inspector.get_columns(table_name)) + + +def _constraint_exists(bind, table_name: str, constraint_name: str) -> bool: + result = bind.execute(text( + """ + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = :table AND constraint_name = :cname + """ + ), {"table": table_name, "cname": constraint_name}) + return result.fetchone() is not None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not _table_exists(inspector, "broker_credentials"): + return + + # 사전 검증: daemon_id IS NULL인 행이 있으면 upgrade 거부 + result = bind.execute(text( + "SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL" + )) + null_count = result.scalar() + if null_count and null_count > 0: + raise RuntimeError( + f"broker_credentials에 daemon_id IS NULL인 행이 {null_count}건 있습니다. " + "REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요." + ) + + # Step 1: daemon_id NOT NULL 제약 추가 + if _column_exists(inspector, "broker_credentials", "daemon_id"): + op.alter_column("broker_credentials", "daemon_id", nullable=False) + + # Step 2: 기존 PK (user_id, broker) 제거 + # PostgreSQL: PK 이름 찾기 + pk_constraints = [ + c for c in inspector.get_pk_constraint("broker_credentials").get("constrained_columns", []) + ] + # PK 제약 이름을 직접 조회 + bind.execute(text( + """ + ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey + """ + )) + + # Step 3: 새 PK (daemon_id, broker) 추가 + op.create_primary_key( + "broker_credentials_pkey", + "broker_credentials", + ["daemon_id", "broker"], + ) + + # NOTE: user_id 컬럼은 감사/이력 목적으로 유지 (삭제 금지, REQ-047 AC-4) + + +def downgrade() -> None: + """ + WARN: 이 downgrade는 데이터 손실 위험을 수반한다. + (daemon_id, broker) PK를 (user_id, broker)로 복원할 때, + 같은 user_id + broker 조합이 여러 daemon에 존재하면 충돌이 발생한다. + 운영 환경에서는 downgrade 전 반드시 별도 데이터 검증을 수행하라. + """ + bind = op.get_bind() + inspector = inspect(bind) + + if not _table_exists(inspector, "broker_credentials"): + return + + # 안전장치: 실 운영 환경에서 downgrade 시도 경고 + # (REQ-047 AC-7: downgrade 실패 또는 명시적 경고 존재) + result = bind.execute(text( + """ + SELECT COUNT(*) FROM ( + SELECT user_id, broker, COUNT(*) as cnt + FROM broker_credentials + GROUP BY user_id, broker + HAVING COUNT(*) > 1 + ) t + """ + )) + conflict_count = result.scalar() + if conflict_count and conflict_count > 0: + raise RuntimeError( + f"downgrade 불가: {conflict_count}개 (user_id, broker) 조합이 중복됩니다. " + "같은 user의 여러 daemon이 동일 broker를 사용하고 있어 " + "(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. " + "수동으로 데이터를 정리한 후 downgrade하세요." + ) + + # PK 전환 역방향: (daemon_id, broker) → (user_id, broker) + bind.execute(text( + "ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey" + )) + + # daemon_id 컬럼을 nullable로 복원 + if _column_exists(inspector, "broker_credentials", "daemon_id"): + op.alter_column("broker_credentials", "daemon_id", nullable=True) + + # (user_id, broker) PK 복원 + op.create_primary_key( + "broker_credentials_pkey", + "broker_credentials", + ["user_id", "broker"], + ) diff --git a/tests/test_bat020_broker_credential_domain.py b/tests/test_bat020_broker_credential_domain.py new file mode 100644 index 0000000..4c9219b --- /dev/null +++ b/tests/test_bat020_broker_credential_domain.py @@ -0,0 +1,178 @@ +"""BAT-020: REQ-046 BrokerCredential daemon_id 필드 단위 테스트.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.domain.models import BrokerCredential + + +# --------------------------------------------------------------------------- +# REQ-046 AC-1: daemon_id=None 기본값 +# --------------------------------------------------------------------------- + +def test_broker_credential_daemon_id_defaults_to_none() -> None: + """REQ-046 AC-1: daemon_id 미지정 시 None이 기본값.""" + cred = BrokerCredential( + user_id="user-1", + broker="kis", + account_ref="12345678", + encrypted_payload="encrypted", + ) + assert hasattr(cred, "daemon_id") + assert cred.daemon_id is None + + +# --------------------------------------------------------------------------- +# REQ-046 AC-2: daemon_id 값 지정 +# --------------------------------------------------------------------------- + +def test_broker_credential_daemon_id_set() -> None: + """REQ-046 AC-2: daemon_id="daemon-abc" 지정 시 해당 값으로 세팅.""" + cred = BrokerCredential( + user_id="user-1", + broker="kis", + account_ref="12345678", + encrypted_payload="encrypted", + daemon_id="daemon-abc", + ) + assert cred.daemon_id == "daemon-abc" + + +# --------------------------------------------------------------------------- +# REQ-046 AC-3: _row_to_broker_credential 7컬럼(daemon_id 포함) 처리 +# --------------------------------------------------------------------------- + +def test_row_to_broker_credential_with_daemon_id() -> None: + """REQ-046 AC-3: 7컬럼 행(daemon_id 포함) 변환 시 daemon_id 세팅.""" + from app.repositories.postgres import PostgresPortfolioRepository + + now = datetime.now(timezone.utc) + row = ("user-1", "kis", "12345678", "encrypted", 2, now, "daemon-xyz") + cred = PostgresPortfolioRepository._row_to_broker_credential(row) + assert cred.user_id == "user-1" + assert cred.broker == "kis" + assert cred.account_ref == "12345678" + assert cred.encrypted_payload == "encrypted" + assert cred.credential_version == 2 + assert cred.last_rotated_at == now + assert cred.daemon_id == "daemon-xyz" + + +# --------------------------------------------------------------------------- +# REQ-046 AC-4: _row_to_broker_credential 6컬럼(daemon_id 없음) 하위호환 +# --------------------------------------------------------------------------- + +def test_row_to_broker_credential_without_daemon_id() -> None: + """REQ-046 AC-4: 6컬럼 행(daemon_id 없음) 변환 시 daemon_id=None (하위호환).""" + from app.repositories.postgres import PostgresPortfolioRepository + + now = datetime.now(timezone.utc) + row = ("user-1", "kis", "12345678", "encrypted", 1, now) + cred = PostgresPortfolioRepository._row_to_broker_credential(row) + assert cred.daemon_id is None + + +# --------------------------------------------------------------------------- +# REQ-046 AC-5/6: list_broker_credentials daemon_id 필터 (mock 기반) +# --------------------------------------------------------------------------- + +class _FakeCursor: + """psycopg Cursor 최소 mock.""" + def __init__(self, rows): + self._rows = rows + self.last_query = None + self.last_params = None + + def execute(self, query, params=None): + self.last_query = query + self.last_params = params + + def fetchall(self): + return self._rows + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +class _FakeConn: + def __init__(self, rows): + self._rows = rows + + def cursor(self): + return _FakeCursor(self._rows) + + +def _make_rows_for_user(user_id: str): + """daemon_id "d1", "d2" 각각 1건씩.""" + now = datetime.now(timezone.utc) + return [ + (user_id, "kis", "ref-d1", "enc-d1", 1, now, "d1"), + (user_id, "kis2", "ref-d2", "enc-d2", 1, now, "d2"), + ] + + +def test_list_broker_credentials_daemon_id_filter() -> None: + """REQ-046 AC-5: daemon_id 필터 시 해당 daemon_id 행만 반환.""" + from unittest.mock import MagicMock, patch + from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig + + rows = _make_rows_for_user("user-1") + # daemon_id="d1" 필터 시 d1 행만 반환되는 mock + d1_rows = [rows[0]] + + config = PortfolioDBConfig( + host="localhost", port=5432, dbname="test", + user="test", password="test", sslmode="disable", + ) + repo = PostgresPortfolioRepository(config) + + mock_cursor = MagicMock() + mock_cursor.__enter__ = lambda s: mock_cursor + mock_cursor.__exit__ = MagicMock(return_value=False) + mock_cursor.fetchall.return_value = d1_rows + + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cursor + repo._connection = mock_conn + + # ensure_schema 무력화 + with patch.object(repo, "ensure_schema"): + results = repo.list_broker_credentials("user-1", daemon_id="d1") + + assert len(results) == 1 + assert results[0].daemon_id == "d1" + assert results[0].broker == "kis" + + +def test_list_broker_credentials_no_daemon_id_filter_returns_all() -> None: + """REQ-046 AC-6: daemon_id 미지정 시 전체 반환 (하위호환).""" + from unittest.mock import MagicMock, patch + from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig + + rows = _make_rows_for_user("user-1") + + config = PortfolioDBConfig( + host="localhost", port=5432, dbname="test", + user="test", password="test", sslmode="disable", + ) + repo = PostgresPortfolioRepository(config) + + mock_cursor = MagicMock() + mock_cursor.__enter__ = lambda s: mock_cursor + mock_cursor.__exit__ = MagicMock(return_value=False) + mock_cursor.fetchall.return_value = rows + + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cursor + repo._connection = mock_conn + + with patch.object(repo, "ensure_schema"): + results = repo.list_broker_credentials("user-1") + + assert len(results) == 2 diff --git a/tests/test_bat020_credential_cli.py b/tests/test_bat020_credential_cli.py new file mode 100644 index 0000000..5bcfa4f --- /dev/null +++ b/tests/test_bat020_credential_cli.py @@ -0,0 +1,460 @@ +"""BAT-020: REQ-050, REQ-048 — credential CLI --daemon-id + backfill 테스트.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +import pytest +from typer.testing import CliRunner + +from app.cli.main import create_app +from app.core.config import Settings +from app.core.encryption import encrypt_payload +from app.core.security import hash_token, token_prefix +from app.domain.models import AuthToken, BrokerCredential, Daemon, User + + +_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + +# --------------------------------------------------------------------------- +# Minimal FakeRepo for credential CLI tests +# --------------------------------------------------------------------------- + +class CredFakeRepo: + """credential CLI 테스트용 최소 FakeRepo.""" + + def __init__(self) -> None: + self._users: dict[str, User] = {} + self._daemons: dict[str, Daemon] = {} + self._broker_credentials: dict[tuple, BrokerCredential] = {} + self._seq = 1 + + def close(self) -> None: + pass + + def ping(self) -> None: + pass + + def ensure_schema(self) -> None: + pass + + def get_user(self, user_id: str) -> User: + u = self._users.get(user_id) + if u is None: + raise ValueError("사용자를 찾을 수 없습니다") + return u + + def get_user_by_username(self, username: str) -> User | None: + return next((u for u in self._users.values() if u.username == username), None) + + def create_user(self, username: str, **kwargs) -> User: + user = User( + id=f"user-{self._seq}", + username=username, + email=kwargs.get("email"), + permission=kwargs.get("permission", "read"), + is_active=kwargs.get("is_active", True), + ) + self._seq += 1 + self._users[user.id] = user + return user + + def create_daemon(self, owner_user_id: str, *, daemon_slug: str, display_name: str | None = None) -> Daemon: + d = Daemon( + id=f"daemon-{self._seq}", + owner_user_id=owner_user_id, + daemon_slug=daemon_slug, + display_name=display_name, + is_active=True, + created_at=datetime.now(timezone.utc), + ) + self._seq += 1 + self._daemons[d.id] = d + return d + + def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None: + return next( + (d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug), + None, + ) + + def get_daemon_by_id(self, daemon_id: str) -> Daemon | None: + return self._daemons.get(daemon_id) + + def upsert_broker_credential( + self, + user_id: str, + broker: str, + *, + account_ref: str, + encrypted_payload: str, + daemon_id: str | None = None, + ) -> BrokerCredential: + self.get_user(user_id) + key = (user_id, broker) + existing = self._broker_credentials.get(key) + cred = BrokerCredential( + user_id=user_id, + broker=broker, + account_ref=account_ref, + encrypted_payload=encrypted_payload, + credential_version=(existing.credential_version + 1) if existing else 1, + last_rotated_at=datetime.now(timezone.utc), + daemon_id=daemon_id, + ) + self._broker_credentials[key] = cred + return cred + + def list_broker_credentials( + self, + user_id: str, + daemon_id: str | None = None, + ) -> list[BrokerCredential]: + creds = [c for (uid, _), c in self._broker_credentials.items() if uid == user_id] + if daemon_id is not None: + creds = [c for c in creds if c.daemon_id == daemon_id] + return sorted(creds, key=lambda c: c.broker) + + def update_broker_credential_daemon_id(self, user_id: str, broker: str, daemon_id: str) -> bool: + key = (user_id, broker) + cred = self._broker_credentials.get(key) + if cred is None or cred.daemon_id is not None: + return False + self._broker_credentials[key] = BrokerCredential( + user_id=cred.user_id, broker=cred.broker, account_ref=cred.account_ref, + encrypted_payload=cred.encrypted_payload, credential_version=cred.credential_version, + last_rotated_at=cred.last_rotated_at, daemon_id=daemon_id, + ) + return True + + def delete_broker_credential(self, user_id: str, broker: str, daemon_id: str | None = None) -> bool: + if daemon_id is not None: + for key, cred in list(self._broker_credentials.items()): + if cred.daemon_id == daemon_id and cred.broker == broker: + del self._broker_credentials[key] + return True + return False + key = (user_id, broker) + if key not in self._broker_credentials: + return False + del self._broker_credentials[key] + return True + + +def _settings_factory() -> Settings: + return Settings( + allowed_origins_raw="http://localhost:3000", + credential_encryption_key=_TEST_ENC_KEY, + ) + + +def _build_runner(repo: CredFakeRepo) -> Any: + app = create_app(settings_factory=_settings_factory, repository_factory=lambda settings: repo) + runner = CliRunner() + runner.app = app + return runner + + +def _make_encrypted(app_key: str = "k", app_secret: str = "s") -> str: + return encrypt_payload(_TEST_ENC_KEY, json.dumps({"app_key": app_key, "app_secret": app_secret})) + + +# --------------------------------------------------------------------------- +# REQ-050 AC-1: credential set --daemon-id 성공 +# --------------------------------------------------------------------------- + +def test_credential_set_with_daemon_id() -> None: + """REQ-050 AC-1: set --daemon-id → broker_credentials에 daemon_id 포함 upsert.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + daemon = repo.create_daemon(user.id, daemon_slug="orb-bot") + runner = _build_runner(repo) + + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "set", + "--user", "alice", + "--daemon-id", "orb-bot", + "--broker", "kis", + "--account-ref", "acc-123", + "--app-key", "mykey", + "--app-secret", "mysecret", + "--force", + ]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["broker"] == "kis" + assert payload["daemon_id"] == daemon.id + assert payload["account_ref"] == "acc-123" + + +# --------------------------------------------------------------------------- +# REQ-050 AC-2: credential list --daemon-id 필터 +# --------------------------------------------------------------------------- + +def test_credential_list_daemon_id_filter() -> None: + """REQ-050 AC-2: list --daemon-id → 해당 daemon 인증정보만 출력.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + d1 = repo.create_daemon(user.id, daemon_slug="bot-d1") + d2 = repo.create_daemon(user.id, daemon_slug="bot-d2") + + enc = _make_encrypted() + repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id) + repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id) + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "list", + "--user", "alice", + "--daemon-id", "bot-d1", + ]) + + assert result.exit_code == 0, result.output + items = json.loads(result.stdout) + assert len(items) == 1 + assert items[0]["daemon_id"] == d1.id + + +# --------------------------------------------------------------------------- +# REQ-050 AC-3: credential list 전체 (daemon-id 미지정) +# --------------------------------------------------------------------------- + +def test_credential_list_no_daemon_id_returns_all() -> None: + """REQ-050 AC-3: list --daemon-id 미지정 → 전체 인증정보.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + d1 = repo.create_daemon(user.id, daemon_slug="bot-d1") + d2 = repo.create_daemon(user.id, daemon_slug="bot-d2") + + enc = _make_encrypted() + repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id) + repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id) + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "list", + "--user", "alice", + ]) + + assert result.exit_code == 0, result.output + items = json.loads(result.stdout) + assert len(items) == 2 + + +# --------------------------------------------------------------------------- +# REQ-050 AC-4: credential remove --daemon-id +# --------------------------------------------------------------------------- + +def test_credential_remove_with_daemon_id() -> None: + """REQ-050 AC-4: remove --daemon-id → (daemon_id, broker) 기반 삭제.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + d1 = repo.create_daemon(user.id, daemon_slug="orb-bot") + + enc = _make_encrypted() + repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=d1.id) + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "remove", + "--user", "alice", + "--daemon-id", "orb-bot", + "--broker", "kis", + "--force", + ]) + + assert result.exit_code == 0, result.output + remaining = repo.list_broker_credentials(user.id) + assert len(remaining) == 0 + + +# --------------------------------------------------------------------------- +# REQ-050 AC-5: 존재하지 않는 daemon_slug → exit 1 +# --------------------------------------------------------------------------- + +def test_credential_set_nonexistent_daemon_exits_1() -> None: + """REQ-050 AC-5: 존재하지 않는 daemon_slug → 오류 + exit 1.""" + repo = CredFakeRepo() + repo.create_user("alice") + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "set", + "--user", "alice", + "--daemon-id", "nonexistent-bot", + "--broker", "kis", + "--account-ref", "acc", + "--app-key", "k", + "--app-secret", "s", + "--force", + ]) + + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# REQ-050 AC-7: CLI 출력에 daemon_id 포함 +# --------------------------------------------------------------------------- + +def test_credential_set_output_includes_daemon_id() -> None: + """REQ-050 AC-7: set 성공 출력에 daemon_id 포함.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + daemon = repo.create_daemon(user.id, daemon_slug="tsl-bot") + runner = _build_runner(repo) + + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "set", + "--user", "alice", + "--daemon-id", "tsl-bot", + "--broker", "kis", + "--account-ref", "acc-999", + "--app-key", "k2", + "--app-secret", "s2", + "--force", + ]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert "daemon_id" in payload + assert "broker" in payload + assert "account_ref" in payload + assert "credential_version" in payload + assert "last_rotated_at" in payload + + +# --------------------------------------------------------------------------- +# REQ-048 AC-1/2: backfill-daemon-id 정상 백필 +# --------------------------------------------------------------------------- + +def test_backfill_daemon_id_updates_null_rows() -> None: + """REQ-048 AC-1/2: daemon_id IS NULL 인증정보 → 백필 후 daemon_id 세팅.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + daemon = repo.create_daemon(user.id, daemon_slug="orb-bot") + + enc = _make_encrypted() + # daemon_id=None으로 등록 (기존 user-scoped 방식) + repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None) + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "backfill-daemon-id", + "--user", "alice", + "--daemon-id", "orb-bot", + "--force", + ]) + + assert result.exit_code == 0, result.output + # 백필 후 daemon_id 세팅 확인 + updated = repo.list_broker_credentials(user.id) + assert len(updated) == 1 + assert updated[0].daemon_id == daemon.id + + +# --------------------------------------------------------------------------- +# REQ-048 AC-3: --dry-run 시 DB 변경 없음 +# --------------------------------------------------------------------------- + +def test_backfill_daemon_id_dry_run_no_change() -> None: + """REQ-048 AC-3: --dry-run → 실제 UPDATE 없음.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + daemon = repo.create_daemon(user.id, daemon_slug="orb-bot") + + enc = _make_encrypted() + repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None) + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "backfill-daemon-id", + "--user", "alice", + "--daemon-id", "orb-bot", + "--dry-run", + ]) + + assert result.exit_code == 0, result.output + # DB 변경 없음 (dry-run) + creds = repo.list_broker_credentials(user.id) + assert creds[0].daemon_id is None + + +# --------------------------------------------------------------------------- +# REQ-048 AC-5: daemon_id IS NULL 없으면 "이미 완료됨" +# --------------------------------------------------------------------------- + +def test_backfill_daemon_id_already_complete() -> None: + """REQ-048 AC-5: daemon_id IS NULL 없으면 이미 완료됨 + exit 0.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + daemon = repo.create_daemon(user.id, daemon_slug="orb-bot") + + enc = _make_encrypted() + # 이미 daemon_id 있음 + repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=daemon.id) + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "backfill-daemon-id", + "--user", "alice", + "--daemon-id", "orb-bot", + "--force", + ]) + + assert result.exit_code == 0, result.output + assert "already_complete" in result.stdout or "이미 완료됨" in result.stdout + + +# --------------------------------------------------------------------------- +# REQ-048 AC-6: 존재하지 않는 user → exit 1 +# --------------------------------------------------------------------------- + +def test_backfill_daemon_id_nonexistent_user_exits_1() -> None: + """REQ-048 AC-6: 존재하지 않는 user --user → exit 1.""" + repo = CredFakeRepo() + repo.create_daemon.__doc__ # dummy + + runner = _build_runner(repo) + result = runner.invoke(runner.app, [ + "--format", "json", "credential", "backfill-daemon-id", + "--user", "nobody", + "--daemon-id", "orb-bot", + "--force", + ]) + + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# REQ-048 AC-4: 백필 전후 encrypted_payload 동등 (데이터 손실 없음) +# --------------------------------------------------------------------------- + +def test_backfill_preserves_encrypted_payload() -> None: + """REQ-048 AC-4: 백필 전후 encrypted_payload, account_ref 동등.""" + repo = CredFakeRepo() + user = repo.create_user("alice") + daemon = repo.create_daemon(user.id, daemon_slug="orb-bot") + + enc = _make_encrypted("orig_key", "orig_secret") + repo.upsert_broker_credential( + user.id, "kis", account_ref="original-ref", encrypted_payload=enc, daemon_id=None + ) + + original_encrypted = enc + original_account_ref = "original-ref" + + runner = _build_runner(repo) + runner.invoke(runner.app, [ + "--format", "json", "credential", "backfill-daemon-id", + "--user", "alice", + "--daemon-id", "orb-bot", + "--force", + ]) + + creds = repo.list_broker_credentials(user.id) + assert creds[0].encrypted_payload == original_encrypted + assert creds[0].account_ref == original_account_ref diff --git a/tests/test_bat020_user_info_daemon_scoped.py b/tests/test_bat020_user_info_daemon_scoped.py new file mode 100644 index 0000000..596ca92 --- /dev/null +++ b/tests/test_bat020_user_info_daemon_scoped.py @@ -0,0 +1,436 @@ +"""BAT-020: REQ-049, REQ-051 — GET /v1/user_info daemon-scoped + 보안 격리 테스트.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.core.auth import AuthContext +from app.core.config import Settings, get_settings +from app.core.encryption import encrypt_payload +from app.domain.models import BrokerCredential, TokenPrincipal, User +from app.main import create_app + +_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + +def _make_settings(**kwargs) -> Settings: + return Settings( + allowed_origins_raw="http://localhost:3000", + db_enabled=True, + credential_encryption_key=_TEST_ENC_KEY, + **kwargs, + ) + + +def _make_client(settings: Settings, mock_repo) -> TestClient: + get_settings.cache_clear() + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _svc_token(token: str = "svc-token") -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _make_cred(user_id: str, daemon_id: str, broker: str = "kis", account_ref: str = "ref") -> BrokerCredential: + plain = json.dumps({"app_key": "k", "app_secret": "s"}) + encrypted = encrypt_payload(_TEST_ENC_KEY, plain) + return BrokerCredential( + user_id=user_id, broker=broker, account_ref=account_ref, + encrypted_payload=encrypted, credential_version=1, + daemon_id=daemon_id, + ) + + +# --------------------------------------------------------------------------- +# REQ-049 AC-1: daemon_id 있음 + broker 있음 → 200 + 단일 broker +# --------------------------------------------------------------------------- + +def test_user_info_daemon_scoped_broker_returned() -> None: + """REQ-049 AC-1: daemon_id 바인딩 token + 해당 daemon broker 있음 → 단일 BrokerItem.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", runtime_id="rt-1", daemon_id="d1", + ) + user = User(id="u1", username="alice", permission="read", is_active=True) + cred = _make_cred("u1", "d1", account_ref="acc-d1") + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [cred] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 200 + body = resp.json() + assert body["broker"] is not None + assert body["broker"]["account_ref"] == "acc-d1" + assert body["broker"]["broker"] == "kis" + assert body["broker"]["credential_payload"] == {"app_key": "k", "app_secret": "s"} + # REQ-049 AC-6: brokers 키 없음 + assert "brokers" not in body + + +# --------------------------------------------------------------------------- +# REQ-049 AC-2: daemon_id 있음 + broker 없음 → 200 + broker=null +# --------------------------------------------------------------------------- + +def test_user_info_daemon_no_broker_returns_null() -> None: + """REQ-049 AC-2: daemon_id 있음, 해당 daemon 인증정보 없음 → broker=null.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", daemon_id="d1", + ) + user = User(id="u1", username="alice", permission="read", is_active=True) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] # 인증정보 없음 + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 200 + assert resp.json()["broker"] is None + + +# --------------------------------------------------------------------------- +# REQ-049 AC-3, REQ-051 AC-3: daemon_id 없는 service token → 403 +# --------------------------------------------------------------------------- + +def test_user_info_no_daemon_id_returns_403() -> None: + """REQ-049 AC-3, REQ-051 AC-3: daemon_id=None service token → 403.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", daemon_id=None, + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# REQ-049 AC-4/5: 같은 user 2데몬 격리 +# --------------------------------------------------------------------------- + +def test_user_info_daemon_isolation_two_daemons() -> None: + """REQ-049 AC-4/5: daemon A, B가 각각 다른 account_ref를 반환.""" + settings = _make_settings() + user = User(id="u1", username="alice", permission="read", is_active=True) + cred_a = _make_cred("u1", "d-a", account_ref="acc-A") + cred_b = _make_cred("u1", "d-b", account_ref="acc-B") + + principal_a = TokenPrincipal( + token_id="ta", user_id="u1", permission="read", + token_type="service", daemon_id="d-a", + ) + principal_b = TokenPrincipal( + token_id="tb", user_id="u1", permission="read", + token_type="service", daemon_id="d-b", + ) + + # daemon A 호출 + mock_repo_a = MagicMock() + mock_repo_a.get_token_principal.return_value = principal_a + mock_repo_a.get_user.return_value = user + mock_repo_a.list_broker_credentials.return_value = [cred_a] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_a), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_a): + client = _make_client(settings, mock_repo_a) + resp_a = client.get("/v1/user_info", headers=_svc_token()) + + assert resp_a.status_code == 200 + assert resp_a.json()["broker"]["account_ref"] == "acc-A" + + # daemon B 호출 + mock_repo_b = MagicMock() + mock_repo_b.get_token_principal.return_value = principal_b + mock_repo_b.get_user.return_value = user + mock_repo_b.list_broker_credentials.return_value = [cred_b] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_b), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_b): + client_b = _make_client(settings, mock_repo_b) + resp_b = client_b.get("/v1/user_info", headers=_svc_token()) + + assert resp_b.status_code == 200 + assert resp_b.json()["broker"]["account_ref"] == "acc-B" + # 상호 반환 없음 + assert resp_a.json()["broker"]["account_ref"] != resp_b.json()["broker"]["account_ref"] + + +# --------------------------------------------------------------------------- +# REQ-049 AC-6: 응답에 broker 단일 키 존재, brokers 리스트 없음 +# --------------------------------------------------------------------------- + +def test_user_info_response_has_broker_not_brokers() -> None: + """REQ-049 AC-6: 응답 JSON에 broker 단일 키만 있고 brokers 키 없음.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", daemon_id="d1", + ) + user = User(id="u1", username="alice", permission="read", is_active=True) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 200 + body = resp.json() + assert "broker" in body + assert "brokers" not in body + + +# --------------------------------------------------------------------------- +# REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null +# --------------------------------------------------------------------------- + +def test_user_info_decryption_failure_returns_null_payload() -> None: + """REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null, 크래시 없음.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", daemon_id="d1", + ) + user = User(id="u1", username="alice", permission="read", is_active=True) + # 잘못된 encrypted_payload (복호화 실패) + cred = BrokerCredential( + user_id="u1", broker="kis", account_ref="ref", + encrypted_payload="INVALID_PAYLOAD", + credential_version=1, daemon_id="d1", + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [cred] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 200 + body = resp.json() + assert body["broker"] is not None + assert body["broker"]["account_ref"] == "ref" + assert body["broker"]["credential_payload"] is None # 복호화 실패 + + +# --------------------------------------------------------------------------- +# REQ-051 AC-1: web 토큰 → 403 +# --------------------------------------------------------------------------- + +def test_user_info_web_token_returns_403() -> None: + """REQ-051 AC-1: web user 토큰(token_type!=service) → 403.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="user", # service 아님 + daemon_id="d1", + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# REQ-051 AC-2: 비인증 → 401 +# --------------------------------------------------------------------------- + +def test_user_info_unauthenticated_returns_401() -> None: + """REQ-051 AC-2: Authorization 헤더 없음 → 401.""" + settings = _make_settings() + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = None # 토큰 없음 + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info") # 헤더 없음 + + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# REQ-051 AC-4: 다른 daemon 인증정보 → null (데이터 격리) +# --------------------------------------------------------------------------- + +def test_user_info_different_daemon_credential_not_returned() -> None: + """REQ-051 AC-4: daemon_id="d1" token이 daemon_id="d2" 인증정보를 받지 않음.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", daemon_id="d1", + ) + user = User(id="u1", username="alice", permission="read", is_active=True) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + # daemon_id="d1" 필터로 조회하면 빈 결과 (DB에는 d2만 있음) + mock_repo.list_broker_credentials.return_value = [] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 200 + assert resp.json()["broker"] is None + + +# --------------------------------------------------------------------------- +# REQ-051 AC-5: 타 엔드포인트 응답에 credential_payload 미포함 +# --------------------------------------------------------------------------- + +def test_other_endpoints_do_not_expose_credential_payload(monkeypatch) -> None: + """REQ-051 AC-5: GET /health, /v1/portfolios 등에 credential 필드 없음.""" + settings = _make_settings() + client = TestClient(create_app(settings_factory=lambda: settings)) + + # /health 응답 검사 + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert "credential_payload" not in body + assert "app_secret" not in body + assert "app_key" not in body + + +# --------------------------------------------------------------------------- +# REQ-051 AC-6: DB 오류 → broker=null graceful degrade (500 없음) +# --------------------------------------------------------------------------- + +def test_user_info_db_error_returns_null_broker() -> None: + """REQ-051 AC-6: list_broker_credentials DB 오류 → broker=null, 500 없음.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", daemon_id="d1", + ) + user = User(id="u1", username="alice", permission="read", is_active=True) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.side_effect = RuntimeError("DB connection error") + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 200 + assert resp.json()["broker"] is None + + +# --------------------------------------------------------------------------- +# REQ-051 AC-8: 복호화 실패 시 로그에 app_secret 미노출 +# --------------------------------------------------------------------------- + +def test_user_info_decryption_failure_log_does_not_expose_secret(caplog) -> None: + """REQ-051 AC-8: 복호화 실패 로그에 app_key, app_secret 값 미포함.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", daemon_id="d1", + ) + user = User(id="u1", username="alice", permission="read", is_active=True) + # 잘못된 encrypted_payload + cred = BrokerCredential( + user_id="u1", broker="kis", account_ref="ref", + encrypted_payload="INVALID_PAYLOAD_SECRET_VALUE", + credential_version=1, daemon_id="d1", + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [cred] + + with caplog.at_level(logging.WARNING, logger="app.api.v1_user_info"), \ + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + client.get("/v1/user_info", headers=_svc_token()) + + # 로그에 app_secret, app_key 값이 없음 확인 + log_text = " ".join(caplog.messages) + assert "mysecret" not in log_text + assert "mykey" not in log_text + # 로그에 INVALID_PAYLOAD 원문이 없음 (payload 노출 아님) + # 단지 오류 유형(class name)만 기록 + assert "INVALID_PAYLOAD_SECRET_VALUE" not in log_text + + +# --------------------------------------------------------------------------- +# REQ-049 AC-9: 기존 필드들 회귀 없음 +# --------------------------------------------------------------------------- + +def test_user_info_regression_existing_fields_unchanged() -> None: + """REQ-049 AC-9: broker 변경이 runtime_id, daemon_id, trading_enabled 등에 영향 없음.""" + settings = _make_settings() + principal = TokenPrincipal( + token_id="t1", user_id="u1", permission="read", + token_type="service", runtime_id="rt-xyz", daemon_id="d1", + ) + user = User(id="u1", username="alice", email="alice@example.com", permission="read", is_active=True) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_client(settings, mock_repo) + resp = client.get("/v1/user_info", headers=_svc_token()) + + assert resp.status_code == 200 + body = resp.json() + assert body["runtime_id"] == "rt-xyz" + assert body["daemon_id"] == "d1" + assert body["trading_enabled"] is True + assert body["user_id"] == "u1" + assert body["username"] == "alice" + assert body["permission"] == "read" diff --git a/tests/test_cli.py b/tests/test_cli.py index d8cf9d8..7f22b01 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -175,6 +175,7 @@ class FakeRepo: *, account_ref: str, encrypted_payload: str, + daemon_id: str | None = None, ) -> BrokerCredential: self.get_user(user_id) key = (user_id, broker) @@ -186,17 +187,65 @@ class FakeRepo: encrypted_payload=encrypted_payload, credential_version=(existing.credential_version + 1) if existing is not None else 1, last_rotated_at=datetime.now(timezone.utc), + daemon_id=daemon_id, ) self._broker_credentials[key] = credential return credential - def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: - return sorted( - [credential for (owner_user_id, _), credential in self._broker_credentials.items() if owner_user_id == user_id], - key=lambda credential: credential.broker, + def list_broker_credentials( + self, + user_id: str, + daemon_id: str | None = None, + ) -> list[BrokerCredential]: + creds = [ + credential + for (owner_user_id, _), credential in self._broker_credentials.items() + if owner_user_id == user_id + ] + if daemon_id is not None: + creds = [c for c in creds if c.daemon_id == daemon_id] + return sorted(creds, key=lambda credential: credential.broker) + + def update_broker_credential_daemon_id( + self, + user_id: str, + broker: str, + daemon_id: str, + ) -> bool: + key = (user_id, broker) + cred = self._broker_credentials.get(key) + if cred is None or cred.daemon_id is not None: + return False + self._broker_credentials[key] = BrokerCredential( + user_id=cred.user_id, + broker=cred.broker, + account_ref=cred.account_ref, + encrypted_payload=cred.encrypted_payload, + credential_version=cred.credential_version, + last_rotated_at=cred.last_rotated_at, + daemon_id=daemon_id, + ) + return True + + def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str): + return next( + (d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug), + 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: + if daemon_id is not None: + # daemon_id 기반 삭제 + for key, cred in list(self._broker_credentials.items()): + if cred.daemon_id == daemon_id and cred.broker == broker: + del self._broker_credentials[key] + return True + return False key = (user_id, broker) if key not in self._broker_credentials: return False @@ -986,8 +1035,10 @@ def test_cli_daemon_create_and_list_render_entries() -> None: def test_cli_credential_set_list_and_remove_flow() -> None: + """REQ-050: --daemon-id 포함 credential set/list/remove 플로우 (BAT-020 갱신).""" repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") + daemon = repo.create_daemon(user.id, daemon_slug="orb-bot") runner = build_runner(repo) set_result = runner.invoke( @@ -999,6 +1050,8 @@ def test_cli_credential_set_list_and_remove_flow() -> None: "set", "--user", "alice", + "--daemon-id", + "orb-bot", "--broker", "kis", "--account-ref", @@ -1023,18 +1076,16 @@ def test_cli_credential_set_list_and_remove_flow() -> None: set_payload = json.loads(set_result.stdout) assert set_payload["user_id"] == user.id assert set_payload["broker"] == "kis" + assert set_payload["daemon_id"] == daemon.id assert set_payload["credential_version"] == 1 assert list_result.exit_code == 0 list_payload = json.loads(list_result.stdout) - assert list_payload == [ - { - "broker": "kis", - "account_ref": "123-45", - "credential_version": 1, - "last_rotated_at": list_payload[0]["last_rotated_at"], - } - ] + assert len(list_payload) == 1 + assert list_payload[0]["broker"] == "kis" + assert list_payload[0]["account_ref"] == "123-45" + assert list_payload[0]["daemon_id"] == daemon.id + assert list_payload[0]["credential_version"] == 1 assert remove_result.exit_code == 0 remove_payload = json.loads(remove_result.stdout) diff --git a/tests/test_daemon_assignment.py b/tests/test_daemon_assignment.py index a6c41df..1723591 100644 --- a/tests/test_daemon_assignment.py +++ b/tests/test_daemon_assignment.py @@ -120,7 +120,8 @@ def test_user_info_returns_daemon_id_in_response(monkeypatch) -> None: assert resp.json()["daemon_id"] == "d-1" -def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None: +def test_user_info_null_daemon_id_returns_403(monkeypatch) -> None: + """REQ-049 AC-3: daemon_id=None service token → 403 Forbidden (BAT-020 정책 변경).""" client = _app_with_db(monkeypatch) principal = _principal(daemon_id=None) user = User(id="user-1", username="alice", permission="read", is_active=True) @@ -134,8 +135,8 @@ def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None: patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): resp = client.get("/v1/user_info", headers=_svc_header()) - assert resp.status_code == 200 - assert resp.json()["daemon_id"] is None + # REQ-049: daemon_id 없는 service token은 user_info 403 (daemon-scoped 인가) + assert resp.status_code == 403 # --------------------------------------------------------------------------- diff --git a/tests/test_v1.py b/tests/test_v1.py index c3163bd..31e4c52 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -118,7 +118,7 @@ class _RuntimeIdRoundTripRepo: daemon_id=token.daemon_id, ) - 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]: return [] @@ -144,6 +144,9 @@ def test_health_returns_full_response() -> None: # --------------------------------------------------------------------------- def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None: + # REQ-049: daemon_id 없는 service token → user_info 403 + # 이 테스트는 service token 발급의 runtime_id 생성을 검증하고, + # daemon_id 없는 token으로 user_info 호출 시 403을 확인한다. repo = _RuntimeIdRoundTripRepo() settings = _make_settings( db_enabled=True, @@ -168,15 +171,13 @@ def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None: assert issue_body["runtime_id"] is not None assert "token" in issue_body + # REQ-049 AC-3: daemon_id 없는 service token → user_info 403 user_info_resp = client.get( "/v1/user_info", headers=_service_token_header(issue_body["token"]), ) - assert user_info_resp.status_code == 200 - user_info_body = user_info_resp.json() - assert user_info_body["runtime_id"] == issue_body["runtime_id"] - assert user_info_body["username"] == "alice" + assert user_info_resp.status_code == 403 def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None: @@ -214,11 +215,12 @@ def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monke # --------------------------------------------------------------------------- def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None: + # REQ-049: daemon_id 있음 + runtime_id=None → 200 반환 settings = _make_app_db(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id=None, # no runtime_id + token_type="service", runtime_id=None, daemon_id="daemon-1", ) user = User(id="user-1", username="alice", permission="read", is_active=True) @@ -237,11 +239,12 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None def test_v1_user_info_returns_user(monkeypatch) -> None: + # REQ-049: daemon_id 있음 + broker=None → 200 반환 settings = _make_app_db(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-abc" + token_type="service", runtime_id="rt-abc", daemon_id="daemon-1", ) user = User( id="user-1", username="alice", email="alice@example.com", @@ -264,15 +267,39 @@ def test_v1_user_info_returns_user(monkeypatch) -> None: assert body["username"] == "alice" assert body["trading_enabled"] is True assert body["disabled_reason"] is None - assert body["brokers"] == [] + # REQ-049 AC-6: broker 단일 필드 존재, brokers 리스트 미존재 + assert "broker" in body + assert "brokers" not in body + assert body["broker"] is None # 인증정보 없음 → null + + +def test_v1_user_info_no_daemon_id_returns_403(monkeypatch) -> None: + """REQ-049 AC-3, REQ-051 AC-3: daemon_id=None 서비스 토큰 → 403.""" + settings = _make_app_db(monkeypatch) + + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-abc", daemon_id=None, + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) + resp = client.get("/v1/user_info", headers=_service_token_header()) + + assert resp.status_code == 403 def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None: + # daemon_id 있음 + 비활성 user → disabled_reason settings = _make_app_db(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id="user-2", permission="read", - token_type="service", runtime_id="rt-xyz" + token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2", ) user = User( id="user-2", username="bob", email=None, @@ -295,8 +322,8 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None: assert body["disabled_reason"] == "account_inactive" -def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None: - """REQ-007 AC5: brokers[]에 복호화된 credential_payload 반환.""" +def test_v1_user_info_returns_decrypted_broker_credential(monkeypatch) -> None: + """REQ-049 AC-1: daemon_id 있음 + broker 인증정보 있음 → 단일 broker 반환.""" from app.core.encryption import encrypt_payload import json as _json @@ -307,12 +334,13 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None: principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-abc" + token_type="service", runtime_id="rt-abc", daemon_id="daemon-1", ) user = User(id="user-1", username="alice", permission="read", is_active=True) cred = BrokerCredential( user_id="user-1", broker="kis", account_ref="12345678", encrypted_payload=encrypted, credential_version=1, + daemon_id="daemon-1", ) mock_repo = MagicMock() @@ -331,8 +359,11 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None: assert resp.status_code == 200 body = resp.json() - assert len(body["brokers"]) == 1 - broker = body["brokers"][0] + # REQ-049 AC-6: broker 단일 필드, brokers 리스트 없음 + assert "broker" in body + assert "brokers" not in body + broker = body["broker"] + assert broker is not None assert broker["broker"] == "kis" assert broker["credential_payload"] == {"app_key": "mykey", "app_secret": "mysecret"} @@ -793,12 +824,13 @@ def test_v1_assets_read_token_200(monkeypatch) -> None: # GET /v1/user_info # --------------------------------------------------------------------------- -def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None: +def test_v1_user_info_null_runtime_id_returns_200_with_null_b(monkeypatch) -> None: + # REQ-049: daemon_id 있음 + runtime_id=None → 200 monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id=None, # no runtime_id + token_type="service", runtime_id=None, daemon_id="daemon-1", ) user = User(id="user-1", username="alice", permission="read", is_active=True) @@ -816,12 +848,13 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None assert resp.json()["runtime_id"] is None -def test_v1_user_info_returns_user(monkeypatch) -> None: +def test_v1_user_info_returns_user_b(monkeypatch) -> None: + # REQ-049 AC-9 회귀: daemon_id 있음 → 기존 필드들 정상 반환 monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-abc" + token_type="service", runtime_id="rt-abc", daemon_id="daemon-1", ) user = User( id="user-1", username="alice", email="alice@example.com", @@ -831,6 +864,7 @@ def test_v1_user_info_returns_user(monkeypatch) -> None: mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): @@ -842,12 +876,13 @@ def test_v1_user_info_returns_user(monkeypatch) -> None: assert body["runtime_id"] == "rt-abc" assert body["username"] == "alice" assert body["disabled_reason"] is None + # REQ-049 AC-6: broker 단일 필드, brokers 키 없음 + assert "broker" in body + assert "brokers" not in body def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch) -> None: - """issue #9 regression: broker_credentials lookup failure (missing table / DB error) - must NOT crash /v1/user_info with 500. daemon_id/user_id/permission do not depend on - broker credentials — degrade gracefully with empty brokers.""" + """REQ-049 AC-2, REQ-051 AC-6: broker_credentials lookup failure → broker=null graceful degrade.""" monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") principal = TokenPrincipal( @@ -872,15 +907,18 @@ def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch) body = resp.json() assert body["daemon_id"] == "daemon-9" assert body["user_id"] == "user-1" - assert body["brokers"] == [] + # REQ-049 AC-2: broker=null (graceful degrade, 크래시 없음) + assert body["broker"] is None + assert "brokers" not in body -def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None: +def test_v1_user_info_inactive_user_has_disabled_reason_b(monkeypatch) -> None: + # daemon_id 있음 + 비활성 user monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") principal = TokenPrincipal( token_id="t1", user_id="user-2", permission="read", - token_type="service", runtime_id="rt-xyz" + token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2", ) user = User( id="user-2", username="bob", email=None, @@ -890,6 +928,7 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None: mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):