feat(bat-022): daemon CRUD admin API + self-service credential management (REQ-052/053/054)
- POST/PATCH/deactivate/activate /v1/admin/daemons — admin-only daemon CRUD (REQ-053)
- GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned scoped credential management (REQ-052)
- Owner re-assignment auto-invalidates BrokerCredential via update_daemon_owner() (REQ-053 AC-4)
- Deactivation auto-revokes service tokens via revoke_tokens_by_daemon_id() (REQ-022 reuse, REQ-053 AC-6)
- DELETE /v1/admin/daemons/{id} → 405 (hard delete not provided, REQ-053 AC-8)
- All responses exclude app_key/app_secret; Fernet encryption at rest (REQ-054)
- Service-token → 403 on credential endpoints (REQ-052 AC-10)
- Admin ownership scope enforced: admin cannot write other user's credential (REQ-052 AC-9)
- REQ-051 regression: 4 service-token guard tests pass (REQ-054 AC-7)
- 44 new tests, 565 total passed (521 baseline + 44 new), 0 failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# BAT-022 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-022
|
||||
|
||||
## OQ-1 해소: 인증정보 관리 엔드포인트 경로 (REQ-052)
|
||||
|
||||
**확정: `/v1/daemons/{daemon_id}/credential`**
|
||||
|
||||
근거:
|
||||
- v1 컨벤션: `/v1/daemons/{daemon_id}`가 daemon 리소스 계층 루트. credential은 daemon의 하위 리소스이므로 중첩 경로가 REST 의미에 맞는다.
|
||||
- 소유 스코프 자연성: daemon_id가 경로에 있어 소유 검증(`daemon.owner_user_id == auth.user_id`) 경로 파라미터에서 바로 가능.
|
||||
- dcy-009 UI 흐름: 사용자가 자기 데몬을 선택한 뒤 인증정보를 관리하는 흐름과 일치.
|
||||
- 기존 `/v1/daemons` GET이 daemon 계층을 쓰므로 일관성 유지.
|
||||
|
||||
메서드:
|
||||
- `GET /v1/daemons/{daemon_id}/credential` — 메타데이터 조회 (has_credential, version, last_rotated_at)
|
||||
- `PUT /v1/daemons/{daemon_id}/credential` — 등록/회전 (upsert, 응답 메타데이터만)
|
||||
- `DELETE /v1/daemons/{daemon_id}/credential` — 삭제 (204)
|
||||
|
||||
브로커 지정: PUT 요청 body에 `broker` 필드(기본값 `"kis"`). 현재 단일 broker 가정이므로 경로에 broker를 두지 않는다. 다중 broker 지원 필요 시 `/v1/daemons/{daemon_id}/credentials/{broker}` 로 확장 경로로 분리 가능(하위 호환).
|
||||
|
||||
## OQ-2 해소: admin 데몬 CRUD 경로 (REQ-053)
|
||||
|
||||
**확정: `/v1/admin/daemons`**
|
||||
|
||||
근거:
|
||||
- dcy-006 패턴: `/v1/admin/...` prefix를 admin 전용 관리 API의 네임스페이스로 일관되게 사용한다. dcy-006에서 ledger 회계 통합 경로는 `/v1/portfolios/...`를 사용했으나, admin 전용 mutate는 다른 서비스(users.py 등)도 `/users`에 admin 게이트를 두는 방식. 여기서 `/v1/daemons` 기존 경로에 POST/PATCH를 추가하면 GET(caller-scoped, REQ-045)과 POST(admin-only, REQ-053)가 같은 경로에 혼재되어 권한 경계가 불명확해진다.
|
||||
- `/v1/admin/daemons`는 admin 전용 write 작업임을 경로 자체로 명시.
|
||||
- trex-web dcy-009 소비자가 admin 경로를 별도 컨텍스트에서 관리하기 쉽다.
|
||||
|
||||
메서드:
|
||||
- `POST /v1/admin/daemons` → 201 DaemonAdminResponse (생성)
|
||||
- `PATCH /v1/admin/daemons/{daemon_id}` → 200 DaemonAdminResponse (display_name 변경, owner 재할당)
|
||||
- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 DaemonAdminResponse
|
||||
- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 DaemonAdminResponse
|
||||
- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (명시적 미제공)
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
| --- | --- |
|
||||
| `app/api/v1_admin_daemons.py` | REQ-053 admin daemon CRUD 라우터 |
|
||||
| `app/api/v1_daemon_credential.py` | REQ-052 소유 스코프 인증정보 관리 라우터 |
|
||||
|
||||
### 변경 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
| --- | --- |
|
||||
| `app/schemas/v1.py` | REQ-052 응답/요청 스키마 + REQ-053 응답/요청 스키마 추가 |
|
||||
| `app/repositories/postgres.py` | `get_broker_credential_by_daemon`, `delete_credentials_by_daemon_id`, `update_daemon_owner` 추가 |
|
||||
| `app/repositories/portfolio_repository.py` | Protocol에 신규 메서드 추가 |
|
||||
| `app/main.py` | 신규 라우터 include |
|
||||
|
||||
### 신규 테스트 파일
|
||||
|
||||
| 파일 | 커버 |
|
||||
| --- | --- |
|
||||
| `tests/test_bat022_req052_credential_api.py` | REQ-052 AC-1~10 |
|
||||
| `tests/test_bat022_req053_admin_daemon_crud.py` | REQ-053 AC-1~12 |
|
||||
| `tests/test_bat022_req054_security.py` | REQ-054 AC-1~7 |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **소유 스코프 게이트 위치**: 라우터 내에서 daemon 조회 후 `daemon.owner_user_id != auth.user_id` 즉시 403. 서비스 레이어 분리 없이 라우터에서 직접 처리(기존 v1_portfolios.py 패턴 동일).
|
||||
|
||||
2. **service-token 403 (REQ-052 AC-10)**: `auth.token_source == "db" and auth.token_type == "service"` 이면 403. env-token(token_source="env")은 token_type이 None이므로 이 조건에 해당하지 않는다. env admin token으로 credential 관리는 허용하지 않는다(user_id가 없으므로 소유 스코프 검사에서 403 발생).
|
||||
|
||||
3. **admin도 타인 credential write 403 (REQ-052 AC-9)**: admin role이라도 `auth.user_id != daemon.owner_user_id`이면 403. 인증정보 소유 스코프는 admin role을 우선시하지 않는다. admin은 REQ-053(`/v1/admin/daemons`)으로 데몬 provisioning만 담당.
|
||||
|
||||
4. **owner 재할당 + credential 무효화 (REQ-053 AC-4)**: `update_daemon_owner()`에서 먼저 daemon의 owner를 업데이트하고 이후 `DELETE FROM broker_credentials WHERE daemon_id = %s` 호출. autocommit 모드이므로 두 연산이 별도. 인증정보 삭제 실패는 로그 경고 후 무시(데몬 업데이트는 이미 완료).
|
||||
|
||||
5. **DELETE 405 (REQ-053 AC-8)**: `@router.delete("/v1/admin/daemons/{daemon_id}")` 핸들러를 명시 등록해 HTTP 405를 반환. FastAPI 기본은 미등록 DELETE → 405 (Method Not Allowed)이나 명시 등록이 더 안전.
|
||||
|
||||
6. **slug 중복 → 409 (REQ-053 AC-2)**: postgres.py create_daemon에서 UniqueViolation → ValueError 변환. 라우터에서 ValueError 메시지 패턴 매칭으로 409 반환.
|
||||
|
||||
7. **DELETE credential 204 (REQ-052 AC-5)**: 인증정보가 없어도 204 반환(멱등). 있으면 삭제 후 204.
|
||||
|
||||
8. **브로커 경로 단순화**: PUT body에 `broker: str = "kis"`. GET/DELETE는 기본 broker(현재 단일 broker 가정).
|
||||
|
||||
## Changed Areas
|
||||
|
||||
- app/api/v1_admin_daemons.py — 신규
|
||||
- app/api/v1_daemon_credential.py — 신규
|
||||
- app/schemas/v1.py — CredentialSetRequest, CredentialMetaResponse, DaemonCreateRequest, DaemonUpdateRequest, DaemonAdminResponse 추가
|
||||
- app/repositories/postgres.py — get_broker_credential_by_daemon, delete_credentials_by_daemon_id, update_daemon_owner 추가
|
||||
- app/repositories/portfolio_repository.py — Protocol 동기화
|
||||
- app/main.py — 라우터 include
|
||||
- tests/test_bat022_req052_credential_api.py — 신규
|
||||
- tests/test_bat022_req053_admin_daemon_crud.py — 신규
|
||||
- tests/test_bat022_req054_security.py — 신규
|
||||
@@ -0,0 +1,47 @@
|
||||
# BAT-022 — daemon CRUD admin API + self-service 인증정보 관리 API
|
||||
|
||||
- Batch ID: BAT-022
|
||||
- Profile: standard
|
||||
- Status: in-progress
|
||||
- Discovery: dcy-011
|
||||
- Included REQ: REQ-052, REQ-053, REQ-054
|
||||
- Folder: docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/
|
||||
- Created: 2026-06-28
|
||||
|
||||
## Summary
|
||||
|
||||
dcy-011(daemon admin CRUD + 사용자 self-service 브로커 인증정보 관리 API)을 portfolio-hub에 구현한다.
|
||||
|
||||
- REQ-052: user-token + 소유 스코프 인증정보 set/rotate/delete + 메타데이터 GET (`/v1/daemons/{daemon_id}/credential`)
|
||||
- REQ-053: admin 데몬 생성·owner 할당·변경·(비)활성화 (`/v1/admin/daemons`)
|
||||
- REQ-054: 모든 관리 API 응답/로그에 app_key/app_secret 평문 미포함(보안 격리) + REQ-051 회귀 PASS
|
||||
|
||||
dcy-010 자산(BrokerCredential 모델, Fernet 암호화, upsert/delete 메서드) 재사용. CLI와 동일 repo 메서드 공유.
|
||||
|
||||
## Included REQ
|
||||
|
||||
| REQ | 제목 | Type | 의존 |
|
||||
| --- | --- | --- | --- |
|
||||
| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | dcy-010 자산(BrokerCredential, Fernet) |
|
||||
| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Daemon 모델, REQ-022(service token revoke) |
|
||||
| REQ-054 | 인증정보 관리 보안 격리 | Non-Functional | REQ-052, REQ-053, REQ-051 |
|
||||
|
||||
## DoR (진입 조건)
|
||||
|
||||
- [x] REQ-052/053/054 전건 Approved 상태 확인
|
||||
- [x] dcy-011 confirmed Discovery 확인(OQ-1/OQ-2 Deferred → design 단계 해소)
|
||||
- [x] 구현 대상 파일 존재 확인: app/repositories/postgres.py, app/domain/models.py, app/core/auth.py, app/core/encryption.py
|
||||
- [x] dcy-010 자산 존재 확인: BrokerCredential PK (daemon_id, broker), upsert_broker_credential, list_broker_credentials, delete_broker_credential
|
||||
|
||||
## DoD (완료 조건)
|
||||
|
||||
- [ ] REQ-052 AC-1~10 테스트 통과
|
||||
- [ ] REQ-053 AC-1~12 테스트 통과
|
||||
- [ ] REQ-054 AC-1~7 테스트 통과 (caplog 비노출, 응답 secret 부재, REQ-051 회귀)
|
||||
- [ ] 기존 521 테스트 회귀 없음
|
||||
- [ ] draft-batch-verification 문서에 AC별 evidence 기록
|
||||
|
||||
## 비가역 결재 게이트
|
||||
|
||||
- DB 마이그레이션: 신규 DDL 없음. 기존 ensure_schema() + postgres.py 메서드 확장으로 처리 (비가역 없음).
|
||||
- HTTP API 계약 신설: trex-web dcy-009가 소비 예정. 이 batch는 계약 신설이므로 기존 소비자에 영향 없음(호환 깨짐 없음).
|
||||
@@ -0,0 +1,85 @@
|
||||
# BAT-022 Planning
|
||||
|
||||
## 구현 의존 순서
|
||||
|
||||
1. **REQ-052/053 스키마** — `app/schemas/v1.py`에 `CredentialSetRequest`, `CredentialMetaResponse`, `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 추가
|
||||
2. **REQ-053 repository 확장** — `postgres.py`에 `update_daemon_owner()` (owner 변경 + 인증정보 자동 삭제) 신규. `update_daemon()` 재사용 가능한 부분은 그대로.
|
||||
3. **REQ-052 repository 확장** — `postgres.py`에 `get_broker_credential_meta()` (단일 credential 조회, user_id 소유 검증 포함) 추가. 기존 `upsert_broker_credential`, `delete_broker_credential` 재사용.
|
||||
4. **REQ-053 라우터** — `app/api/v1_admin_daemons.py` 신규. `POST /v1/admin/daemons`, `PATCH /v1/admin/daemons/{daemon_id}`, `POST /v1/admin/daemons/{daemon_id}/activate`, `POST /v1/admin/daemons/{daemon_id}/deactivate`. `require_admin` 사용.
|
||||
5. **REQ-052 라우터** — `app/api/v1_daemon_credential.py` 신규. `GET /v1/daemons/{daemon_id}/credential`, `PUT /v1/daemons/{daemon_id}/credential`, `DELETE /v1/daemons/{daemon_id}/credential`. `require_read` + 소유 스코프 검사.
|
||||
6. **REQ-052/053/054 테스트** — `tests/test_bat022_*.py` 신규 (AC별 pytest 케이스)
|
||||
7. **REQ-054 보안 격리 테스트** — caplog, 응답 JSON 필드 열거, REQ-051 회귀
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### T-1: 스키마 추가 (app/schemas/v1.py)
|
||||
|
||||
**REQ-052 스키마:**
|
||||
- `CredentialSetRequest` — `app_key: str`, `app_secret: str`, `account_ref: str`, `broker: str = "kis"`
|
||||
- `CredentialMetaResponse` — `daemon_id: str`, `broker: str`, `account_ref: str`, `has_credential: bool`, `credential_version: int`, `last_rotated_at: datetime | None` (secret 필드 없음)
|
||||
|
||||
**REQ-053 스키마:**
|
||||
- `DaemonCreateRequest` — `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str`
|
||||
- `DaemonUpdateRequest` — `display_name: str | None`, `owner_user_id: str | None`
|
||||
- `DaemonAdminResponse` — `id: str`, `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str`, `is_active: bool` (credential 필드 없음)
|
||||
|
||||
### T-2: repository 메서드 추가 (app/repositories/postgres.py + protocol)
|
||||
|
||||
- `get_broker_credential_by_daemon` — `(daemon_id, broker)` 로 단일 BrokerCredential 조회(없으면 None). 소유 검증은 서비스/라우터 레이어에서.
|
||||
- `delete_credentials_by_daemon_id` — daemon_id에 속한 모든 BrokerCredential 삭제(owner 재할당 시 무효화용). REQ-053 AC-4 지원.
|
||||
- `update_daemon_owner` — owner_user_id 변경 + 해당 daemon의 모든 broker_credentials 삭제 (단일 메서드 atomic). owner 변경 없이 display_name만 변경하면 기존 `update_daemon()` 재사용.
|
||||
- Protocol(`portfolio_repository.py`) 동기화.
|
||||
|
||||
### T-3: admin daemon 라우터 (app/api/v1_admin_daemons.py)
|
||||
|
||||
경로: `/v1/admin/daemons`
|
||||
|
||||
- `POST /v1/admin/daemons` → `DaemonCreateRequest` → 201 + `DaemonAdminResponse`
|
||||
- 중복 slug → 409 Conflict
|
||||
- 비admin/service-token → 403, 비인증 → 401
|
||||
- `PATCH /v1/admin/daemons/{daemon_id}` → `DaemonUpdateRequest` → 200 + `DaemonAdminResponse`
|
||||
- owner 변경 시 `update_daemon_owner()` 호출(인증정보 자동 무효화)
|
||||
- owner 변경 없으면 `update_daemon(display_name=...)` 호출
|
||||
- 미존재 → 404
|
||||
- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 + `DaemonAdminResponse`
|
||||
- `update_daemon(is_active=False)` + `revoke_tokens_by_daemon_id()` (REQ-022 재사용)
|
||||
- 미존재 → 404
|
||||
- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 + `DaemonAdminResponse`
|
||||
- `update_daemon(is_active=True)` 호출
|
||||
- 미존재 → 404
|
||||
- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (하드 삭제 미제공)
|
||||
|
||||
### T-4: credential 라우터 (app/api/v1_daemon_credential.py)
|
||||
|
||||
경로: `/v1/daemons/{daemon_id}/credential`
|
||||
|
||||
공통 소유 스코프 게이트:
|
||||
- 비인증(토큰 없음) → 401 (require_read가 처리)
|
||||
- service-token(`token_type == "service"`) → 403
|
||||
- auth.user_id가 daemon.owner_user_id와 불일치 → 403
|
||||
- 미존재 daemon_id → 404
|
||||
|
||||
엔드포인트:
|
||||
- `GET /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse`
|
||||
- 인증정보 없으면 `has_credential: false` + 나머지 null
|
||||
- `PUT /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse`
|
||||
- `app_key`/`app_secret` Fernet 암호화 → `upsert_broker_credential()` 호출
|
||||
- 응답에 app_key/app_secret 미포함
|
||||
- `DELETE /v1/daemons/{daemon_id}/credential` → 204 No Content (인증정보 없어도 204)
|
||||
|
||||
### T-5: main.py 라우터 등록
|
||||
|
||||
- `v1_admin_daemons_router`, `v1_daemon_credential_router` include
|
||||
|
||||
### T-6: 테스트 신규 (tests/test_bat022_*.py)
|
||||
|
||||
- `test_bat022_req052_credential_api.py` — REQ-052 AC-1~10 (소유 스코프 음성 포함)
|
||||
- `test_bat022_req053_admin_daemon_crud.py` — REQ-053 AC-1~12 (owner 재할당 + 인증정보 무효화, DELETE 405 포함)
|
||||
- `test_bat022_req054_security.py` — REQ-054 AC-1~7 (caplog, secret 필드 부재, REQ-051 회귀)
|
||||
|
||||
## Risks / Constraints
|
||||
|
||||
- service-token 403 (REQ-052 AC-10): `token_type == "service"` 체크는 `auth.token_source == "db"` 전제. env-token은 `token_type=None`이므로 별도 분기 필요.
|
||||
- admin role 검증 (REQ-053): `require_admin`은 기존 permission hierarchy(admin > trade-write > read)를 사용. env admin 토큰도 처리.
|
||||
- owner 변경 + 인증정보 무효화(REQ-053 AC-4): `update_daemon_owner()`는 단일 DB 트랜잭션이 아님(psycopg3 autocommit). partial failure(daemon 업데이트 성공 + 인증정보 삭제 실패)는 드문 케이스이나 로그 경고 추가.
|
||||
- DELETE 405 (REQ-053 AC-8): FastAPI에서 미등록 경로의 DELETE는 404를 반환. 명시적 405 반환하려면 라우터에 `DELETE` 핸들러를 405 고정으로 등록해야 함.
|
||||
Reference in New Issue
Block a user