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:
+155
-6
@@ -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-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-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
|
||||
@@ -70,6 +70,8 @@
|
||||
### Inputs
|
||||
|
||||
- pfhub subcommands :: include user, portfolio, strategy, tag, service-token, daemon, credential, health-check, init-db, and migrate-db command families
|
||||
- `service-token` subcommands: `issue`, `rotate`, `rebind` (BAT-007: `--daemon-id` 변경), `revoke`, `list`, `migrate-null-daemon --dry-run` (BAT-007: NULL daemon_id 진단 CLI)
|
||||
- `daemon` subcommands: `create`, `list`, `update` (BAT-007: `--deactivate --force` 시 바인딩 service token 자동 revoke)
|
||||
- Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments
|
||||
- Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
|
||||
- Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` sub-commands accept portfolio_id (UUID) directly
|
||||
@@ -105,20 +107,24 @@
|
||||
- All /v1 endpoints require `Authorization: Bearer <token>` header.
|
||||
- Token must be DB-issued (`token_source="db"`); env tokens are rejected with HTTP 403.
|
||||
- Admin catalog endpoints (`POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate`) require admin permission.
|
||||
- POST /v1/assets/{asset_id}/ledger/trades additionally requires `trade-write` permission.
|
||||
- POST /v1/portfolios/{portfolio_id}/ledger/trades additionally requires `trade-write` permission and a DB service token with non-NULL `daemon_id`.
|
||||
|
||||
### Token Type Branching
|
||||
|
||||
- `token_type="service"`: daemon-scoped endpoints. `daemon_id` must be non-NULL (NULL → HTTP 403 fail-closed).
|
||||
- `token_type="user"`: user-scoped. Endpoints return owner's own data.
|
||||
- `token_type="admin"`: admin-scoped. May query other users' data via `?user_id=<id>` param.
|
||||
- `token_type="session"`: user-scoped (login token). Endpoints return owner's own data.
|
||||
- `token_type="client"`: **Deprecated/Removed** (REQ-016/BAT-006). 발급 경로 제거. 잔존 DB row는 읽을 수 있으나 신규 발급 불가. v1 API 호출 시 HTTP 403 fail-closed.
|
||||
- env token (token_source="env"): admin-scoped. May query other users' data via `?user_id=<id>` param. DB /v1 endpoints에서는 HTTP 403 (DB token만 허용).
|
||||
|
||||
### Daemon Authorization Model
|
||||
|
||||
- Service tokens are bound to a daemon at issuance via `daemon_id` field. Legacy/unbound tokens have `daemon_id=NULL`.
|
||||
- GET /v1/assets (service token) and POST /v1/assets/{asset_id}/ledger/trades are daemon-scoped. NULL token `daemon_id` → HTTP 403 (fail-closed).
|
||||
- GET /v1/assets (service token) and POST /v1/portfolios/{portfolio_id}/ledger/trades are daemon-scoped. NULL token `daemon_id` → HTTP 403 (fail-closed).
|
||||
- GET /v1/user_info is available to any service token; returns `daemon_id: null` as diagnostic for unbound tokens.
|
||||
- Each asset (portfolio) may be assigned to exactly one daemon via `portfolios.managed_by_daemon_id`. Mismatch or unassigned asset → HTTP 403 on ledger writes.
|
||||
- **Token rebind** (BAT-007/REQ-023): `pfhub service-token rebind <name> --daemon-id <id>` — daemon_id 변경, runtime_id 불변. 대상 daemon은 active이어야 하고 token 소유 user와 일치해야 한다.
|
||||
- **Daemon deactivation** (BAT-007/REQ-022): `pfhub daemon update <daemon_id> --deactivate --force` — daemon is_active=false + 바인딩된 모든 service token 즉시 revoke.
|
||||
- **NULL daemon migration CLI** (BAT-007/REQ-023): `pfhub service-token migrate-null-daemon --dry-run` — NULL daemon_id active service token inventory 출력 + user별 candidate daemon 목록. `--dry-run` 없이 실행 시 exit code 1 + 수동 안내.
|
||||
|
||||
### Endpoint: `GET /v1/assets`
|
||||
|
||||
@@ -275,6 +281,109 @@
|
||||
- After BAT-006, all `asset_id` values in GET /v1/assets responses change from TEXT to UUID format.
|
||||
- trex-basic consumers must handle UUID-format asset_id before the coordinated deployment is completed.
|
||||
|
||||
## Interface: `v1 Portfolio API (BAT-009/010)`
|
||||
|
||||
- Type: http-api
|
||||
- Consumers / Producers: authenticated users and admin services
|
||||
- Purpose: CRUD for user-owned portfolios; tag and comment sub-resources; ownership and disabled-state enforcement
|
||||
- Stability: internal (versioned under /v1)
|
||||
|
||||
### Authentication
|
||||
|
||||
- All /v1/portfolios endpoints require `Authorization: Bearer <token>` header.
|
||||
- `token_type ∈ {service, client}` → HTTP 403 fail-closed for all write operations and GET sub-resources.
|
||||
- `permission='admin'` → access to all portfolios. `permission ∈ {read, trade-write}` → own portfolios only.
|
||||
- Comment and portfolio CRUD routes: DB token or env token; POST/PATCH/DELETE require user or admin token type.
|
||||
- Tag POST/DELETE routes additionally require `token_source="db"` (DB token).
|
||||
|
||||
### Endpoint: `POST /v1/portfolios`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403
|
||||
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_name": "<str|null>", "strategy_params": {<dict>}, "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
|
||||
- Response: HTTP 201 `PortfolioItem`
|
||||
- Error: HTTP 422 if asset not found or inactive; HTTP 403 if service/client token
|
||||
|
||||
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403; ownership required
|
||||
- Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict|null>}, "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
|
||||
- Response: HTTP 200 `PortfolioItem`
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if portfolio is disabled and non-status fields are provided
|
||||
|
||||
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}` (Soft Delete)
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403; ownership required
|
||||
- Body: none
|
||||
- Response: HTTP 200 `PortfolioItem` with `status="disabled"`, `managed_by_daemon_id=null`
|
||||
- Dependent data: portfolio_comments and tag_ids are preserved on soft delete; only status and daemon assignment change
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if not found
|
||||
|
||||
### Endpoint: `GET /v1/portfolios`
|
||||
|
||||
- Auth: any valid token; client → HTTP 403
|
||||
- Query Params: `?include_disabled=true` (default false); `?market=<code>`; `?user_id=<str>` (admin only)
|
||||
- Branching by token type:
|
||||
- `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → HTTP 403
|
||||
- `user/admin`: returns owner's own portfolios; admin may override with `?user_id=<id>`
|
||||
- Response: HTTP 200 `PortfolioListResponse {"portfolios": [...], "total": <int>}`
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags`
|
||||
|
||||
- Auth: DB or env token; service/client → HTTP 403; ownership required
|
||||
- Response: HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": <int>}`
|
||||
- Note: disabled portfolio GET is allowed
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found
|
||||
|
||||
### Endpoint: `POST /v1/portfolios/{portfolio_id}/tags`
|
||||
|
||||
- Auth: DB token required; trade-write or admin; service/client → HTTP 403; ownership required
|
||||
- Body: `{"tag_id": "<uuid>"}`
|
||||
- Response: HTTP 200 `TagApplyResponse {"tag_id": "<uuid>", "status": "applied"}`
|
||||
- Policy: tag must be is_active=true; non-admin must use own or admin tag; disabled portfolio → HTTP 409
|
||||
- Error: HTTP 403 if policy violation, service/client, or ownership mismatch; HTTP 404 if portfolio or tag not found; HTTP 409 if disabled portfolio
|
||||
|
||||
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}`
|
||||
|
||||
- Auth: DB token required; trade-write or admin; service/client → HTTP 403; ownership required
|
||||
- Response: HTTP 200 `TagApplyResponse {"tag_id": "<uuid>", "status": "removed"}`
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if disabled portfolio
|
||||
|
||||
### Endpoint: `POST /v1/portfolios/{portfolio_id}/comments`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403; ownership required
|
||||
- Body: `{"content": "<str>"}` (min_length=1)
|
||||
- Response: HTTP 201 `PortfolioCommentItem {"comment_id": "<uuid>", "portfolio_id": "<uuid>", "content": "<str>", "created_at": "<ISO8601|null>", "updated_at": "<ISO8601|null>"}`
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if disabled portfolio
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/comments`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403; ownership required
|
||||
- Response: HTTP 200 `PortfolioCommentListResponse {"comments": [PortfolioCommentItem], "total": <int>}` (created_at desc from repo)
|
||||
- Note: disabled portfolio GET is allowed
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found
|
||||
|
||||
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403; portfolio ownership required (no per-comment author check)
|
||||
- Body: `{"content": "<str>"}` (min_length=1)
|
||||
- Response: HTTP 200 `PortfolioCommentItem`
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or comment not found; HTTP 409 if disabled portfolio
|
||||
|
||||
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}`
|
||||
|
||||
- Auth: any valid token; service/client → HTTP 403; portfolio ownership required
|
||||
- Response: HTTP 204 (no content)
|
||||
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or comment not found; HTTP 409 if disabled portfolio
|
||||
|
||||
### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011)
|
||||
|
||||
- Auth: DB service token with `trade-write` permission; `daemon_id IS NULL` → HTTP 403 fail-closed
|
||||
- Request: `LedgerTradeRequest` — `order_id`, `execution_id`, `side` (buy|sell), `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`
|
||||
- Response: HTTP 201 + `LedgerTradeResponse` with `status="new"`; HTTP 200 + `status="duplicate"` for idempotent replay; HTTP 409 for conflicting payload
|
||||
- Error: HTTP 403 (no daemon_id on token, portfolio unassigned, daemon mismatch); HTTP 404 (non-existent portfolio); HTTP 409 (disabled portfolio or payload conflict)
|
||||
- Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint
|
||||
- **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404.
|
||||
|
||||
## Interface: `Tag Management API (BAT-004)`
|
||||
|
||||
- Type: http-api
|
||||
@@ -341,6 +450,43 @@
|
||||
- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "<uuid>", "status": "removed"}`
|
||||
- Error: HTTP 404 if portfolio not found; HTTP 403 if caller is not owner or admin
|
||||
|
||||
## Deprecated Endpoints
|
||||
|
||||
### `POST /auth/tokens` (Client Token Issuance) — Removed
|
||||
|
||||
- **Status**: Removed as of BAT-006/REQ-016 (2026-05-20).
|
||||
- Route `POST /auth/tokens` 및 `CreateClientTokenRequest` / `IssuedClientTokenResponse` 스키마 제거. 호출 시 HTTP 404 또는 405.
|
||||
- `token_type="client"` 발급 경로는 코드에서 완전히 제거됨. 잔존 client token row는 DB에 읽기 전용으로 남을 수 있음.
|
||||
|
||||
### `POST /auth/service-tokens` Body — daemon_id 추가 (BAT-006/REQ-018)
|
||||
|
||||
- Body `daemon_id` 필드가 서비스 토큰 발급 시 라우트 핸들러로 정상 forwarding됨. (이전: 누락으로 인해 daemon_id가 항상 NULL로 저장)
|
||||
- Body: `{"name": "<str>", "username": "<str>", "permission": "read|trade-write", "expires_in_days": <int|null>, "no_expiry": <bool>, "daemon_id": "<uuid|null>"}`
|
||||
- Response: HTTP 201 `{"token": "<raw>", "runtime_id": "<str>", "daemon_id": "<str|null>", ...}`
|
||||
|
||||
## Token Issuance Authorization Matrix
|
||||
|
||||
(REQ-020/BAT-008 — dcy-004 FR-5, NFR-4)
|
||||
|
||||
매트릭스 위반 호출 → HTTP 403 fail-closed (모든 권한 미충족 케이스).
|
||||
|
||||
| Token Type | Verb | admin | trade-write | read | 비고 |
|
||||
|------------|------|-------|-------------|------|------|
|
||||
| session | issue (POST /auth/login) | ✓ | ✓ | ✓ | 유효 credential 필요 |
|
||||
| session | revoke (DELETE /auth/tokens/{id}) | ✓ 본인 | ✓ 본인 | ✓ 본인 | 타 user token 폐기 불가 |
|
||||
| session | list (GET /auth/tokens) | ✓ 본인 | ✓ 본인 | ✓ 본인 | — |
|
||||
| service | issue (POST /auth/service-tokens) | ✓ | ✗ | ✗ | admin만 발급 가능 |
|
||||
| service | rotate (pfhub service-token rotate) | ✓ | ✗ | ✗ | CLI only |
|
||||
| service | rebind (pfhub service-token rebind) | ✓ | ✗ | ✗ | CLI only |
|
||||
| service | revoke (pfhub service-token revoke) | ✓ | ✗ | ✗ | CLI only |
|
||||
| service | list (pfhub service-token list) | ✓ | ✗ | ✗ | CLI only |
|
||||
| env | issued/managed | infra operator | ✗ | ✗ | PORTFOLIOHUB_ADMIN_TOKENS 환경 변수 직접 편집 |
|
||||
| client | — | ✗ | ✗ | ✗ | **폐지됨** (REQ-016/BAT-006) |
|
||||
|
||||
**Admin 자기 발급 정책 (dcy-004 OQ-4)**: admin은 본인 user_id 대상으로 daemon-bound 또는 unbound service token 발급 가능. 관행상 최소 권한 원칙에 따라 운영 daemon 대상 발급을 권장.
|
||||
|
||||
**코드 매핑**: `require_admin` → admin 전용 게이트. `require_read` → read 이상 허용. `AuthService.issue_service_token()` → admin 권한 필요 (라우트에서 `require_admin` 의존성). 불일치 발견 시 별도 follow-up REQ로 분리.
|
||||
|
||||
## Shared Constraints
|
||||
|
||||
- Token permission levels gate both HTTP and CLI mutations consistently.
|
||||
@@ -361,6 +507,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"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시.
|
||||
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신.
|
||||
- 2026-05-21 KST: BAT-008 (REQ-019/020) — 토큰 surface 정합성 회복 + 권한 매트릭스: service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model에 rebind/deactivate/null-daemon CLI 추가; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020).
|
||||
- 2026-05-20 KST: bat-005 — REQ-008 AC-2: added Tag Management API section (POST/GET/PATCH /v1/tags, POST/DELETE /v1/assets/{id}/tags, POST/DELETE /v1/portfolios/{id}/tags); updated CLI surface (pfhub tag, removed pfhub portfolio tag); updated CLI inputs.
|
||||
- 2026-05-19 KST: bat-002 — REQ-010/012/013/015: added admin catalog endpoints (POST/PATCH /v1/assets, PATCH /v1/assets/{id}/deactivate); rewrote GET /v1/assets authentication to support service/user/admin token branching; added market filter param; removed market field from AssetItem; added AssetCatalogResponse schema.
|
||||
- 2026-05-13 KST: bat-010 — REQ-006/007/008/011/023: added daemon authorization model section; updated GET /v1/assets (daemon-scoped filter, NULL 403); updated GET /v1/user_info (daemon_id field, NULL runtime_id → 200+null diagnostic); updated POST /v1/assets/{asset_id}/ledger/trades (daemon assignment authorization, fail-closed policy); added daemon CLI subcommand to CLI surface; updated v1 purpose and source discovery.
|
||||
|
||||
Reference in New Issue
Block a user