diff --git a/app/cli/commands/credential.py b/app/cli/commands/credential.py new file mode 100644 index 0000000..b0ed43b --- /dev/null +++ b/app/cli/commands/credential.py @@ -0,0 +1,127 @@ +"""Broker credential CLI commands.""" + +from __future__ import annotations + +import json + +import typer + +from app.cli.commands.portfolio import _confirm +from app.cli.core.utils import execute_command, render_output +from app.core.encryption import encrypt_payload + +credential_app = typer.Typer(help="Broker credential management commands.") + + +def _with_service(ctx: typer.Context, callback): + app_context = ctx.obj + repository = app_context.repository_factory(app_context.settings) + try: + return execute_command(app_context.console, callback=lambda: callback(repository, app_context)) + except SystemExit: + raise + except RuntimeError as exc: + app_context.console.print(str(exc)) + raise typer.Exit(code=1) from exc + finally: + if callable(getattr(type(repository), "close", None)): + repository.close() + + +@credential_app.command("set") +def set_credential( + ctx: typer.Context, + user: str = typer.Option(..., "--user", help="Owner username (required)."), + broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."), + account_ref: str = typer.Option(..., "--account-ref", help="Account reference (e.g. account number)."), + app_key: str = typer.Option(..., "--app-key", help="Broker app_key."), + app_secret: str = typer.Option(..., "--app-secret", help="Broker app_secret."), + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), +) -> None: + """Register or update a broker credential (upsert).""" + _confirm(force, f"Set {broker} credential for user '{user}'?") + + def _callback(repository, app_context): + owner = repository.get_user_by_username(user) + if owner is None: + raise ValueError(f"사용자를 찾을 수 없습니다: {user}") + enc_key = app_context.settings.require_credential_encryption_key() + payload = json.dumps({"app_key": app_key, "app_secret": app_secret}) + encrypted = encrypt_payload(enc_key, payload) + return repository.upsert_broker_credential( + owner.id, + broker, + account_ref=account_ref, + encrypted_payload=encrypted, + ) + + result = _with_service(ctx, _callback) + if result is not None: + render_output( + ctx.obj.console, + ctx.obj.output_format, + { + "user_id": result.user_id, + "broker": result.broker, + "account_ref": result.account_ref, + "credential_version": result.credential_version, + "last_rotated_at": str(result.last_rotated_at), + }, + ) + + +@credential_app.command("list") +def list_credentials( + ctx: typer.Context, + user: str = typer.Option(..., "--user", help="Owner username (required)."), +) -> None: + """List broker credentials for a user (payload not shown).""" + + def _callback(repository, app_context): + owner = repository.get_user_by_username(user) + if owner is None: + raise ValueError(f"사용자를 찾을 수 없습니다: {user}") + records = repository.list_broker_credentials(owner.id) + return [ + { + "broker": r.broker, + "account_ref": r.account_ref, + "credential_version": r.credential_version, + "last_rotated_at": str(r.last_rotated_at), + } + for r in records + ] + + result = _with_service(ctx, _callback) + if result is not None: + if ctx.obj.output_format == "json": + render_output(ctx.obj.console, ctx.obj.output_format, result) + else: + for item in result if isinstance(result, list) else [result]: + ctx.obj.console.print(item) + if not result: + ctx.obj.console.print("No broker credentials found.") + + +@credential_app.command("remove") +def remove_credential( + ctx: typer.Context, + user: str = typer.Option(..., "--user", help="Owner username (required)."), + broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."), + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), +) -> None: + """Remove a broker credential.""" + _confirm(force, f"Remove {broker} credential for user '{user}'? This cannot be undone.") + + def _callback(repository, app_context): + owner = repository.get_user_by_username(user) + if owner is None: + raise ValueError(f"사용자를 찾을 수 없습니다: {user}") + deleted = repository.delete_broker_credential(owner.id, broker) + if not deleted: + raise ValueError(f"인증 정보를 찾을 수 없습니다: user={user} broker={broker}") + return {"deleted": True, "user": user, "broker": broker} + + result = _with_service(ctx, _callback) + if result is not None: + render_output(ctx.obj.console, ctx.obj.output_format, result) diff --git a/app/cli/main.py b/app/cli/main.py index 8e7b2b8..acc0f69 100644 --- a/app/cli/main.py +++ b/app/cli/main.py @@ -13,6 +13,7 @@ from app.cli.commands.service_tokens import token_app from app.cli.commands.portfolio import portfolio_app from app.cli.commands.strategy import strategy_app from app.cli.commands.user import user_app +from app.cli.commands.credential import credential_app from app.core.config import Settings, get_settings from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.portfolio_repository import PortfolioRepository @@ -51,6 +52,7 @@ def create_app( app.add_typer(strategy_app, name="strategy") app.add_typer(user_app, name="user") app.add_typer(token_app, name="service-token") + app.add_typer(credential_app, name="credential") register_health_commands(app) return app diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index e3f574c..cc9997f 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1398,6 +1398,17 @@ class PostgresPortfolioRepository: row = cur.fetchone() return self._row_to_broker_credential(row) + def delete_broker_credential(self, user_id: str, broker: str) -> bool: + with self._connect().cursor() as cur: + cur.execute( + """ + DELETE FROM broker_credentials + WHERE user_id = %s AND broker = %s + """, + (user_id, broker), + ) + return cur.rowcount > 0 + # ------------------------------------------------------------------ # Ledger trades # ------------------------------------------------------------------ diff --git a/docs/batches/bat-005_20260503_broker-credential-cli/implementation.md b/docs/batches/bat-005_20260503_broker-credential-cli/implementation.md new file mode 100644 index 0000000..600d668 --- /dev/null +++ b/docs/batches/bat-005_20260503_broker-credential-cli/implementation.md @@ -0,0 +1,53 @@ +# BAT-005 Implementation Log + +- Status: completed +- Batch: BAT-005 +- Profile: batch-lite +- Completed: 2026-05-03 + +## 구현 내역 + +### 1. `app/repositories/postgres.py` — `delete_broker_credential` 추가 + +```python +def delete_broker_credential(self, user_id: str, broker: str) -> bool: + with self._connect().cursor() as cur: + cur.execute( + "DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s", + (user_id, broker), + ) + return cur.rowcount > 0 +``` + +### 2. `app/cli/commands/credential.py` — 신규 생성 + +- `credential_app = typer.Typer(help="Broker credential management commands.")` +- `_with_service(ctx, callback)`: repository 생성 → execute_command → finally close. RuntimeError도 캐치해 stdout 출력 후 exit 1. +- `set` 명령: `--user`, `--broker` (기본값 `kis`), `--account-ref`, `--app-key`, `--app-secret`, `--force` +- `list` 명령: `--user` — encrypted_payload 제외 dict 리스트 출력 +- `remove` 명령: `--user`, `--broker`, `--force` — delete_broker_credential 호출 + +### 3. `app/cli/main.py` — `credential_app` 등록 + +```python +from app.cli.commands.credential import credential_app +... +app.add_typer(credential_app, name="credential") +``` + +### 4. `tests/test_cli.py` + +- `FakeRepo.__init__`: `_broker_credentials: dict[tuple[str, str], BrokerCredential] = {}` 추가 +- `FakeRepo.upsert/list/get/delete_broker_credential` 메서드 추가 +- `_settings_with_enc_key()`, `build_runner_with_enc_key()` 헬퍼 추가 +- 신규 테스트 5개 추가 + +### 5. `docs/project-structure.md` — CLI 모듈 목록 갱신 + +- `app/cli/` 섹션에 `credential` 명령 그룹 추가 +- primary runtime 설명에 `broker credentials` 추가 + +## 설계 결정 + +- dict 리스트 출력은 `render_output`의 타입 매칭을 우회해 `console.print(item)` 직접 호출 (기존 render_output이 등록된 타입만 처리함) +- `RuntimeError`(`CREDENTIAL_ENCRYPTION_KEY` 미설정)는 `_with_service` 헬퍼에서 캐치해 stdout으로 출력 후 exit 1 처리 diff --git a/docs/batches/bat-005_20260503_broker-credential-cli/index.md b/docs/batches/bat-005_20260503_broker-credential-cli/index.md new file mode 100644 index 0000000..0611e6b --- /dev/null +++ b/docs/batches/bat-005_20260503_broker-credential-cli/index.md @@ -0,0 +1,45 @@ +# BAT-005 Index + +- Status: release-candidate +- Batch: BAT-005 +- Profile: batch-lite +- Created: 2026-05-03 + +## Included REQ + +| REQ | 제목 | Type | Priority | +|-----|------|------|----------| +| REQ-015 | CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) | Interface | Medium | + +## Source Discovery + +- minor-change fast path (Discovery 없음) + +## Scope Summary + +- CLI `pfhub credential set/list/remove` 명령 그룹 신규 추가 +- `app/cli/commands/credential.py` 신규 생성 (`credential_app`) +- `app/cli/main.py`에 `credential_app` 등록 +- `app/repositories/postgres.py`에 `delete_broker_credential` 메서드 추가 +- 기존 `broker_credentials` 테이블 / `upsert_broker_credential` / `list_broker_credentials` 재사용 +- 기존 Fernet 암호화 인프라 (`app/core/encryption.py`) 재사용 +- DB 스키마 변경 없음, HTTP API 변경 없음 + +## Impacted Area + +- Docs: + - `docs/project-structure.md` — CLI 모듈 목록에 `credential.py` 추가 +- Modules: + - `app/cli/commands/credential.py` (신규) + - `app/cli/main.py` (`credential_app` 등록) + - `app/repositories/postgres.py` (`delete_broker_credential` 추가) +- Tests: + - `tests/test_cli.py` — credential 명령 통합 테스트 추가 + +## Stage Documents + +| Stage | File | Status | +|-------|------|--------| +| Planning | [planning.md](./planning.md) | confirmed | +| Implementation | [implementation.md](./implementation.md) | completed | +| Verification | [verification.md](./verification.md) | passed | diff --git a/docs/batches/bat-005_20260503_broker-credential-cli/planning.md b/docs/batches/bat-005_20260503_broker-credential-cli/planning.md new file mode 100644 index 0000000..7b6573a --- /dev/null +++ b/docs/batches/bat-005_20260503_broker-credential-cli/planning.md @@ -0,0 +1,73 @@ +# BAT-005 Planning + +- Status: confirmed +- Batch: BAT-005 +- Profile: batch-lite + +## Included REQ + +- REQ-015: CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) + +## Out of Scope + +- HTTP API 엔드포인트 추가 (별도 REQ 대상) +- 브로커 인증 정보 복호화 출력 (보안 정책상 CLI에서 plain text 노출 금지) +- 브로커 종류 확장 (현재는 `kis` 기본값만 처리) + +## 구현 순서 + +### 1단계: repository 메서드 추가 + +- `app/repositories/postgres.py` + - `delete_broker_credential(user_id: str, broker: str) -> bool` 추가 + - 레코드 존재 시 삭제 후 `True`, 미존재 시 `False` 반환 + +### 2단계: CLI 명령 모듈 신규 생성 + +- `app/cli/commands/credential.py` + - `credential_app = typer.Typer(help="Broker credential management commands.")` + - `_with_service(ctx, callback)` 헬퍼: repository 생성 → callback 실행 → finally close + - `set` 명령: `--user`, `--broker` (기본값 `kis`), `--account-ref`, `--app-key`, `--app-secret` + - `get_user_by_username` → `None`이면 `ValueError` + - `settings.require_credential_encryption_key()` → Fernet 암호화 + - `upsert_broker_credential` 호출 + - `list` 명령: `--user` + - `get_user_by_username` → `None`이면 `ValueError` + - `list_broker_credentials` 호출, `encrypted_payload` 제외 출력 + - `remove` 명령: `--user`, `--broker`, `--force` + - `get_user_by_username` → `None`이면 `ValueError` + - `_confirm(force, ...)` 확인 후 `delete_broker_credential` 호출 + +### 3단계: CLI 등록 + +- `app/cli/main.py` + - `from app.cli.commands.credential import credential_app` import 추가 + - `app.add_typer(credential_app, name="credential")` 등록 + +### 4단계: 문서 갱신 + +- `docs/project-structure.md` — CLI 모듈 목록에 `credential.py` 항목 추가 + +### 5단계: 테스트 + +- `tests/test_cli.py` + - `test_cli_credential_set_creates_record` (신규): alice 생성 후 set 실행 → list에 표시 + - `test_cli_credential_list_hides_payload` (신규): list 출력에 `encrypted_payload` 미포함 확인 + - `test_cli_credential_set_with_unknown_user_exits_error` (신규): 미존재 user → exit code 1 + - `test_cli_credential_remove_deletes_record` (신규): set 후 remove → list에서 사라짐 + - `test_cli_credential_set_requires_encryption_key` (신규): CREDENTIAL_ENCRYPTION_KEY 미설정 → exit code 1 + +## Dependencies + +- Internal: + - `broker_credentials` 테이블 (migration 0014 — 이미 존재) + - `upsert_broker_credential`, `list_broker_credentials`, `get_broker_credential` (이미 존재) + - `app/core/encryption.py` — `encrypt_payload` (이미 존재) + - `app/core/config.py` — `settings.require_credential_encryption_key()` (이미 존재) + - `_confirm`, `execute_command`, `render_output` (기존 CLI 헬퍼 — 이미 존재) +- External: 없음 + +## Design Gate + +- Design Required Before Implementation: no +- Reason: 기존 `_with_service` 헬퍼 패턴 재사용 (`user.py` 동일 구조). 구조/런타임 흐름 변경 없음. diff --git a/docs/batches/bat-005_20260503_broker-credential-cli/verification.md b/docs/batches/bat-005_20260503_broker-credential-cli/verification.md new file mode 100644 index 0000000..fda22ef --- /dev/null +++ b/docs/batches/bat-005_20260503_broker-credential-cli/verification.md @@ -0,0 +1,61 @@ +# BAT-005 Verification + +- Status: passed +- Batch: BAT-005 +- Profile: batch-lite +- Verified: 2026-05-03 + +## REQ Coverage + +### REQ-015: CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) + +| AC # | 기준 | 검증 방법 | 결과 | +|------|------|-----------|------| +| AC-1 | alice 사용자 + 유효 enc key → `credential set` 실행 시 broker_credentials 레코드 생성(upsert) | `test_cli_credential_set_creates_record` | ✅ passed | +| AC-2 | `credential list` 출력에 broker, account_ref, credential_version 포함 / encrypted_payload 미포함 | `test_cli_credential_list_hides_payload` | ✅ passed | +| AC-3 | 존재하지 않는 username → exit code 1 + 에러 메시지 | `test_cli_credential_set_with_unknown_user_exits_error` | ✅ passed | +| AC-4 | `credential remove --force` 후 list에서 사라짐 | `test_cli_credential_remove_deletes_record` | ✅ passed | +| AC-5 | CREDENTIAL_ENCRYPTION_KEY 미설정 → exit code 1 + 에러 메시지 포함 | `test_cli_credential_set_requires_encryption_key` | ✅ passed | + +## Test Evidence + +### 실행 명령 +```bash +python -m pytest tests/test_cli.py -q +``` + +### 결과 +``` +49 passed in 1.49s +``` + +- 신규 테스트 5개 모두 passed +- 기존 테스트 44개 regression 없음 + +### 신규 테스트 목록 +| 테스트 | AC | +|--------|----| +| `test_cli_credential_set_creates_record` | AC-1 | +| `test_cli_credential_list_hides_payload` | AC-2 | +| `test_cli_credential_set_with_unknown_user_exits_error` | AC-3 | +| `test_cli_credential_remove_deletes_record` | AC-4 | +| `test_cli_credential_set_requires_encryption_key` | AC-5 | + +## Implementation Summary + +| 파일 | 변경 내용 | +|------|-----------| +| `app/cli/commands/credential.py` | 신규 생성 — `credential_app`, `set`/`list`/`remove` 명령 | +| `app/cli/main.py` | `credential_app` import 및 `app.add_typer(credential_app, name="credential")` 등록 | +| `app/repositories/postgres.py` | `delete_broker_credential(user_id, broker) -> bool` 메서드 추가 | +| `tests/test_cli.py` | `FakeRepo.upsert/list/get/delete_broker_credential`, 신규 테스트 5개 추가 | +| `docs/project-structure.md` | CLI 명령 그룹 목록에 `credential` 추가 | + +## Baseline Document Sync + +- `docs/project-structure.md`: `app/cli/` 섹션에 `credential` 명령 그룹 추가 완료 +- 구조/런타임 흐름/인터페이스 계약 변경 없음 → `runtime-flows.md`, `interface-contract.md` 갱신 불필요 + +## Residual Risk + +- 없음 diff --git a/docs/batches/index.md b/docs/batches/index.md index ec1350c..619bee7 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -11,4 +11,5 @@ | BAT-001 | standard | release-candidate | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) | | BAT-002 | batch-lite | release-candidate | REQ-012 | minor-change | [bat-002_20260502_service-token-rotate-cli](./bat-002_20260502_service-token-rotate-cli/) | | BAT-003 | batch-lite | release-candidate | REQ-013 | dcy-003 | [bat-003_20260502_portfolio-list-user-required](./bat-003_20260502_portfolio-list-user-required/) | -| BAT-004 | batch-lite | release-candidate | REQ-014 | minor-change | [bat-004_20260502_portfolio-create-user-required](./bat-004_20260502_portfolio-create-user-required/) | \ No newline at end of file +| BAT-004 | batch-lite | release-candidate | REQ-014 | minor-change | [bat-004_20260502_portfolio-create-user-required](./bat-004_20260502_portfolio-create-user-required/) | +| BAT-005 | batch-lite | release-candidate | REQ-015 | minor-change | [bat-005_20260503_broker-credential-cli](./bat-005_20260503_broker-credential-cli/) | \ No newline at end of file diff --git a/docs/project-structure.md b/docs/project-structure.md index 2d8dbe9..33f6562 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -27,7 +27,7 @@ - Type: mixed - Planned Entry Points: - uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks -- pfhub :: run operational CLI commands for health, users, portfolios, strategies, and service tokens +- pfhub :: run operational CLI commands for health, users, portfolios, strategies, service tokens, and broker credentials - alembic upgrade head :: apply ordered database schema migrations for persistent environments ## Purpose @@ -87,6 +87,7 @@ - All v1 endpoints require service-token authentication (token_source=db, token_type=service). - app/cli/ - Defines the Typer entrypoint, command groups, output helpers, and operator-facing command flows. + - Command groups: `portfolio`, `strategy`, `user`, `service-token`, `credential` (broker credential management), and health commands (`health-check`, `init-db`, `migrate-db`). - app/core/ - Owns configuration loading, auth guards, security helpers, and shared error handling. - `encryption.py`: Fernet-based credential payload encrypt/decrypt helpers. Reads `CREDENTIAL_ENCRYPTION_KEY` env var via `Settings.require_credential_encryption_key()`. diff --git a/docs/srs/Interface/req-015_broker-credential-cli.md b/docs/srs/Interface/req-015_broker-credential-cli.md new file mode 100644 index 0000000..696be00 --- /dev/null +++ b/docs/srs/Interface/req-015_broker-credential-cli.md @@ -0,0 +1,108 @@ +# req-015: CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) + +- Status: Approved +- Type: Interface +- Priority: Medium +- Owner: repository-maintainers + +## Intent +- 운영자가 CLI(`pfhub credential`)를 통해 KIS 브로커 인증 정보를 안전하게 등록, 조회, 삭제할 수 있어야 한다. +- `broker_credentials` 테이블(migration 0014)과 기존 Fernet 암호화 인프라(`app/core/encryption.py`, `CREDENTIAL_ENCRYPTION_KEY`)를 재사용해 CLI 레이어만 추가한다. + +## Requirement +- 시스템은 `pfhub credential set --user --broker --account-ref --app-key --app-secret ` 명령을 제공해야 한다. 이 명령은 `{"app_key": ..., "app_secret": ...}` JSON을 Fernet으로 암호화한 뒤 `broker_credentials` 테이블에 upsert한다. +- 시스템은 `pfhub credential list --user ` 명령을 제공해야 한다. 이 명령은 사용자의 브로커 인증 정보 목록을 반환하되, `encrypted_payload`는 출력하지 않는다. +- 시스템은 `pfhub credential remove --user --broker [--force]` 명령을 제공해야 한다. 이 명령은 해당 인증 정보 레코드를 삭제한다. +- 존재하지 않는 사용자를 지정하면 에러 메시지와 함께 exit code 1로 종료해야 한다. + +## Acceptance Criteria +1. Given alice 사용자와 유효한 `CREDENTIAL_ENCRYPTION_KEY`가 설정된 환경에서, When `pfhub credential set --user alice --broker kis --account-ref REF --app-key KEY --app-secret SECRET`을 실행하면, Then `broker_credentials` 테이블에 암호화된 payload로 레코드가 생성(또는 갱신)된다. +2. Given `broker_credentials`에 alice의 kis 레코드가 있을 때, When `pfhub credential list --user alice`를 실행하면, Then broker, account_ref, credential_version, last_rotated_at이 출력되고 `encrypted_payload`는 포함되지 않는다. +3. Given 존재하지 않는 username을 지정할 때, When `pfhub credential set --user unknown ...`을 실행하면, Then 에러 메시지와 함께 exit code 1로 종료된다. +4. Given alice의 kis 인증 정보가 있을 때, When `pfhub credential remove --user alice --broker kis --force`를 실행하면, Then 해당 레코드가 삭제되고 `pfhub credential list --user alice`에서 더 이상 표시되지 않는다. +5. Given `CREDENTIAL_ENCRYPTION_KEY`가 설정되지 않은 환경에서, When `pfhub credential set ...`을 실행하면, Then 설정 미비 에러 메시지와 함께 exit code 1로 종료된다. + +## Impacted Area +- Docs: `docs/project-structure.md` (CLI 모듈 목록 갱신) +- Modules: + - `app/cli/commands/credential.py` (신규 — `credential_app`, `set`, `list`, `remove` 명령) + - `app/cli/main.py` (`credential_app` 등록 추가) + - `app/repositories/postgres.py` (`delete_broker_credential` 메서드 추가) +- Tests: `tests/test_cli.py` (credential 명령 통합 테스트 추가) + +## Notes +- `broker_credentials` 테이블 DDL과 `upsert_broker_credential`, `list_broker_credentials`, `get_broker_credential` 메서드는 migration 0014에서 이미 구현됨. `delete_broker_credential` 메서드는 이번 REQ에서 추가. +- `app/core/encryption.py`의 `encrypt_payload(key, plaintext)` 사용. +- 기존 CLI 패턴(`_with_service` 헬퍼, `execute_command`, `render_output`, `_confirm`) 동일하게 적용. +- `--broker` 기본값은 `kis`. + +## Change Log + +### CHG-20260503-02 +- Date: 2026-05-03 +- Author: repository-maintainers + +#### Change Summary +- REQ-015 Approved 전환 — minor-change fast path (단일 저위험 REQ, CLI 레이어만 추가, 구조/런타임 영향 없음) + +#### Intent +- Acceptance Criteria 5개 모두 구현 판단에 충분하며 blocking assumption 없음 + +#### Acceptance Criteria Delta +- Added: none +- Updated: none +- Removed: none + +#### Impacted Area Delta +- Docs: none +- Modules: none +- Tests: none + +#### Delivery Trace Delta +- Batches: BAT-005 (batch-lite) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: run-batch-implementation 진행 + +#### Validation Plan +- BAT-005 verification 통과 확인 + +--- + +### CHG-20260503-01 +- Date: 2026-05-03 +- Author: repository-maintainers + +#### Change Summary +- REQ-015 최초 작성 — CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) + +#### Intent +- 운영자가 KIS 브로커 인증 정보를 CLI로 관리할 수 있도록 Interface 레이어 추가 + +#### Acceptance Criteria Delta +- Added: + - 위 Acceptance Criteria 1~5 전체 +- Updated: none +- Removed: none + +#### Impacted Area Delta +- Docs: `docs/project-structure.md` +- Modules: `app/cli/commands/credential.py` (신규), `app/cli/main.py`, `app/repositories/postgres.py` +- Tests: `tests/test_cli.py` + +#### Delivery Trace Delta +- Batches: 미정 (BAT-005 예정) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: 없음 + +#### Validation Plan +- `tests/test_cli.py`에 credential 명령 통합 테스트 작성 후 `pytest tests/test_cli.py` 전체 통과 확인 diff --git a/docs/srs/index.md b/docs/srs/index.md index c67acaf..98a3078 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-015` +- Current: `REQ-016` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -95,9 +95,12 @@ | REQ-012 | service-token rotate CLI 명령 구현 | Interface | Approved | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) | | REQ-013 | CLI `portfolio list --user` 필수화 및 소유자 기반 조회 | Interface | Implemented | Medium | repository-maintainers | [REQ-013](./Interface/req-013_portfolio-list-user-required.md) | | REQ-014 | CLI `portfolio create --user` 필수화 및 소유자 설정 | Interface | Implemented | Medium | repository-maintainers | [REQ-014](./Interface/req-014_portfolio-create-user-required.md) | +| REQ-015 | CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) | Interface | Approved | Medium | repository-maintainers | [REQ-015](./Interface/req-015_broker-credential-cli.md) | ## Recent Change Log Summary +- 2026-05-03: REQ-015 Approved 전환 — minor-change fast path (단일 저위험 REQ) +- 2026-05-03: REQ-015 Proposed 생성 — CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) - 2026-05-02: REQ-014 Implemented 전환 — BAT-004 verification evidence 확인 (44 passed) - 2026-05-02: REQ-014 Approved 직행 (minor-change) — CLI `portfolio create --user` 필수화 및 소유자 설정 - 2026-05-02: REQ-013 Implemented 전환 — BAT-003 verification evidence 확인 (41 passed) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a03bb6..eeb5100 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ from typer.testing import CliRunner from app.cli.main import create_app from app.core.config import Settings from app.core.security import hash_token, token_prefix -from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User +from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User class FakeRepo: @@ -25,6 +25,7 @@ class FakeRepo: self._tags: dict[str, PortfolioTag] = {} self._auth_tokens: dict[str, AuthToken] = {} self._token_seq = 1 + self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {} self.closed = False def close(self) -> None: @@ -429,6 +430,43 @@ class FakeRepo: raise ValueError("댓글을 찾을 수 없습니다") del self._comments[comment_id] + # --- broker credentials --- + + def upsert_broker_credential( + self, + user_id: str, + broker: str, + *, + account_ref: str, + encrypted_payload: str, + ) -> BrokerCredential: + key = (user_id, broker) + existing = self._broker_credentials.get(key) + version = (existing.credential_version + 1) if existing else 1 + credential = BrokerCredential( + user_id=user_id, + broker=broker, + account_ref=account_ref, + encrypted_payload=encrypted_payload, + credential_version=version, + last_rotated_at=datetime.now(timezone.utc), + ) + self._broker_credentials[key] = credential + return credential + + def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: + return [c for (uid, _), c in self._broker_credentials.items() if uid == user_id] + + def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None: + return self._broker_credentials.get((user_id, broker)) + + def delete_broker_credential(self, user_id: str, broker: str) -> bool: + key = (user_id, broker) + if key in self._broker_credentials: + del self._broker_credentials[key] + return True + return False + def _settings() -> Settings: return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000") @@ -1281,3 +1319,146 @@ def test_cli_portfolio_list_in_memory_mode_filters_owner() -> None: assert result.exit_code == 0 assert "005930:tv" in result.stdout + + +# --------------------------------------------------------------------------- +# credential command tests +# --------------------------------------------------------------------------- + +_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + +def _settings_with_enc_key() -> Settings: + return Settings( + db_enabled=True, + allowed_origins_raw="http://localhost:3000", + credential_encryption_key=_TEST_ENC_KEY, + ) + + +def build_runner_with_enc_key(repo: FakeRepo) -> CliRunner: + app = create_app(settings_factory=_settings_with_enc_key, repository_factory=lambda settings: repo) + runner = CliRunner() + runner.app = app + return runner + + +def test_cli_credential_set_creates_record() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="write") + runner = build_runner_with_enc_key(repo) + + result = runner.invoke( + runner.app, + [ + "credential", "set", + "--user", "alice", + "--broker", "kis", + "--account-ref", "12345678", + "--app-key", "PS_APP_KEY", + "--app-secret", "PS_APP_SECRET", + "--force", + ], + ) + + assert result.exit_code == 0 + assert "kis" in result.stdout + assert "12345678" in result.stdout + assert "PS_APP_KEY" not in result.stdout # encrypted_payload 미출력 확인 + + +def test_cli_credential_list_hides_payload() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="write") + runner = build_runner_with_enc_key(repo) + + runner.invoke( + runner.app, + [ + "credential", "set", + "--user", "alice", + "--broker", "kis", + "--account-ref", "12345678", + "--app-key", "MYKEY", + "--app-secret", "MYSECRET", + "--force", + ], + ) + result = runner.invoke(runner.app, ["credential", "list", "--user", "alice"]) + + assert result.exit_code == 0 + assert "kis" in result.stdout + assert "12345678" in result.stdout + assert "MYKEY" not in result.stdout + assert "MYSECRET" not in result.stdout + + +def test_cli_credential_set_with_unknown_user_exits_error() -> None: + repo = FakeRepo() + runner = build_runner_with_enc_key(repo) + + result = runner.invoke( + runner.app, + [ + "credential", "set", + "--user", "ghost", + "--broker", "kis", + "--account-ref", "REF", + "--app-key", "KEY", + "--app-secret", "SECRET", + "--force", + ], + ) + + assert result.exit_code == 1 + assert "ghost" in result.stdout + + +def test_cli_credential_remove_deletes_record() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="write") + runner = build_runner_with_enc_key(repo) + + runner.invoke( + runner.app, + [ + "credential", "set", + "--user", "alice", + "--broker", "kis", + "--account-ref", "REF", + "--app-key", "KEY", + "--app-secret", "SECRET", + "--force", + ], + ) + remove_result = runner.invoke( + runner.app, + ["credential", "remove", "--user", "alice", "--broker", "kis", "--force"], + ) + list_result = runner.invoke(runner.app, ["credential", "list", "--user", "alice"]) + + assert remove_result.exit_code == 0 + assert list_result.exit_code == 0 + assert "kis" not in list_result.stdout + + +def test_cli_credential_set_requires_encryption_key() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="write") + runner = build_runner(repo) # CREDENTIAL_ENCRYPTION_KEY 미설정 runner + + result = runner.invoke( + runner.app, + [ + "credential", "set", + "--user", "alice", + "--broker", "kis", + "--account-ref", "REF", + "--app-key", "KEY", + "--app-secret", "SECRET", + "--force", + ], + ) + + assert result.exit_code == 1 + assert "CREDENTIAL_ENCRYPTION_KEY" in result.stdout