feat(BAT-005): add broker credential CLI command with persistence and tests

- Add CredentialService for managing broker credentials
- Implement credential CLI commands (create, list, get, update, delete)
- Add postgres repository support for credential operations
- Add comprehensive test coverage for credential CLI
- Update docs/batches/index.md and docs/srs/index.md with BAT-005
- Update docs/project-structure.md to reflect credential service
This commit is contained in:
2026-05-03 12:44:40 +00:00
parent fb555dd72e
commit 222c66bb7f
12 changed files with 670 additions and 4 deletions
+127
View File
@@ -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)
+2
View File
@@ -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.portfolio import portfolio_app
from app.cli.commands.strategy import strategy_app from app.cli.commands.strategy import strategy_app
from app.cli.commands.user import user_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.core.config import Settings, get_settings
from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository from app.repositories.portfolio_repository import PortfolioRepository
@@ -51,6 +52,7 @@ def create_app(
app.add_typer(strategy_app, name="strategy") app.add_typer(strategy_app, name="strategy")
app.add_typer(user_app, name="user") app.add_typer(user_app, name="user")
app.add_typer(token_app, name="service-token") app.add_typer(token_app, name="service-token")
app.add_typer(credential_app, name="credential")
register_health_commands(app) register_health_commands(app)
return app return app
+11
View File
@@ -1398,6 +1398,17 @@ class PostgresPortfolioRepository:
row = cur.fetchone() row = cur.fetchone()
return self._row_to_broker_credential(row) 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 # Ledger trades
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -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 처리
@@ -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 |
@@ -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` 동일 구조). 구조/런타임 흐름 변경 없음.
@@ -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
- 없음
+2 -1
View File
@@ -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-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-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-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/) | | 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/) |
+2 -1
View File
@@ -27,7 +27,7 @@
- Type: mixed - Type: mixed
- Planned Entry Points: - Planned Entry Points:
- uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks - 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 - alembic upgrade head :: apply ordered database schema migrations for persistent environments
## Purpose ## Purpose
@@ -87,6 +87,7 @@
- All v1 endpoints require service-token authentication (token_source=db, token_type=service). - All v1 endpoints require service-token authentication (token_source=db, token_type=service).
- app/cli/ - app/cli/
- Defines the Typer entrypoint, command groups, output helpers, and operator-facing command flows. - 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/ - app/core/
- Owns configuration loading, auth guards, security helpers, and shared error handling. - 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()`. - `encryption.py`: Fernet-based credential payload encrypt/decrypt helpers. Reads `CREDENTIAL_ENCRYPTION_KEY` env var via `Settings.require_credential_encryption_key()`.
@@ -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 <username> --broker <broker> --account-ref <ref> --app-key <key> --app-secret <secret>` 명령을 제공해야 한다. 이 명령은 `{"app_key": ..., "app_secret": ...}` JSON을 Fernet으로 암호화한 뒤 `broker_credentials` 테이블에 upsert한다.
- 시스템은 `pfhub credential list --user <username>` 명령을 제공해야 한다. 이 명령은 사용자의 브로커 인증 정보 목록을 반환하되, `encrypted_payload`는 출력하지 않는다.
- 시스템은 `pfhub credential remove --user <username> --broker <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` 전체 통과 확인
+4 -1
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-015` - Current: `REQ-016`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## 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-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-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-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 ## 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 Implemented 전환 — BAT-004 verification evidence 확인 (44 passed)
- 2026-05-02: REQ-014 Approved 직행 (minor-change) — CLI `portfolio create --user` 필수화 및 소유자 설정 - 2026-05-02: REQ-014 Approved 직행 (minor-change) — CLI `portfolio create --user` 필수화 및 소유자 설정
- 2026-05-02: REQ-013 Implemented 전환 — BAT-003 verification evidence 확인 (41 passed) - 2026-05-02: REQ-013 Implemented 전환 — BAT-003 verification evidence 확인 (41 passed)
+182 -1
View File
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
from app.cli.main import create_app from app.cli.main import create_app
from app.core.config import Settings from app.core.config import Settings
from app.core.security import hash_token, token_prefix 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: class FakeRepo:
@@ -25,6 +25,7 @@ class FakeRepo:
self._tags: dict[str, PortfolioTag] = {} self._tags: dict[str, PortfolioTag] = {}
self._auth_tokens: dict[str, AuthToken] = {} self._auth_tokens: dict[str, AuthToken] = {}
self._token_seq = 1 self._token_seq = 1
self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {}
self.closed = False self.closed = False
def close(self) -> None: def close(self) -> None:
@@ -429,6 +430,43 @@ class FakeRepo:
raise ValueError("댓글을 찾을 수 없습니다") raise ValueError("댓글을 찾을 수 없습니다")
del self._comments[comment_id] 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: def _settings() -> Settings:
return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000") 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 result.exit_code == 0
assert "005930:tv" in result.stdout 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