# Portfolio Hub Portfolio Hub is a FastAPI service that extracts portfolio management from `jsbot-kis` and exposes it through an authenticated HTTP API. ## Current status This repository is in bootstrap phase. - FastAPI application entrypoint is available. - Token-based authentication and role checks are implemented. - Portfolio and user management API contracts are implemented. - Core portfolio domain services are ported from `jsbot-kis`. - Postgres-backed portfolio and user persistence is implemented and gated behind `pfhub_DB_ENABLED=true`. ## Configuration Copy `.env.example` to `.env` and edit values as needed: ```bash cp .env.example .env ``` The application loads configuration in this order (higher priority first): 1. Environment variables 2. Variables from `.env` file 3. Default values in code When deploying to production, set environment variables directly instead of using `.env`. ### Key configuration options - **DB repository switch**: `pfhub_DB_ENABLED` (enables DB-backed persistence) - **Initial user bootstrap**: `pfhub_BOOTSTRAP_USERNAME`, `pfhub_BOOTSTRAP_EMAIL` - **Initial user password**: `pfhub_BOOTSTRAP_PASSWORD` - **Session/token TTL**: `pfhub_SESSION_TOKEN_TTL_HOURS`, `pfhub_CLIENT_TOKEN_DEFAULT_TTL_DAYS`, `pfhub_SERVICE_TOKEN_DEFAULT_TTL_DAYS` - **TimescaleDB / PostgreSQL**: `TIMESCALEDB_*` (single source of DB connection settings) - **Redis**: `REDIS_*` (for caching and sessions) - **Loki**: `LOKI_*` (for centralized logging) - **Authentication**: `pfhub_*_TOKENS` (bearer tokens with role-based access) For complete configuration options, see `.env.example`. ## Run locally ```bash python -m venv .venv source .venv/bin/activate python -m pip install -e '.[dev]' # Configure .env cp .env.example .env # Start server uvicorn app.main:app --reload # In another terminal, run tests pytest ``` If you need local infrastructure for TimescaleDB, Redis, and Loki, start them with Docker Compose before running the app: ```bash docker compose -f docker-compose.local-test.yml up -d docker compose -f docker-compose.local-test.yml ps ``` Default endpoints exposed by `docker-compose.local-test.yml`: - TimescaleDB: `127.0.0.1:5432` - Redis: `127.0.0.1:6379` (RedisInsight UI: `127.0.0.1:8001`) - Loki: `127.0.0.1:3100` Typical `.env` values for this local setup: ```env pfhub_DB_ENABLED=true TIMESCALEDB_HOST=127.0.0.1 TIMESCALEDB_PORT=5432 TIMESCALEDB_USER=portfolio_hub_user TIMESCALEDB_PASSWORD=change-me TIMESCALEDB_NAME=portfolio_hub REDIS_HOST=127.0.0.1 REDIS_PORT=6379 LOKI_HOST=127.0.0.1 LOKI_PORT=3100 ``` To stop and remove the local containers: ```bash docker compose -f docker-compose.local-test.yml down ``` ## CLI The repository now includes a `pfhub` CLI that mirrors the main portfolio flows from `jsbot-kis`. The same commands are also available through the shorter alias `phub`. ```bash pfhub --help pfhub user list pfhub user create --username alice --password password123 --email alice@example.com --force pfhub user get pfhub user update --username alice-updated --force pfhub user delete --force pfhub service-token list --user alice pfhub service-token issue nightly --user alice --ttl-days 30 pfhub service-token issue permanent --user alice --no-expiry pfhub service-token revoke nightly --force pfhub service-token rotate permanent --force pfhub portfolio list --user pfhub portfolio status 005930:tv --recent 10 pfhub portfolio create 005930:tv --symbol 005930 --user --cash-balance 10000000 --force pfhub portfolio buy 005930:tv --quantity 10 --price 71000 --fee 5000 --force pfhub portfolio sell 005930:tv --quantity 5 --price 72000 --tax 3000 --force pfhub portfolio invest 005930:tv --type deposit --amount 50000 --force pfhub portfolio transfer 005930:tv --direction to-buffer --amount 100000 --force pfhub portfolio reset 005930:tv --force pfhub portfolio delete 005930:tv --keep-ledger --force ``` Supported commands: - `init-db` - `migrate-db` - `user list` - `user get` - `user create` - `user update` - `user delete` - `service-token list` - `service-token issue` - `service-token revoke` - `service-token rotate` - `portfolio list` - `portfolio status` - `portfolio create` - `portfolio update` - `portfolio buy` - `portfolio sell` - `portfolio invest` - `portfolio transfer` - `portfolio reset` - `portfolio delete` - `health-check` Write commands ask for confirmation by default. Use `--force` to skip the prompt for scripts and automation. Service token issue policy: - 기본값은 365일 TTL이다. - `--ttl-days`와 `--no-expiry`는 동시에 사용할 수 없다. - `--no-expiry`는 관리자 권한에서만 허용된다. - 토큰 원문은 발급 응답 1회 이후 마스킹된 값으로만 출력/기록된다. The CLI uses the same repository and settings layer as the HTTP API. To target the external TimescaleDB, set `pfhub_DB_ENABLED=true` and provide `TIMESCALEDB_*` values in the environment or `.env`. Initialize a new database explicitly before the first use: ```bash pfhub_DB_ENABLED=true pfhub init-db pfhub_DB_ENABLED=true pfhub health-check pfhub_DB_ENABLED=true pfhub portfolio list ``` `init-db` will: - connect to the maintenance database, defaulting to `postgres` - create the target database if it does not exist - run Alembic `upgrade head` `migrate-db` will: - connect to the target database - run Alembic `upgrade head` - apply any future schema revisions in order This repository now uses Alembic for versioned schema migrations. Future schema changes should be added under `migrations/versions`. ## Local DB Validation Runbook 이 섹션은 로컬 docker compose 환경에서 DB 초기화, 마이그레이션, health-check, smoke flow를 반복 검증하기 위한 절차를 정의한다. ### 준비 — 컨테이너 시작 ```bash docker compose -f docker-compose.local-test.yml up -d docker compose -f docker-compose.local-test.yml ps # portfolio-hub-timescaledb 상태가 (healthy) 인지 확인 ``` 기본 접속 정보 (`.env` 설정 기준): - TimescaleDB: `127.0.0.1:5432` - DB: `portfolio_hub`, User: `portfolio_hub_user` ### Scenario A — Fresh DB (DB 미존재 상태) ```bash # 1. DB 생성 + Alembic upgrade head pfhub_DB_ENABLED=true pfhub init-db # 기대: database: portfolio_hub / created: True / schema_initialized: True / migration_applied: head # 2. DB 연결 health 확인 pfhub_DB_ENABLED=true pfhub health-check # 기대: db_enabled: True / repository: PostgresPortfolioRepository # 3. Smoke flow — 사용자 생성 + portfolio 생성 + 목록 확인 pfhub_DB_ENABLED=true pfhub user create --username alice --password password123 --force pfhub_DB_ENABLED=true pfhub portfolio create my-portfolio --symbol 005930 --user alice --cash-balance 1000000 --force pfhub_DB_ENABLED=true pfhub portfolio list --user alice # 기대: my-portfolio가 목록에 포함 ``` **pass 기준**: 각 명령 exit code 0 / 기대 필드 출력 확인 **fail 기준**: connection refused / Alembic revision 오류 / exit code ≠ 0 ### Scenario B — runtime-precreated schema (App 선행 기동) ```bash # 1. App 먼저 기동 (ensure_schema() 실행) pfhub_DB_ENABLED=true uvicorn app.main:app & # 2. migrate-db로 Alembic 관리 상태로 전환 pfhub_DB_ENABLED=true pfhub migrate-db # 기대: migration_applied: head # 3. health-check 재확인 pfhub_DB_ENABLED=true pfhub health-check ``` **동작 원리**: `bootstrap_or_upgrade_to_head()`가 `alembic_version` 테이블 없음 → 기존 핵심 테이블 존재 → legacy stamp → head upgrade 순서로 실행한다. ### 반복 실행 (재현성 확인) ```bash # 컨테이너 + 볼륨 초기화 docker compose -f docker-compose.local-test.yml down -v # 위 Scenario A 또는 B 재실행 ``` 출력이 이전 실행과 동일한지 비교한다. JSON 출력 수집: ```bash pfhub_DB_ENABLED=true pfhub init-db --output json pfhub_DB_ENABLED=true pfhub health-check --output json ``` 로그 수집 위치: `docker compose -f docker-compose.local-test.yml logs portfolio-hub-timescaledb` ### Rollback / 재시도 판단 기준 | 증상 | 판단 | 조치 | |------|------|------| | `init-db`: `connection refused` | DB 컨테이너 미준비 | `docker compose ps` → `(healthy)` 대기 후 재시도 | | `migrate-db`: `Alembic revision 오류` | revision 파일 손상 또는 순서 오류 | `migrations/versions/` revision 확인 | | `migrate-db` 후 v1 API 오류 | ensure_schema() 선행 후 Alembic 미실행 | `migrate-db` 재실행 | | 데이터 오염으로 스키마 불일치 | 심각한 데이터 오류 | 볼륨 초기화(`down -v`) + Scenario A 재시작 | ### Release Gate Criteria 다음 조건을 모두 만족해야 로컬 검증 완료로 간주한다: 1. Scenario A: `init-db` exit code 0 + `health-check` exit code 0 + smoke flow 정상 완료 2. `migrate-db` 반복 실행 시 오류 없음 (멱등성 확인) 3. `docker compose logs`에 ERROR 레벨 로그 없음 ## Docs Sync Guardrail To keep implementation and specification documents in sync, this repository provides an automated check. Rules enforced: - If files under `app/` change, at least one `docs/technical/` document must be updated in the same commit/PR. - If files under `app/api/` or `app/schemas/` change, `docs/technical/api-contracts.md` must be updated in the same commit/PR. - If files under `migrations/versions/` change, `docs/technical/persistence-and-migrations.md` must be updated in the same commit/PR. - Changed migration revision IDs must appear in `docs/technical/persistence-and-migrations.md`. Enable local pre-commit check: ```bash pip install -e .[dev] pre-commit install ``` Run manually: ```bash python3 scripts/check_docs_sync.py --staged ``` CI check: - GitHub Actions workflow: `.github/workflows/docs-sync-check.yml` - Runs automatically on pull requests. ## Authentication model pfhub currently supports bearer tokens with three permission levels. - `read`: list and status endpoints - `trade-write`: mutation endpoints such as buy, sell, invest, transfer, reset - `admin`: delete, user management, and future operational actions ## User management User management is exposed through both HTTP API and CLI. - API base route: `/users` - Permission: `admin` only - Supported operations: list, get, create, update, delete Frontend and app authentication is exposed through `/auth`. - `POST /auth/login`: username/password login, returns bearer session token - `GET /auth/tokens`: list current user's issued tokens - `POST /auth/tokens`: issue a client token for app/script usage - `DELETE /auth/tokens/{token_id}`: revoke a previously issued token Service tokens (long-lived integration tokens) are managed via CLI: ```bash pfhub service-token issue --user [--permission read|trade-write] [--ttl-days N | --no-expiry] pfhub service-token revoke [--force] ``` Example API calls: ```bash curl -X GET "http://localhost:8000/users" \ -H "Authorization: Bearer " curl -X POST "http://localhost:8000/users" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"username": "alice", "email": "alice@example.com"}' ``` The default `.env.example` includes example tokens for development. When `pfhub_DB_ENABLED=true` and both `pfhub_BOOTSTRAP_USERNAME` and `pfhub_BOOTSTRAP_PASSWORD` are set, the app creates that admin user automatically on startup only if the `users` table is empty. This is intended for first-run bootstrap, not recurring synchronization. ## Production Operator Runbook 이 섹션은 실배포 환경에서 admin 접근, release smoke credential 관리, provenance 확인, 로그 증적 수집을 반복 가능한 절차로 정리한다. ### 1. Canonical admin access path - steady-state 운영 경로는 운영자 개인 소유 admin 사용자 계정으로 로그인해 얻은 session token 또는 그 컨텍스트에서 발급한 client token이다. - `POST /auth/login`으로 admin auth context를 확보한 뒤, 같은 컨텍스트로 `POST /auth/service-tokens`를 호출해 smoke용 service-token을 발급한다. - bootstrap 환경 변수 기반 admin 생성은 `users` 테이블이 비어 있는 신규 환경 또는 disaster recovery 직후 admin 사용자 부재가 확인된 경우에만 허용한다. - 환경 변수 기반 admin token은 `/opt/portfoliohub/.env`에서만 꺼내는 break-glass fallback으로만 사용하고, 정기 release smoke의 표준 경로로 사용하지 않는다. - 현재 프로덕션 secret material location은 `portfoliohub` systemd service의 `EnvironmentFile=/opt/portfoliohub/.env` 이다. 로컬 워크스페이스 파일이나 release 문서는 canonical storage가 아니다. ### 2. Smoke credential ownership policy 역할 책임: - **deployment secret custodian** (repository-maintainers 역할 내): secret material 보관 위치(`/opt/portfoliohub/.env`) 및 접근 통제 관리 책임자. - **production operator / release approver**: 실배포 smoke 수행 및 release 승인 책임자. read/trade-write smoke service-token 공동 소유. 정책: - admin 로그인 자격증명은 named operator가 개인별로 소유하고 공유하지 않는다. - read/trade-write smoke service-token은 개인 소유가 아니라 환경 단위 운영 credential로 취급하고, release approver 또는 repository-maintainers 역할이 공동 소유한다. - raw password, raw session token, raw client token, raw service-token은 현재 프로덕션 기준 `/opt/portfoliohub/.env`와 동일 접근 통제 하의 운영 저장소에만 저장한다. 관리 주체: deployment secret custodian. - release 문서와 운영 메모에는 token name, token_prefix, permission, runtime_id, issued_at, revoked_at만 남기고 raw secret은 남기지 않는다. - 토큰은 release smoke 전 발급 또는 rotate하고, 노출 의심, 담당자 변경, 장애 대응 후에는 revoke 또는 rotate한다. ### 3. Release credential matrix | Credential | 용도 | 발급 주체 | 보관 위치 | 소유 형태 | 폐기 조건 | |---|---|---|---|---|---| | public/no-auth | `GET /health`, `/docs`, `/openapi.json` | 없음 | 없음 | 해당 없음 | 해당 없음 | | read service-token | `GET /v1/assets`, `GET /v1/user_info` | admin context로 `POST /auth/service-tokens` | `/opt/portfoliohub/.env` 관리 경로 | 환경 단위 공동 소유 (release approver / repository-maintainers) | 노출 의심, 담당자 변경, 장애 후 revoke/rotate | | trade-write service-token | `POST /v1/assets/{asset_id}/ledger/trades` | admin context로 `POST /auth/service-tokens` | `/opt/portfoliohub/.env` 관리 경로 | 환경 단위 공동 소유 (release approver / repository-maintainers) | 노출 의심, 담당자 변경, 장애 후 revoke/rotate | | admin auth context | `POST /auth/service-tokens` (token 발급) | `/auth/login` (steady-state) 또는 break-glass env token | 개인 소유 (공유 금지) | named operator 개인 소유 | 담당자 퇴임, 노출 의심 | ### 4. Recommended production smoke flow 1. 운영자 개인 admin 계정으로 로그인해 admin session token을 확보한다. 2. 필요하면 동일 컨텍스트로 client token을 발급해 짧은 작업 단위 credential로 전환한다. 3. admin auth context로 `POST /auth/service-tokens`를 호출해 read smoke token과 trade-write smoke token을 분리 발급한다. 4. raw token은 즉시 `/opt/portfoliohub/.env` 기준 운영 저장소에 저장하고, release evidence에는 token name, token_prefix, permission, runtime_id만 기록한다. 5. public/no-auth, read service-token, trade-write service-token, admin auth context를 분리해 smoke를 수행한다. 예시: ```bash curl -X POST "/auth/login" \ -H "Content-Type: application/json" \ -d '{"username":"","password":""}' curl -X POST "/auth/service-tokens" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name":"prod-smoke-read","username":"","permission":"read"}' curl -X POST "/auth/service-tokens" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name":"prod-smoke-trade-write","username":"","permission":"trade-write"}' ``` ### 5. Provenance check procedure - `pfhub service-token list --user `로 token name, token_prefix, token_id, user_id, permission, created_at, last_used_at, revoked_at를 확인한다. - `pfhub service-token rotate --force`로 무기한 token을 rotate하고 새 runtime_id를 확보한다. - `pfhub user get `로 사용자에 연결된 service token 이름 목록을 교차 확인한다. - `GET /v1/user_info`를 live service-token으로 호출해 runtime_id와 permission을 확인하고, 발급 또는 rotate 시 기록한 runtime_id와 대조한다. ### 6. Log and evidence collection **Canonical smoke environment**: production을 canonical acceptance 환경으로 사용한다. staging smoke는 optional preflight로만 사용하며 release acceptance 기준이 되지 않는다. 절차의 source of truth는 이 README이며, 실행 evidence의 canonical record는 해당 release 문서에 남긴다. **로그 접근 경로:** - 현재 production log stack은 Linux/Ubuntu host에서 systemd로 구동되는 `portfoliohub` uvicorn 서비스와 Caddy reverse proxy다. - startup 및 애플리케이션 로그는 `sudo journalctl -u portfoliohub -n 200 --no-pager` 또는 `sudo journalctl -u portfoliohub -f`로 확인한다. - reverse proxy 또는 access 수준 로그는 `sudo journalctl -u caddy -n 200 --no-pager` 또는 `sudo journalctl -u caddy -f`로 확인한다. - Loki는 `LOKI_ENABLED`와 실제 수집 파이프라인이 확인된 환경에서만 보조 집계 경로로 취급하며, 현재 canonical production path는 아니다. - 외부 URL만으로는 로그 접근이 되지 않으므로, 운영자는 production host의 systemd journal 조회 권한을 가져야 한다. **HTTP 오류 분류:** - `401 Unauthorized`: bearer token 미제공 또는 유효하지 않은 token. 인증 정보 자체가 없거나 만료된 경우. - `403 Forbidden`: token은 유효하나 해당 엔드포인트에 필요한 permission 미보유. read token으로 trade-write 엔드포인트 호출 시 발생. - `404 Not Found`: 인증/권한은 통과했으나 대상 리소스(portfolio, asset 등)가 DB에 존재하지 않음. 권한 문제와 혼동하지 않도록 주의. **Release evidence 기록 필드:** - 포함: 시각(executed_at, completed_at), endpoint, HTTP status code, source system(journalctl unit name), token name, token_prefix, runtime_id - 제외: raw secret, raw token 값 ## Planned next steps 1. Apply and verify Alembic migrations against a real PostgreSQL/Timescale instance. 2. Add integration tests for DB-backed portfolio and user flows. 3. Design token-to-user mapping if ownership or user-scoped authorization is needed later.