33 KiB
33 KiB
Portfolio Hub Runtime Flows
- Baseline Mode: observed
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- 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
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
Purpose
- This document is the current approved execution-flow baseline for the project.
- It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates.
Scope
- Covers representative command and runtime flows.
- Does not duplicate full acceptance criteria or low-level implementation details.
Covered 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, tags, service tokens, and daemon registry
- alembic upgrade head :: apply ordered database schema migrations for persistent environments
Shared Components
- app/core/config.py Settings loading and environment normalization
- app/core/auth.py permission guards and auth context resolution
- app/services/* business logic for auth, users, portfolios, and strategy sets
- app/repositories/factory.py repository selection and Postgres config construction
- app/repositories/postgres.py Postgres persistence, schema bootstrap, and query execution
Flow: HTTP API Request Handling
- Uvicorn loads app.main:app, which creates the FastAPI application from create_app().
- create_app() loads Settings, registers middleware and routers, and wires exception handlers.
- During lifespan startup, the app optionally bootstraps an initial admin user when DB mode and bootstrap credentials are configured.
- Incoming requests pass through auth and permission dependencies before services mutate state or query persistence.
- Services call the repository factory, which returns PostgresPortfolioRepository only when PORTFOLIOHUB_DB_ENABLED=true.
- Routes serialize domain results into app/schemas response models or return HTTP 204 for deletion flows.
Flow: CLI Operational Command Execution
- The pfhub entrypoint builds a Typer application and injects Settings, console output, and a repository factory into AppContext.
- Command groups parse arguments, confirmations, and output format selection before invoking the relevant service or repository methods.
- Database setup commands use the shared settings layer to target the configured PostgreSQL or TimescaleDB instance.
- Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied.
- Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions.
Flow: pfhub tag CLI Command Execution
pfhub tag create <name> [--user <username>] [--admin]- Resolves
--usertouser_idviarepo.get_user_by_username(); raises ValueError if not found. - Calls
repo.create_tag(name=name.strip(), is_admin_tag=--admin, created_by_user_id=resolved_id). - Duplicate name → repo ValueError → console error, exit 1.
- Resolves
pfhub tag list [--inactive]- Calls
repo.list_tags_master(include_inactive=--inactive). - Renders Tag table (table format) or JSON array (--format json).
- Calls
pfhub tag deactivate <tag_id> --force- Without
--force: prompts for confirmation; abort on refusal (exit 0). - Calls
repo.get_tag_by_id(tag_id); if not found → ValueError → exit 1. - If already inactive → prints "already inactive", returns (idempotent, exit 0).
- Calls
repo.deactivate_tag(tag_id).
- Without
pfhub tag apply asset <asset_id> <tag_id>- Validates tag: get_tag_by_id → not found → ValueError; not is_admin_tag → ValueError; not is_active → ValueError.
- Calls
repo.append_portfolio_tag_id(asset_id, tag_id).
pfhub tag apply portfolio <portfolio_id> <tag_id>- Validates tag: get_tag_by_id → not found → ValueError; not is_active → ValueError.
- Resolves portfolio: repo.get(portfolio_id) → not found → ValueError.
- Policy check: tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id; else → ValueError.
- Calls
repo.append_portfolio_tag_id(portfolio_id, tag_id).
pfhub tag remove asset <asset_id> <tag_id> --force/pfhub tag remove portfolio <portfolio_id> <tag_id> --force- Without
--force: prompts for confirmation; abort on refusal (exit 0). - Calls
repo.remove_portfolio_tag_id(...).
- Without
pfhub portfolio tag add/list/remove— removed in BAT-005; Typer returns exit code 2 (no such command).
Flow: CLI Portfolio Command — Name+User Resolution
- All portfolio mutation commands (
status,create,update,buy,sell,invest,transfer,reset,delete) accept a<name>positional argument and--user <username>option. _resolve_portfolio_id(repository, name, user)is called before any mutation: a. Callsget_user_by_username(user)→ raises ValueError if user not found. b. Callsget_portfolio_by_name_and_user(owner_user_id, name)→ raises ValueError if portfolio not found. c. Returnsportfolio.portfolio_id(UUID) for downstream use.createcommand takes<name>as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response.update --new-name <new_name>renames the portfolio;--forceis required to confirm the rename.commentandtagsub-commands continue to accept<portfolio_id>(UUID) directly; they are outside the name+user resolution flow.
Flow Constraints
- Persistence-dependent HTTP and CLI flows require PORTFOLIOHUB_DB_ENABLED=true plus valid TimescaleDB connection settings.
- Permission levels read, trade-write, and admin must map consistently across API guards, issued tokens, and CLI mutations.
- Request validation and auth resolution should happen before persistence mutation or response rendering.
- The health endpoint remains unauthenticated so infrastructure can probe readiness without a token.
GET /healthresponse includesdb_enabled: bool(REQ-035, dcy-005). 본 필드는 환경변수PORTFOLIOHUB_DB_ENABLED설정값을 그대로 노출하는 것이며 actual DB connectivity ping이 아니다. production-grade readiness probe (SELECT 1 ping 등)는 본 endpoint 범위 밖이며 별도/readinessendpoint 분리는 후속 Discovery에서 다룬다.
Flow: v1 API — Tag Management (POST/GET/PATCH /v1/tags)
- POST /v1/tags (create tag):
- DB token required; trade-write or admin permission.
- Env token → HTTP 403.
nameis stripped and lowercased;is_admin_tagset if caller's permission is admin.repo.create_tag(name, is_admin_tag, created_by_user_id=auth.user_id)→ UniqueViolation → HTTP 409.- Returns HTTP 201
TagItem.
- GET /v1/tags (list tags):
- DB token required; read permission.
?include_inactive=true→repo.list_tags_master(include_inactive=True); default returns active only.- Returns HTTP 200
TagListResponse {"tags": [...], "total": N}.
- PATCH /v1/tags/{tag_id}/deactivate:
- DB token required; read permission.
repo.get_tag_by_id(tag_id)→ None → HTTP 404.- Caller must be admin or tag creator (
auth.user_id == tag.created_by_user_id); else → HTTP 403. - If already inactive → return HTTP 200 immediately (idempotent).
repo.deactivate_tag(tag_id)→ HTTP 200TagItem.
Flow: v1 API — Portfolio Mutation (POST/PATCH/DELETE /v1/portfolios)
- POST /v1/portfolios (create):
- Any valid token;
_require_write_tokenblocks service/client (token_type ∈ {service, client}→ HTTP 403). - admin with
body.user_id→ creates portfolio for target user; otherwise usesauth.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(...)withstatus="enabled"→ HTTP 201PortfolioItem.
- Any valid token;
- PATCH /v1/portfolios/{portfolio_id} (update):
_require_write_tokenblocks 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 200PortfolioItem.
- DELETE /v1/portfolios/{portfolio_id} (soft delete):
_require_write_tokenblocks service/client → HTTP 403._get_portfolio_or_404→ ownership check.repo.soft_delete_portfolio(portfolio_id)→ setsstatus="disabled",managed_by_daemon_id=NULL.- Dependent data (portfolio_comments, tag_ids) preserved. Portfolio still queryable with
?include_disabled=true. - Returns HTTP 200
PortfolioItemwithstatus="disabled".
Flow: v1 API — GET /v1/portfolios
- client token → HTTP 403. Any other valid token proceeds.
servicetoken:auth.daemon_id is None→ HTTP 403.repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...).user/admintoken: admin with?user_id=<id>→ filter by that user; else filter byauth.user_id.?include_disabled=false(default) →status="enabled"filter applied.?include_disabled=true→ no status filter.- Returns HTTP 200
PortfolioListResponse.
Flow: v1 API — Soft Delete and Dependent Data Preservation
DELETE /v1/portfolios/{portfolio_id}setsportfolios.status="disabled"andportfolios.managed_by_daemon_id=NULL.portfolio_commentsrows keyed byportfolio_idare NOT deleted; they remain retrievable viaGET /v1/portfolios/{id}/commentseven after soft delete.portfolios.tag_ids UUID[]is NOT cleared; existing tag references are preserved.- Disabled portfolios are excluded from
GET /v1/portfoliosresponses by default (?include_disabled=false). - 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.
- 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)
- POST /v1/assets/{asset_id}/tags (apply tag to asset — admin only):
- DB token required; admin permission (
require_admin). repo.get(asset_id)→ ValueError → HTTP 404.repo.get_tag_by_id(tag_id)→ None → HTTP 404.not tag.is_admin_tag→ HTTP 403;not tag.is_active→ HTTP 403.repo.append_asset_tag_id(asset_id, tag_id)→ HTTP 200{"tag_id": ..., "status": "applied"}(BAT-013에서 portfolios → assets 테이블 대상으로 정합화).
- DB token required; admin permission (
- DELETE /v1/assets/{asset_id}/tags/{tag_id} (remove tag from asset — admin only):
- DB token required; admin permission.
repo.get(asset_id)→ ValueError → HTTP 404.repo.remove_asset_tag_id(asset_id, tag_id)→ HTTP 200{"tag_id": ..., "status": "removed"}(BAT-013에서 portfolios → assets 테이블 대상으로 정합화).
- POST /v1/portfolios/{portfolio_id}/tags (apply tag to portfolio — owner or admin):
- 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"}.
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (
- DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} (remove tag from portfolio — owner or admin):
- 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"}.
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (
- 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)
- POST /v1/portfolios/{portfolio_id}/comments (create):
require_read;_require_write_tokenblocks service/client → HTTP 403._get_portfolio_or_404→ ownership check;portfolio.status == "disabled"→ HTTP 409.repo.create_comment(portfolio_id, content=body.content)→ HTTP 201PortfolioCommentItem.
- 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 increated_at descorder.- Returns HTTP 200
PortfolioCommentListResponse {"comments": [...], "total": <int>}.
- PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id} (update):
require_read;_require_write_tokenblocks 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.
- DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id} (delete):
require_read;_require_write_tokenblocks 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
- v1 endpoint receives Bearer token from Authorization header.
require_read/require_adminresolves the token: checks env tokens first, then DB lookup viaget_token_principal().- Env tokens → HTTP 403 (all /v1 endpoints require DB tokens).
AuthContextis populated withuser_id,token_type,permission,runtime_id, anddaemon_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 (admin asset catalog)
- Admin token required (
require_admin). Non-admin (user/service/client) callers receive HTTP 403. - Query params:
?status=<active|inactive|all>(defaultactive),?market=<code>(optional).statusoutside the allow-list → HTTP 422. repo.list_assets()→ in-memory filter bystatus(skipped whenall) and bymarket(<code> in asset.markets).- Tag archived 합성: 결과 자산들의
tag_idsunion을 모아repo.get_tags_by_ids(...)로 1회 조회.tags.is_active=False또는 마스터에 없는 tag_id는archived=True로 응답. 빈 배열은 그대로 빈 배열. - Returns
AssetCatalogListResponsewithitems[]andtotal. Each item:asset_id, symbol, markets, display_name, status, tag_ids: [{tag_id, archived}], created_at, updated_at. Portfolio 필드(cash,quantity,owner_user_id등)는 포함하지 않는다 — 포지션 조회는GET /v1/portfolios(REQ-028).
Flow: Admin Asset Catalog CRUD — POST/PATCH /v1/assets
- Admin token required (
require_admin). POST /v1/assets:repo.insert_asset(symbol, markets, display_name, status)→ 201AssetCatalogResponse.PATCH /v1/assets/{asset_id}:repo.update_asset(asset_id, markets, display_name, status)→ 200AssetCatalogResponse.PATCH /v1/assets/{asset_id}/deactivate:repo.update_asset(asset_id, status="inactive")→ 200AssetCatalogResponse.- Non-admin callers receive HTTP 403.
Flow: v1 API — GET /v1/user_info
- Service-token auth required. Available to all service tokens including unbound (daemon_id=NULL); no daemon-id guard.
get_user()fetches user record byuser_id.list_broker_credentials()fetches all broker credential rows for the user.- For each credential, Fernet-decrypts
encrypted_payloadusingCREDENTIAL_ENCRYPTION_KEY; decryption failures log a warning and returncredential_payload: null. - Returns
UserInfoResponsewithtrading_enabled(fromis_active),disabled_reason,brokers[],runtime_id(null if unbound), anddaemon_id(null for legacy/unbound tokens — diagnostic, not an error).
Flow: v1 API — POST /v1/portfolios/{portfolio_id}/ledger/trades (REQ-029, BAT-011)
- Service-token auth required with
trade-writepermission (_require_service_tokendependency). token_source != "db"ortoken_type != "service"→ HTTP 403.auth.daemon_id is None→ HTTP 403 fail-closed ("daemon_id not assigned to this token").repo.get(portfolio_id)→ exception → HTTP 404.portfolio.status == "disabled"→ HTTP 409 (disabled write 차단, REQ-027 정합).portfolio.managed_by_daemon_id is None→ HTTP 403 ("Asset is not assigned to any daemon").portfolio.managed_by_daemon_id != auth.daemon_id→ HTTP 403 ("Token daemon does not match asset daemon assignment").insert_ledger_trade(asset_id=portfolio_id, ...)with idempotency key(portfolio_id, order_id, execution_id).- New record → HTTP 201 +
status="new". - Duplicate key + identical payload → HTTP 200 +
status="duplicate". - 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
- Admin token auth required (
require_admindependency). - Validates request body:
name,username,permission,expires_in_days/no_expiry,daemon_id(optional; BAT-006/REQ-018 forward 보정). - If
daemon_idprovided:get_daemon_by_id()→ 존재하지 않거나 inactive → HTTP 422. daemon.owner_user_id ≠ 발급 대상 user → HTTP 422. - Calls
AuthService.issue_service_token()which creates a new DB token withruntime_idanddaemon_id. - Returns HTTP 201 with
token(raw value),runtime_id,daemon_id, andauth_tokenmetadata.
Flow: Daemon Registry — Create and Bind Service Token
- Operator creates a daemon via
pfhub daemon create <daemon_slug> --user <username>(optionally--display-name <name>).AuthServiceis not involved; the repository creates theDaemonrecord directly withowner_user_idresolved fromusername.- Returns
daemon.id(server-generated UUID),daemon_slug,is_active=True.
- Operator issues a service token bound to the daemon via
pfhub service-token issue <name> --user <username> --daemon-id <daemon_id>.AuthService.issue_service_token()validates: daemon exists, is_active=True, andowner_user_idmatches the token's user.- Unknown or inactive daemon → ValueError ("존재하지 않거나 비활성").
create_auth_token()persists the token withdaemon_idset.
- Operator assigns an asset to the daemon via portfolio update (
managed_by_daemon_idcolumn set). GET /v1/assetswith the bound token returns only assets whereportfolios.managed_by_daemon_id = token.daemon_id.POST /v1/portfolios/{portfolio_id}/ledger/tradeswith the bound token passes daemon assignment authorization and records the trade (신 path, REQ-029).
Flow: pfhub service-token rebind (BAT-007/REQ-023)
- Operator calls
pfhub service-token rebind <name> --daemon-id <daemon_id>. 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 불변.- Returns
(previous_daemon_id, rebound_token).
Flow: pfhub daemon update --deactivate (BAT-007/REQ-022)
- Operator calls
pfhub daemon update <daemon_id> --deactivate [--force]. --force없으면 확인 프롬프트; 거절 시 exit 0.repo.update_daemon(daemon_id, is_active=False)— daemon is_active=false 설정.repo.revoke_tokens_by_daemon_id(daemon_id)— 바인딩된 모든 active service token에revoked_at=NOW()설정.- Console에 revoked count 출력.
Flow: Service-Token Rotate — daemon_id Invariant
- Operator calls
pfhub service-token rotate <name> --force. AuthService.rotate_service_token(name)delegates torepository.rotate_service_token(name).- Repository looks up the existing token by name; raises ValueError if not found.
- Old token is revoked (
revoked_at = NOW()). - New token row is created with:
- New raw token value (generate_token()), new
token_hash, newtoken_prefix. - New
runtime_id(uuid4) — per-token, changes on every rotate. - Same
daemon_idas the old token — stable across rotates (daemon_id invariant). - Same
user_id,name,permission,token_type,expires_at.
- New raw token value (generate_token()), new
- Returns raw token (shown once) and new
auth_tokenmetadata including newruntime_id. GET /v1/user_infocalled with the new token returns the samedaemon_idbut a newruntime_id.
Flow: Application Startup Validation
lifespanstartup hook runs_bootstrap_initial_user()if DB enabled and bootstrap credentials configured.- If
db_enabled=True, callssettings.require_credential_encryption_key(). - If
CREDENTIAL_ENCRYPTION_KEYis not configured, raisesRuntimeErrorand aborts startup.
Flow: Local DB Initialization and Validation
Scenario A — Fresh DB (DB 미존재 상태)
docker compose -f docker-compose.local-test.yml up -d— TimescaleDB 컨테이너 시작.- 컨테이너 준비 확인:
docker compose -f docker-compose.local-test.yml ps→portfolio-hub-timescaledb상태가healthy인지 확인. pfhub_DB_ENABLED=true pfhub init-db— 대상 DB 생성 + Alembicupgrade head실행.- 기대 출력:
database: portfolio_hub,created: True,schema_initialized: True,migration_applied: head - pass 기준: exit code 0, 위 필드 모두 출력
- fail 기준: connection refused, permission denied, Alembic revision 오류
- 기대 출력:
pfhub_DB_ENABLED=true pfhub health-check— DB ping 성공 확인.- 기대 출력:
db_enabled: True,repository: PostgresPortfolioRepository - pass 기준: exit code 0,
db_enabled: True
- 기대 출력:
- Smoke flow: 사용자 생성 → portfolio 생성 →
portfolio list --user <username>확인.- pass 기준: 생성된 portfolio가 list 출력에 포함됨
Scenario B — runtime-precreated schema (ensure_schema() 선행)
- DB 컨테이너 시작 (Scenario A 1~2 동일).
pfhub_DB_ENABLED=true uvicorn app.main:app— App 기동.ensure_schema()가 핵심 테이블 생성.pfhub_DB_ENABLED=true pfhub migrate-db—bootstrap_or_upgrade_to_head()실행.- 내부 동작:
alembic_version테이블 없음 → 기존 핵심 테이블(portfolios 등) 존재 → legacy stamp → head로 stamp + 미보유 객체(broker_credentials, ledger_trades, runtime_id 등) upgrade - 기대 출력:
migration_applied: head - pass 기준: exit code 0, 이후
pfhub health-checkexit code 0
- 내부 동작:
pfhub_DB_ENABLED=true pfhub health-check재확인.
Repeatable Run Criteria
- 모든 시나리오는 컨테이너 볼륨 초기화(
docker compose down -v) 후 재실행 가능. - 출력 형식:
pfhub --output json <command>로 JSON 출력 수집 시 필드명이 고정됨. - 로그 수집 위치:
docker compose logs portfolio-hub-timescaledb— DB 측 오류 시 참조. - Evidence 형식: exit code + 주요 필드 값 스크린샷 또는 텍스트 캡처 → batch verification.md에 기록.
Flow: Alembic vs ensure_schema() Schema Ownership Boundary
ensure_schema() (postgres.py)와 Alembic revision 양쪽이 다루는 객체가 존재한다. 충돌은 bootstrap_or_upgrade_to_head()의 legacy stamp 경로 + 각 revision의 IF NOT EXISTS guard로 처리된다.
경계 규칙
- ensure_schema() 선행 가능 객체: users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 관련 인덱스. 모두 IF NOT EXISTS로 보호됨. BAT-010:
auth_tokens.daemon_id,portfolios.managed_by_daemon_id,daemons테이블도 ensure_schema()에 ADD/CREATE IF NOT EXISTS guard로 추가됨. - Alembic만 소유 (ensure_schema()에 없음):
portfolios.market,auth_tokens.runtime_id,idx_auth_tokens_runtime_id_unique,broker_credentials,ledger_trades. ensure_schema()만 실행한 환경에서 v1 API 사용 불가. - BAT-010 migration 20260513_0016:
daemons테이블,auth_tokens.daemon_id,portfolios.managed_by_daemon_id추가. down_revision=20260511_0015. ensure_schema() 선행 환경에서도 migration 적용 시 IF NOT EXISTS guard로 safe. - 허용 실행 순서:
init-db선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시migrate-db후속 실행. - 금지:
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.pyAuthService.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단계)
- 강한 무작위 token 생성:
python3 -c "import secrets; print(secrets.token_urlsafe(48))" PORTFOLIOHUB_ADMIN_TOKENS에 신규 token을 콤마 분리로 추가 (다중 token 동시 운영):PORTFOLIOHUB_ADMIN_TOKENS=<구_token>,<신규_token>- service rolling restart (구 token 계속 유효).
- 신규 token으로 admin 호출 검증 (예:
GET /health, admin 작업 sanity check). - 구 token을
PORTFOLIOHUB_ADMIN_TOKENS에서 제거:PORTFOLIOHUB_ADMIN_TOKENS=<신규_token> - service rolling restart (구 token 무효화).
- 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.
- Redis and Loki are configured as supporting infrastructure but are not yet represented as explicit runtime flows.
- Broker credential write path (upsert) is available only via CLI; no HTTP API endpoint for credential registration exists yet.
Change Log
- 2026-05-21 KST: BAT-013 (REQ-005 CHG-20260521-02) — /v1/assets/{id}/tags apply/remove SQL 대상 정합화:
repo.append_portfolio_tag_id/repo.remove_portfolio_tag_id호출을repo.append_asset_tag_id/repo.remove_asset_tag_id로 교체. portfolios 테이블 mutation 호출은 portfolio 라우트(/v1/portfolios/{id}/tags) 전용으로 한정. HTTP contract 무변, BAT-012 신설assets.tag_ids컬럼이 비로소 정상 채워짐. - 2026-05-21 KST: BAT-012 (REQ-002 / REQ-010 AC-9~12) — GET /v1/assets 흐름 재정의: 기존 token_type 분기 portfolio 조회 설명을 admin asset catalog 조회로 교체; tag_ids 배열 + archived 합성 흐름 신설(tags 마스터 is_active 조회로 비활성·미존재 tag_id를
archived=True로 응답). - 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.
- 2026-05-11 KST: bat-007 — Added "Local DB Initialization and Validation" flow (Scenario A/B, repeatable run criteria); added "Alembic vs ensure_schema() Schema Ownership Boundary" flow.
- 2026-05-11 KST: bat-006 — Added CLI portfolio name+user resolution flow; updated GET /v1/assets flow to note asset_id is UUID after BAT-006 migration.
- 2026-05-01 KST: bat-001 — Added v1 API flows (service-token auth, GET /v1/assets, GET /v1/user_info, POST ledger/trades, POST /auth/service-tokens), startup validation flow.
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.