feat: v1 portfolio mutation, sub-resources, ledger path migration (BAT-008~011)

dcy-003/004 기반 v1 API surface 완성 및 BAT-008~011 구현·검증 완료:

- BAT-008 (REQ-019~021,024): 토큰 surface baseline 문서 갱신
- BAT-009 (REQ-025~028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
  - _require_write_token, _get_portfolio_or_404, soft delete, daemon-scoped list
- BAT-010 (REQ-030~032): portfolio tag/comment 서브 라우트 신설 + baseline 갱신
  - GET/POST/DELETE /v1/portfolios/{id}/tags
  - POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments
- BAT-011 (REQ-029): ledger path migration
  - POST /v1/assets/{asset_id}/ledger/trades → /v1/portfolios/{portfolio_id}/ledger/trades
  - v1_ledger.py 제거, disabled 차단(409) 추가
  - test_v1.py / test_daemon_assignment.py 구 path 갱신
- 신규 tests: test_v1_portfolios_crud.py, test_v1_portfolios_list.py,
  test_v1_portfolios_tags_comments.py, test_v1_portfolios_ledger.py
- 전체 suite: 340 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 07:25:39 +00:00
co-authored by Claude Sonnet 4.6
parent c815ce39b6
commit 4bf6b554a7
50 changed files with 3989 additions and 249 deletions
+194 -19
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005
- Last Updated (KST): 2026-05-21 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004)
- Primary Runtime: mixed
## Project Summary
@@ -114,6 +114,43 @@
- If already inactive → return HTTP 200 immediately (idempotent).
- `repo.deactivate_tag(tag_id)` → HTTP 200 `TagItem`.
## Flow: `v1 API — Portfolio Mutation (POST/PATCH/DELETE /v1/portfolios)`
1. **POST /v1/portfolios** (create):
- Any valid token; `_require_write_token` blocks service/client (`token_type ∈ {service, client}` → HTTP 403).
- admin with `body.user_id` → creates portfolio for target user; otherwise uses `auth.user_id`.
- `repo.get_asset_by_id(asset_id)` → None or inactive → HTTP 422.
- `repo.get_or_create_strategy_set(strategy_name, strategy_params_json)` → resolves strategy.
- `repo.create_portfolio(...)` with `status="enabled"` → HTTP 201 `PortfolioItem`.
2. **PATCH /v1/portfolios/{portfolio_id}** (update):
- `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404(auth, portfolio_id, repo)` → checks ownership; non-owner non-admin → HTTP 403.
- If `portfolio.status == "disabled"`: non-status fields present → HTTP 409 (only status restoration allowed).
- `repo.update_portfolio(portfolio_id, **fields)` → HTTP 200 `PortfolioItem`.
3. **DELETE /v1/portfolios/{portfolio_id}** (soft delete):
- `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check.
- `repo.soft_delete_portfolio(portfolio_id)` → sets `status="disabled"`, `managed_by_daemon_id=NULL`.
- Dependent data (portfolio_comments, tag_ids) preserved. Portfolio still queryable with `?include_disabled=true`.
- Returns HTTP 200 `PortfolioItem` with `status="disabled"`.
## Flow: `v1 API — GET /v1/portfolios`
1. client token → HTTP 403. Any other valid token proceeds.
2. `service` token: `auth.daemon_id is None` → HTTP 403. `repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`.
3. `user`/`admin` token: admin with `?user_id=<id>` → filter by that user; else filter by `auth.user_id`.
4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter.
5. Returns HTTP 200 `PortfolioListResponse`.
## Flow: `v1 API — Soft Delete and Dependent Data Preservation`
1. `DELETE /v1/portfolios/{portfolio_id}` sets `portfolios.status="disabled"` and `portfolios.managed_by_daemon_id=NULL`.
2. `portfolio_comments` rows keyed by `portfolio_id` are NOT deleted; they remain retrievable via `GET /v1/portfolios/{id}/comments` even after soft delete.
3. `portfolios.tag_ids UUID[]` is NOT cleared; existing tag references are preserved.
4. Disabled portfolios are excluded from `GET /v1/portfolios` responses by default (`?include_disabled=false`).
5. Write mutations (PATCH non-status fields, POST/PATCH/DELETE tags, POST/PATCH/DELETE comments) on a disabled portfolio → HTTP 409. READ operations (GET tags, GET comments) remain allowed.
6. Status restoration: `PATCH /v1/portfolios/{id}` with `{"status": "enabled"}` is the only allowed mutation on a disabled portfolio; this re-enables the portfolio.
## Flow: `v1 API — Tag Apply/Remove (POST/DELETE /v1/assets/{id}/tags and /v1/portfolios/{id}/tags)`
1. **POST /v1/assets/{asset_id}/tags** (apply tag to asset — admin only):
@@ -127,18 +164,47 @@
- `repo.get(asset_id)` → ValueError → HTTP 404.
- `repo.remove_portfolio_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
3. **POST /v1/portfolios/{portfolio_id}/tags** (apply tag to portfolio — owner or admin):
- DB token required; trade-write or admin permission.
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`).
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
- Caller must be portfolio owner or admin; else → HTTP 403.
- `portfolio.status == "disabled"` → HTTP 409 (disabled portfolio write blocked).
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
- `not tag.is_active` → HTTP 403.
- Policy: `tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id`; else → HTTP 403.
- `repo.append_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}`.
4. **DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}** (remove tag from portfolio — owner or admin):
- DB token required; trade-write or admin permission.
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`).
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
- Caller must be portfolio owner or admin; else → HTTP 403.
- `portfolio.status == "disabled"` → HTTP 409.
- `repo.remove_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
5. **GET /v1/portfolios/{portfolio_id}/tags** (list applied tags — REQ-030, BAT-010):
- `require_read`; service/client → HTTP 403 (inline).
- `_get_portfolio_or_404(auth, portfolio_id, repo)` → ownership check; disabled portfolio allowed (GET).
- `repo.get_tags_by_ids(portfolio.tag_ids)` → dict; empty tag_ids → `PortfolioTagListResponse(tags=[], total=0)`.
- Returns HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": <int>}`.
## Flow: `v1 API — Portfolio Comment CRUD (REQ-031, BAT-010)`
1. **POST /v1/portfolios/{portfolio_id}/comments** (create):
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
- `repo.create_comment(portfolio_id, content=body.content)` → HTTP 201 `PortfolioCommentItem`.
2. **GET /v1/portfolios/{portfolio_id}/comments** (list):
- `require_read`; service/client → HTTP 403 (inline).
- `_get_portfolio_or_404` → ownership check; disabled portfolio GET is allowed.
- `repo.list_comments(portfolio_id)` → list in `created_at desc` order.
- Returns HTTP 200 `PortfolioCommentListResponse {"comments": [...], "total": <int>}`.
3. **PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}** (update):
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
- `repo.update_comment(portfolio_id, comment_id, content=body.content)` → ValueError → HTTP 404.
- Returns HTTP 200 `PortfolioCommentItem`.
4. **DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}** (delete):
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
- `repo.delete_comment(portfolio_id, comment_id)` → ValueError → HTTP 404.
- Returns HTTP 204 (no content).
## Flow: `v1 API — DB Token Authentication`
@@ -146,6 +212,8 @@
2. `require_read` / `require_admin` resolves the token: checks env tokens first, then DB lookup via `get_token_principal()`.
3. Env tokens → HTTP 403 (all /v1 endpoints require DB tokens).
4. `AuthContext` is populated with `user_id`, `token_type`, `permission`, `runtime_id`, and `daemon_id`.
- `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006)
- `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed
## Flow: `v1 API — GET /v1/assets`
@@ -175,25 +243,28 @@
4. For each credential, Fernet-decrypts `encrypted_payload` using `CREDENTIAL_ENCRYPTION_KEY`; decryption failures log a warning and return `credential_payload: null`.
5. Returns `UserInfoResponse` with `trading_enabled` (from `is_active`), `disabled_reason`, `brokers[]`, `runtime_id` (null if unbound), and `daemon_id` (null for legacy/unbound tokens — diagnostic, not an error).
## Flow: `v1 API — POST /v1/assets/{asset_id}/ledger/trades`
## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011)
1. Service-token auth required with `trade-write` permission.
2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing.
3. Daemon assignment authorization (fail-closed, three checks in order):
a. `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token").
b. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
c. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment").
4. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`.
5. If new record: returns HTTP 201 with `status="new"`.
6. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`.
7. If duplicate key with different payload: returns HTTP 409 with conflict detail.
1. Service-token auth required with `trade-write` permission (`_require_service_token` dependency).
2. `token_source != "db"` or `token_type != "service"` → HTTP 403.
3. `auth.daemon_id is None` → HTTP 403 fail-closed ("daemon_id not assigned to this token").
4. `repo.get(portfolio_id)` → exception → HTTP 404.
5. `portfolio.status == "disabled"` → HTTP 409 (disabled write 차단, REQ-027 정합).
6. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
7. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment").
8. `insert_ledger_trade(asset_id=portfolio_id, ...)` with idempotency key `(portfolio_id, order_id, execution_id)`.
9. New record → HTTP 201 + `status="new"`.
10. Duplicate key + identical payload → HTTP 200 + `status="duplicate"`.
11. Duplicate key + different payload → HTTP 409.
- 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404.
## Flow: `v1 API — POST /auth/service-tokens`
1. Admin token auth required (`require_admin` dependency).
2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`.
3. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id`.
4. Returns HTTP 201 with `token` (raw value), `runtime_id`, and `auth_token` metadata.
2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`, `daemon_id` (optional; BAT-006/REQ-018 forward 보정).
3. If `daemon_id` provided: `get_daemon_by_id()` → 존재하지 않거나 inactive → HTTP 422. daemon.owner_user_id ≠ 발급 대상 user → HTTP 422.
4. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id` and `daemon_id`.
5. Returns HTTP 201 with `token` (raw value), `runtime_id`, `daemon_id`, and `auth_token` metadata.
## Flow: `Daemon Registry — Create and Bind Service Token`
@@ -206,7 +277,25 @@
- `create_auth_token()` persists the token with `daemon_id` set.
3. Operator assigns an asset to the daemon via portfolio update (`managed_by_daemon_id` column set).
4. `GET /v1/assets` with the bound token returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id`.
5. `POST /v1/assets/{asset_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade.
5. `POST /v1/portfolios/{portfolio_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade (신 path, REQ-029).
## Flow: `pfhub service-token rebind` (BAT-007/REQ-023)
1. Operator calls `pfhub service-token rebind <name> --daemon-id <daemon_id>`.
2. `AuthService.rebind_service_token(name, daemon_id)`:
a. `get_service_token_by_name(name)` → None → ValueError.
b. `get_daemon_by_id(daemon_id)` → None 또는 is_active=false → ValueError.
c. `daemon.owner_user_id ≠ token.user_id` → ValueError ("해당 토큰 사용자에 속하지 않습니다").
d. `repository.rebind_service_token(name, daemon_id)` → token.daemon_id 갱신, runtime_id 불변.
3. Returns `(previous_daemon_id, rebound_token)`.
## Flow: `pfhub daemon update --deactivate` (BAT-007/REQ-022)
1. Operator calls `pfhub daemon update <daemon_id> --deactivate [--force]`.
2. `--force` 없으면 확인 프롬프트; 거절 시 exit 0.
3. `repo.update_daemon(daemon_id, is_active=False)` — daemon is_active=false 설정.
4. `repo.revoke_tokens_by_daemon_id(daemon_id)` — 바인딩된 모든 active service token에 `revoked_at=NOW()` 설정.
5. Console에 revoked count 출력.
## Flow: `Service-Token Rotate — daemon_id Invariant`
@@ -273,6 +362,89 @@
4. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행.
5. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판).
## Token Expiry and Rotation Policy
(REQ-021/BAT-008 — dcy-004 FR-6, OQ-5 결정: 현행 TTL 유지)
### Type별 기본 TTL
| Token Type | 기본 만료 | no_expiry 허용 | 비고 |
|------------|-----------|----------------|------|
| session | `NOW() + settings.session_token_ttl_hours` | ✗ (거부) | `AuthService.login()` |
| service | `NOW() + 365일` (기본) | ✓ | `AuthService.issue_service_token(no_expiry=True)` |
| env | 없음 (TTL 미적용) | N/A | `PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수 |
### no_expiry 허용 범위
- service token: `pfhub service-token issue <name> --no-expiry` 또는 POST /auth/service-tokens body `"no_expiry": true` → expires_at=NULL 발급.
- session: no_expiry 요청 불가. 요청 시 → ValueError / HTTP 422 거부.
- ⚠ 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 미구현. 정책은 거부이며 follow-up으로 코드 enforcement 추가 예정.
- `no_expiry`와 `expires_in_days`를 동시에 지정하면 ValueError ("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다").
### 강제 회전 주기
- session / service: 강제 회전 주기 없음.
- env admin token: 분기(3개월) 권장 회전, 강제 없음 (아래 SOP 참조).
### 정책 위반 처리
- no_expiry를 session에 요청 → 정책상 HTTP 422 또는 ValueError 거부 (미구현 — follow-up 필요).
- 코드 reference: `app/services/auth_service.py` `AuthService.issue_service_token()` no_expiry + expires_in_days 분기 검증.
## env admin token 운영 SOP
(REQ-024/BAT-008 — dcy-004 FR-9, OQ-6 결정: 분기 권장 회전, 강제 없음)
### 적용 범위
`PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수로 주입되는 admin token. dev/test/prod 모든 환경에 적용. prod 환경 약한 placeholder 발견 시 즉시 적용.
### 권장 회전 주기
분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율 결정.
### 회전 트리거 조건
- (a) 정기: 분기마다
- (b) token leak 의심
- (c) 운영자 교체·퇴사
- (d) 보안 사고 대응
### 다단계 회전 절차 (7단계)
1. 강한 무작위 token 생성:
```
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
```
2. `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token을 콤마 분리로 **추가** (다중 token 동시 운영):
```
PORTFOLIOHUB_ADMIN_TOKENS=<구_token>,<신규_token>
```
3. service rolling restart (구 token 계속 유효).
4. 신규 token으로 admin 호출 검증 (예: `GET /health`, admin 작업 sanity check).
5. 구 token을 `PORTFOLIOHUB_ADMIN_TOKENS`에서 **제거**:
```
PORTFOLIOHUB_ADMIN_TOKENS=<신규_token>
```
6. service rolling restart (구 token 무효화).
7. evidence 기록: 회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과.
### rollback 절차
- 회전 실패 시: 환경 변수에 구 token 재추가 후 rolling restart.
- fallback admin 접근 경로: DB 직접 접근으로 임시 token 재생성 (비상 시).
### Token 강도 가이드
- 최소 48-byte URL-safe 무작위 (`secrets.token_urlsafe(48)` 이상).
- 평문 단어, 예측 가능한 패턴 (예: `admin-token-789`) 금지.
- dev/test 환경에서도 약한 placeholder 사용 금지.
### 운영자 권한
- `PORTFOLIOHUB_ADMIN_TOKENS` 추가/제거는 인프라 운영자 권한.
- 현행 코드(`app/core/auth.py:_resolve_permission`)가 set 기반으로 다중 token 처리 → 추가 코드 변경 없이 다중 token 동시 운영 가능.
## Current Gaps / Planned Changes
- End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending.
@@ -281,6 +453,9 @@
## Change Log
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024).
- 2026-05-20 KST: bat-005 — REQ-008 AC-4: added pfhub tag CLI flow; added v1 tag management flow (POST/GET/PATCH /v1/tags); added v1 tag apply/remove flow (/v1/assets/{id}/tags, /v1/portfolios/{id}/tags); updated Covered Entry Points (tags).
- 2026-05-13 KST: bat-010 — REQ-006/007/008/009/023/005/012: updated GET /v1/assets flow (daemon-id guard, daemon-scoped filter); updated GET /v1/user_info flow (daemon_id diagnostic, NULL → 200+null); updated POST ledger/trades flow (3-step daemon assignment auth, fail-closed); added "Daemon Registry — Create and Bind Service Token" flow; added "Service-Token Rotate — daemon_id Invariant" flow; updated Alembic/ensure_schema boundary (BAT-010 migration 20260513_0016); updated Covered Entry Points (daemon subcommand).
- 2026-05-11 KST: bat-008 — REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001; Source Discovery/Batch header updated.