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:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
- 없음
|
||||
@@ -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/) |
|
||||
| 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/) |
|
||||
@@ -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()`.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+182
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user