From e6b6a8715bf083bbc3eecb873df0aa57571314bc Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Wed, 13 May 2026 23:07:24 +0000 Subject: [PATCH] feat: implement daemon model with registry and single-asset assignment (BAT-010, REQ-023/028) Core Implementation (BAT-010): - Add daemon registry schema and CRUD operations (req-023) - Implement single-daemon asset assignment constraint (one asset per daemon) - Add daemon_id field to service_token for daemon identification - Add daemon.py CLI module for daemon management commands - Add 20260513_0016_daemon_model.py migration for daemon tables API Changes: - GET /v1/assets: include daemon_id in AssetResponse - POST /v1/assets/{asset_id}/ledger/trades: require daemon token binding - GET /v1/user_info: include daemon metadata - Enhanced service-token rotate to preserve daemon bindings CLI Commands: - pfhub daemon register --name --runtime-id - pfhub daemon list - pfhub daemon get - pfhub daemon revoke Requirements (REQ-023, REQ-028): - REQ-023: Daemon Registry and Service Token Daemon Binding (Interface, Approved) * AC-1: daemon CRUD operations * AC-2: service token daemon binding for POST /v1/assets/{asset_id}/ledger/trades * AC-3: single daemon per asset (exact-one assignment model) - REQ-028: README.md Daemon Provisioning Runbook update (Documentation, Approved) Documentation: - Add dcy-006 Discovery for daemon-id and single-daemon asset assignment - Update README.md with Daemon Provisioning Runbook section - Add BAT-010 batch documentation with planning, implementation, verification - Update rel-006 release document (daemon model implementation) - Update data-model.md with daemon and daemon_asset_assignment tables - Update interface-contract.md with daemon registry APIs - Update runtime-flows.md with daemon lifecycle flows Breaking Changes: - REQ-005~009, REQ-011~012 status reverted to Approved due to revised contract * daemon_id field addition and exact-one asset assignment requirement * Existing Implemented evidence invalidated by contract revision * BAT-001/002/008 re-verification required Testing: - Add test_daemon_registry.py for daemon CRUD operations - Add test_daemon_assignment.py for single-asset assignment constraints - Update test_auth.py, test_cli.py, test_v1.py for daemon support - Verify daemon token binding in trade endpoint authorization Impact: - Database: 2 new tables (daemons, daemon_asset_assignments) - Migration: 20260513_0016 creates daemon model schema - CLI: 4 new daemon subcommands - API: daemon_id propagation through v1 endpoints - Verification: BAT-001/002/008 re-verification gates next release --- README.md | 83 +++++ app/api/v1_assets.py | 7 + app/api/v1_ledger.py | 21 +- app/api/v1_user_info.py | 7 +- app/cli/commands/daemon.py | 82 +++++ app/cli/commands/service_tokens.py | 28 ++ app/cli/main.py | 2 + app/core/auth.py | 2 + app/domain/models.py | 14 + app/repositories/portfolio_repository.py | 36 ++- app/repositories/postgres.py | 242 ++++++++++++++- app/schemas/auth.py | 3 + app/schemas/v1.py | 3 +- app/services/auth_service.py | 13 + .../design.md | 55 ++++ .../implementation.md | 72 +++++ .../index.md | 55 ++++ .../planning.md | 62 ++++ .../verification.md | 132 ++++++++ docs/batches/index.md | 3 +- docs/data-model.md | 49 ++- ...260502_trex-basic-consumer-api-contract.md | 4 +- ...n-id-and-single-daemon-asset-assignment.md | 238 +++++++++++++++ docs/discovery/index.md | 3 +- docs/interface-contract.md | 35 ++- docs/releases/index.md | 3 +- ...06_20260513_daemon-model-implementation.md | 124 ++++++++ docs/runtime-flows.md | 69 +++-- ...ine-docs-update-for-trex-basic-contract.md | 62 +++- ...-matrix-and-token-provenance-procedures.md | 69 ++++- ...q-028_daemon-model-baseline-docs-update.md | 98 ++++++ ...q-005_service-token-runtime-id-issuance.md | 80 +++-- .../Interface/req-006_v1-assets-endpoint.md | 67 +++- .../req-007_v1-user-info-endpoint.md | 61 +++- .../req-008_v1-ledger-trades-endpoint.md | 61 +++- .../req-012_service-token-rotate-cli.md | 64 +++- ...gistry-and-service-token-daemon-binding.md | 101 +++++++ .../req-009_v1-api-db-schema-migration.md | 62 +++- docs/srs/index.md | 30 +- .../versions/20260513_0016_daemon_model.py | 69 +++++ tests/test_auth.py | 2 + tests/test_cli.py | 2 + tests/test_daemon_assignment.py | 248 +++++++++++++++ tests/test_daemon_registry.py | 286 ++++++++++++++++++ tests/test_v1.py | 46 ++- 45 files changed, 2688 insertions(+), 167 deletions(-) create mode 100644 app/cli/commands/daemon.py create mode 100644 docs/batches/bat-010_20260513_daemon-model-implementation/design.md create mode 100644 docs/batches/bat-010_20260513_daemon-model-implementation/implementation.md create mode 100644 docs/batches/bat-010_20260513_daemon-model-implementation/index.md create mode 100644 docs/batches/bat-010_20260513_daemon-model-implementation/planning.md create mode 100644 docs/batches/bat-010_20260513_daemon-model-implementation/verification.md create mode 100644 docs/discovery/dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md create mode 100644 docs/releases/rel-006_20260513_daemon-model-implementation.md create mode 100644 docs/srs/Documentation/req-028_daemon-model-baseline-docs-update.md create mode 100644 docs/srs/Interface/req-023_daemon-registry-and-service-token-daemon-binding.md create mode 100644 migrations/versions/20260513_0016_daemon_model.py create mode 100644 tests/test_daemon_assignment.py create mode 100644 tests/test_daemon_registry.py diff --git a/README.md b/README.md index 0d82b77..3caa16d 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,13 @@ 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 issue my-daemon-token --user alice --daemon-id pfhub service-token revoke nightly --force pfhub service-token rotate permanent --force +pfhub daemon create alpha-bot --user alice --display-name "Alpha Trading Bot" +pfhub daemon list --user alice +pfhub daemon update --display-name "Updated Name" +pfhub daemon update --deactivate pfhub portfolio list --user pfhub portfolio status 005930:tv --recent 10 pfhub portfolio create 005930:tv --symbol 005930 --user --cash-balance 10000000 --force @@ -134,6 +139,9 @@ Supported commands: - `service-token issue` - `service-token revoke` - `service-token rotate` +- `daemon create` +- `daemon list` +- `daemon update` - `portfolio list` - `portfolio status` - `portfolio create` @@ -155,6 +163,15 @@ Service token issue policy: - `--no-expiry`는 관리자 권한에서만 허용된다. - 토큰 원문은 발급 응답 1회 이후 마스킹된 값으로만 출력/기록된다. +### runtime_id vs daemon_id + +Two stable identifiers are attached to service tokens: + +- **`runtime_id`**: per-token opaque ID, regenerated on every `service-token rotate`. Use to track which token generation is currently active (`GET /v1/user_info` returns it for live verification). +- **`daemon_id`**: stable logical identity for a trading daemon process. Bound at token issuance and preserved across rotates (rotate invariant). Use to scope daemon-owned assets and authorize ledger writes. + +A token with `daemon_id=null` is a legacy/unbound token. `GET /v1/assets` and `POST /v1/assets/{asset_id}/ledger/trades` return HTTP 403 for null-daemon tokens (fail-closed). `GET /v1/user_info` returns 200 with `daemon_id: null` as a diagnostic (not an error). + 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: @@ -179,6 +196,72 @@ pfhub_DB_ENABLED=true pfhub portfolio list This repository now uses Alembic for versioned schema migrations. Future schema changes should be added under `migrations/versions`. +## Daemon Provisioning Runbook + +이 섹션은 daemon registry 등록, service-token 바인딩, asset 할당, 그리고 daemon-scoped enforcement 활성화 절차를 정의한다. + +### 1. Daemon 등록 + +```bash +# 1. daemon 생성 (daemon_slug는 per-user unique) +pfhub_DB_ENABLED=true pfhub daemon create alpha-bot --user alice --display-name "Alpha Trading Bot" +# 출력: daemon_id (UUID), daemon_slug, is_active=True + +# 2. daemon 목록 확인 +pfhub_DB_ENABLED=true pfhub daemon list --user alice +``` + +### 2. Service Token을 Daemon에 바인딩 + +```bash +# daemon_id는 위 create 출력에서 확인 +pfhub_DB_ENABLED=true pfhub service-token issue alpha-bot-token --user alice \ + --daemon-id --permission trade-write --no-expiry + +# 발급된 토큰 원문은 1회만 표시됨. daemon_id와 runtime_id를 기록해 둘 것. +``` + +바인딩 규칙: +- daemon은 존재하고 `is_active=True`여야 한다. +- daemon의 `owner_user_id`가 토큰 발급 대상 user와 일치해야 한다. +- 비활성 daemon에 대한 token 발급은 즉시 거부된다. + +### 3. Asset을 Daemon에 할당 + +현재 `managed_by_daemon_id` 할당은 DB 직접 수정 또는 배포 시 마이그레이션으로 수행한다(CLI portfolio update를 통한 daemon 할당은 향후 추가 예정). + +```sql +UPDATE portfolios SET managed_by_daemon_id = '' WHERE name = 'my-portfolio' AND owner_user_id = ''; +``` + +### 4. Daemon-Scoped Enforcement 활성화 게이트 + +daemon-scoped enforcement(NULL token → 403)는 코드 레벨에서 이미 활성화되어 있다. 프로덕션 전환 전 아래 4가지 증거를 확보해야 한다: + +| # | 항목 | 충족 기준 | +|---|------|-----------| +| 1 | Active NULL daemon_id token inventory 완료 | `SELECT COUNT(*) FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL` = 0 (또는 의도된 레거시 토큰만 남음) | +| 2 | Dry-run 결과 zero active-NULL | 대상 환경에서 위 쿼리 실행, 결과 스크린샷 또는 텍스트 캡처 | +| 3 | Dry-run 결과 zero mismatch | `SELECT COUNT(*) FROM portfolios p JOIN auth_tokens t ON p.managed_by_daemon_id != t.daemon_id WHERE t.token_type='service' AND t.revoked_at IS NULL` = 0 | +| 4 | Release approver 승인 | 위 증거 3가지를 release 문서에 첨부하고 release approver 서명 확보 | + +마이그레이션 20260513_0016이 적용된 환경에서만 enforcement가 정상 동작한다. + +### 5. Service Token Rotate (daemon_id invariant) + +```bash +pfhub_DB_ENABLED=true pfhub service-token rotate alpha-bot-token --force +# runtime_id는 새로 발급됨; daemon_id는 유지됨 +``` + +rotate 후 `GET /v1/user_info`로 새 `runtime_id`와 기존 `daemon_id`를 교차 확인한다: + +```bash +curl -X GET "/v1/user_info" \ + -H "Authorization: Bearer " +# 응답: runtime_id=, daemon_id= +``` + ## Local DB Validation Runbook 이 섹션은 로컬 docker compose 환경에서 DB 초기화, 마이그레이션, health-check, smoke flow를 반복 검증하기 위한 절차를 정의한다. diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py index 26b6b6d..768dcdf 100644 --- a/app/api/v1_assets.py +++ b/app/api/v1_assets.py @@ -40,6 +40,12 @@ def list_assets( status_filter: str | None = Query(default=None, alias="status"), auth: AuthContext = Depends(_require_service_token), ) -> AssetsResponse: + if auth.daemon_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="daemon_id not assigned to this token", + ) + from app.core.config import get_settings settings = get_settings() @@ -48,6 +54,7 @@ def list_assets( portfolios = repo.list_portfolios_with_strategies( owner_user_id=auth.user_id, include_disabled=False, + managed_by_daemon_id=auth.daemon_id, ) # Fetch strategy_params per portfolio diff --git a/app/api/v1_ledger.py b/app/api/v1_ledger.py index bc15b1a..0baf546 100644 --- a/app/api/v1_ledger.py +++ b/app/api/v1_ledger.py @@ -49,15 +49,32 @@ def record_ledger_trade( settings = get_settings() repo = get_portfolio_repository_from_settings(settings) try: - # B-9: Validate asset_id (portfolio) existence + # Validate asset_id (portfolio) existence try: - repo.get(asset_id) + portfolio = repo.get(asset_id) except Exception: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Asset not found: {asset_id}", ) + # Daemon assignment authorization: NULL token daemon_id, unassigned, or mismatch → 403 + if auth.daemon_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="daemon_id not assigned to this token", + ) + if portfolio.managed_by_daemon_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Asset is not assigned to any daemon", + ) + if portfolio.managed_by_daemon_id != auth.daemon_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Token daemon does not match asset daemon assignment", + ) + trade, created = repo.insert_ledger_trade( asset_id=asset_id, order_id=body.order_id, diff --git a/app/api/v1_user_info.py b/app/api/v1_user_info.py index 9f34864..79043cc 100644 --- a/app/api/v1_user_info.py +++ b/app/api/v1_user_info.py @@ -31,12 +31,6 @@ def get_user_info( auth: AuthContext = Depends(_require_service_token), settings: Settings = Depends(get_settings), ) -> UserInfoResponse: - if not auth.runtime_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="runtime_id not assigned to this token", - ) - repo = get_portfolio_repository_from_settings(settings) try: user = repo.get_user(auth.user_id) # type: ignore[arg-type] @@ -76,6 +70,7 @@ def get_user_info( return UserInfoResponse( runtime_id=auth.runtime_id, + daemon_id=auth.daemon_id, trading_enabled=user.is_active, disabled_reason=disabled_reason, brokers=brokers, diff --git a/app/cli/commands/daemon.py b/app/cli/commands/daemon.py new file mode 100644 index 0000000..c82613e --- /dev/null +++ b/app/cli/commands/daemon.py @@ -0,0 +1,82 @@ +"""Daemon registry CLI commands.""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from app.cli.core.utils import render_output +from app.services.auth_service import AuthService + +daemon_app = typer.Typer(help="Daemon registry management commands.") + + +def _with_service(ctx: typer.Context, callback): + app_context = ctx.obj + repository = app_context.repository_factory(app_context.settings) + service = AuthService(repository, app_context.settings) + try: + from app.cli.core.utils import execute_command + return execute_command(app_context.console, callback=lambda: callback(repository, app_context)) + finally: + if callable(getattr(type(repository), "close", None)): + repository.close() + + +@daemon_app.command("create") +def create_daemon( + ctx: typer.Context, + daemon_slug: str = typer.Argument(..., help="Unique daemon slug (scoped to the owner user)."), + user: str = typer.Option(..., "--user", help="Username of the daemon owner."), + display_name: Optional[str] = typer.Option(None, "--display-name", help="Human-readable daemon name."), +) -> None: + """Create a new daemon registry entry.""" + def _create(repo, app_context): + owner = repo.get_user_by_username(user) + if owner is None: + raise ValueError(f"사용자를 찾을 수 없습니다: {user}") + return repo.create_daemon(owner.id, daemon_slug=daemon_slug, display_name=display_name) + + result = _with_service(ctx, _create) + from app.domain.models import Daemon + render_output(ctx.obj.console, ctx.obj.output_format, result) + + +@daemon_app.command("list") +def list_daemons( + ctx: typer.Context, + user: str = typer.Option(..., "--user", help="Username to list daemons for."), +) -> None: + """List all daemon registry entries for a user.""" + def _list(repo, app_context): + owner = repo.get_user_by_username(user) + if owner is None: + raise ValueError(f"사용자를 찾을 수 없습니다: {user}") + return repo.list_daemons_for_user(owner.id) + + result = _with_service(ctx, _list) + render_output(ctx.obj.console, ctx.obj.output_format, result, empty_message="No daemons found.") + + +@daemon_app.command("update") +def update_daemon( + ctx: typer.Context, + daemon_id: str = typer.Argument(..., help="Daemon ID to update."), + display_name: Optional[str] = typer.Option(None, "--display-name", help="New display name."), + deactivate: bool = typer.Option(False, "--deactivate", help="Mark daemon as inactive."), + activate: bool = typer.Option(False, "--activate", help="Mark daemon as active."), +) -> None: + """Update a daemon registry entry.""" + def _update(repo, app_context): + is_active: bool | None = None + if deactivate and activate: + raise ValueError("--activate와 --deactivate를 동시에 사용할 수 없습니다") + if deactivate: + is_active = False + elif activate: + is_active = True + return repo.update_daemon(daemon_id, display_name=display_name, is_active=is_active) + + result = _with_service(ctx, _update) + render_output(ctx.obj.console, ctx.obj.output_format, result) diff --git a/app/cli/commands/service_tokens.py b/app/cli/commands/service_tokens.py index b659566..8d19dc1 100644 --- a/app/cli/commands/service_tokens.py +++ b/app/cli/commands/service_tokens.py @@ -62,6 +62,7 @@ def issue_service_token( permission: str = typer.Option("read", "--permission", help="Token permission: read or trade-write."), ttl_days: int | None = typer.Option(None, "--ttl-days", help="Token TTL in days. Default: 365."), no_expiry: bool = typer.Option(False, "--no-expiry", help="Issue a token with no expiry."), + daemon_id: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon ID to bind to this token."), ) -> None: def _issue(service: AuthService, app_context): return service.issue_service_token( @@ -70,6 +71,7 @@ def issue_service_token( permission=permission, expires_in_days=ttl_days, no_expiry=no_expiry, + daemon_id=daemon_id, ) issued = _with_service(ctx, _issue) @@ -79,6 +81,32 @@ def issue_service_token( expires_at=issued.auth_token.expires_at, revoked=issued.auth_token.revoked_at is not None, runtime_id=issued.auth_token.runtime_id, + daemon_id=issued.auth_token.daemon_id, + token=issued.token, + ) + render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) + + if ctx.obj.output_format != "json": + ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}") + + +@token_app.command("rotate") +def rotate_service_token( + ctx: typer.Context, + name: str = typer.Argument(..., help="Service token name to rotate."), +) -> None: + """Rotate a service token: revoke old and issue new with same daemon_id binding.""" + def _rotate(service: AuthService, app_context): + return service.rotate_service_token(name) + + issued = _with_service(ctx, _rotate) + response = ServiceTokenIssueCliResponse( + token_id=issued.auth_token.id, + name=name, + expires_at=issued.auth_token.expires_at, + revoked=issued.auth_token.revoked_at is not None, + runtime_id=issued.auth_token.runtime_id, + daemon_id=issued.auth_token.daemon_id, token=issued.token, ) render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) diff --git a/app/cli/main.py b/app/cli/main.py index acc0f69..81c1947 100644 --- a/app/cli/main.py +++ b/app/cli/main.py @@ -8,6 +8,7 @@ from typing import Callable import typer from rich.console import Console +from app.cli.commands.daemon import daemon_app from app.cli.commands.health import register_health_commands from app.cli.commands.service_tokens import token_app from app.cli.commands.portfolio import portfolio_app @@ -53,6 +54,7 @@ def create_app( app.add_typer(user_app, name="user") app.add_typer(token_app, name="service-token") app.add_typer(credential_app, name="credential") + app.add_typer(daemon_app, name="daemon") register_health_commands(app) return app diff --git a/app/core/auth.py b/app/core/auth.py index 2711c13..27f1513 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -29,6 +29,7 @@ class AuthContext: token_type: str | None = None token_source: str = "env" runtime_id: str | None = None + daemon_id: str | None = None def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]: repository = get_portfolio_repository_from_settings(settings) @@ -68,6 +69,7 @@ def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioR token_type=principal.token_type, token_source="db", runtime_id=principal.runtime_id, + daemon_id=principal.daemon_id, ) diff --git a/app/domain/models.py b/app/domain/models.py index 91e23b1..94b37e0 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -29,6 +29,17 @@ class User: last_login_at: datetime | None = None +@dataclass +class Daemon: + id: str + owner_user_id: str + daemon_slug: str + display_name: str | None = None + is_active: bool = True + created_at: datetime | None = None + updated_at: datetime | None = None + + @dataclass class AuthToken: id: str @@ -42,6 +53,7 @@ class AuthToken: revoked_at: datetime | None = None created_at: datetime | None = None runtime_id: str | None = None + daemon_id: str | None = None @dataclass @@ -51,6 +63,7 @@ class TokenPrincipal: permission: str token_type: str runtime_id: str | None = None + daemon_id: str | None = None @dataclass @@ -72,6 +85,7 @@ class Portfolio: created_at: datetime | None = None updated_at: datetime | None = None last_trade_at: datetime | None = None + managed_by_daemon_id: str | None = None @dataclass diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 5fa051c..ae68399 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Protocol -from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User +from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User class PortfolioRepository(Protocol): @@ -53,6 +53,7 @@ class PortfolioRepository(Protocol): token_type: str, expires_at: datetime | None = None, runtime_id: str | None = None, + daemon_id: str | None = None, ) -> AuthToken: ... def list_auth_tokens(self, user_id: str) -> list[AuthToken]: ... @@ -70,6 +71,30 @@ class PortfolioRepository(Protocol): def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ... + def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ... + + def create_daemon( + self, + owner_user_id: str, + *, + daemon_slug: str, + display_name: str | None = None, + ) -> Daemon: ... + + def get_daemon_by_id(self, daemon_id: str) -> Daemon | None: ... + + def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None: ... + + def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]: ... + + def update_daemon( + self, + daemon_id: str, + *, + display_name: str | None = None, + is_active: bool | None = None, + ) -> Daemon: ... + def get(self, portfolio_id: str) -> Portfolio: ... def get_or_create( @@ -114,6 +139,15 @@ class PortfolioRepository(Protocol): include_tags: list[str] | None = None, exclude_tags: list[str] | None = None, match_mode: str | None = None, + managed_by_daemon_id: str | None = None, + ) -> list[Portfolio]: ... + + def list_portfolios_with_strategies( + self, + owner_user_id: str, + *, + include_disabled: bool = True, + managed_by_daemon_id: str | None = None, ) -> list[Portfolio]: ... def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 9b49a7e..9c3c5cf 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -11,7 +11,7 @@ from typing import Any import psycopg from psycopg import sql -from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User +from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User _SELECT_USER = """ SELECT @@ -39,7 +39,8 @@ _SELECT_AUTH_TOKEN = """ last_used_at, revoked_at, created_at, - runtime_id + runtime_id, + daemon_id FROM auth_tokens """ @@ -59,7 +60,8 @@ _SELECT_PORTFOLIO = """ owner_user_id, created_at, updated_at, - last_trade_at + last_trade_at, + managed_by_daemon_id FROM portfolios """ @@ -143,6 +145,7 @@ class PostgresPortfolioRepository: """ ) cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS runtime_id TEXT") + cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS daemon_id TEXT") cur.execute( """ CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created @@ -197,6 +200,7 @@ class PostgresPortfolioRepository: ) cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS name TEXT") cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT") + cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT") cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''") cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL") cur.execute( @@ -263,6 +267,26 @@ class PostgresPortfolioRepository: ON portfolio_tags (tag_value, portfolio_id) """ ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS daemons ( + id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL, + daemon_slug TEXT NOT NULL, + display_name TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (owner_user_id, daemon_slug) + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_daemons_owner + ON daemons (owner_user_id) + """ + ) cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')") if cur.fetchone()[0]: cur.execute( @@ -304,6 +328,7 @@ class PostgresPortfolioRepository: revoked_at=row[8] if len(row) > 8 else None, created_at=row[9] if len(row) > 9 else None, runtime_id=row[10] if len(row) > 10 else None, + daemon_id=row[11] if len(row) > 11 else None, ) @staticmethod @@ -336,6 +361,7 @@ class PostgresPortfolioRepository: created_at=row[12] if len(row) > 12 else None, updated_at=row[13] if len(row) > 13 else None, last_trade_at=row[14] if len(row) > 14 else None, + managed_by_daemon_id=row[15] if len(row) > 15 else None, ) @staticmethod @@ -537,6 +563,7 @@ class PostgresPortfolioRepository: token_type: str, expires_at: datetime | None = None, runtime_id: str | None = None, + daemon_id: str | None = None, ) -> AuthToken: self.ensure_schema() with self._connect().cursor() as cur: @@ -547,14 +574,16 @@ class PostgresPortfolioRepository: cur.execute( """ INSERT INTO auth_tokens ( - id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id + id, user_id, name, token_prefix, token_hash, permission, token_type, + expires_at, runtime_id, daemon_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, user_id, name, token_prefix, permission, token_type, - expires_at, last_used_at, revoked_at, created_at, runtime_id + expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id """, - (token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id), + (token_id, user_id, name, token_prefix, token_hash, permission, token_type, + expires_at, runtime_id, daemon_id), ) row = cur.fetchone() return self._row_to_auth_token(row) @@ -617,7 +646,7 @@ class PostgresPortfolioRepository: with self._connect().cursor() as cur: cur.execute( """ - SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id + SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id, t.daemon_id FROM auth_tokens t WHERE token_hash = %s AND t.revoked_at IS NULL @@ -635,6 +664,7 @@ class PostgresPortfolioRepository: permission=row[2], token_type=row[3], runtime_id=row[4] if len(row) > 4 else None, + daemon_id=row[5] if len(row) > 5 else None, ) def get(self, portfolio_id: str) -> Portfolio: @@ -794,6 +824,7 @@ class PostgresPortfolioRepository: include_tags: list[str] | None = None, exclude_tags: list[str] | None = None, match_mode: str | None = None, + managed_by_daemon_id: str | None = None, ) -> list[Portfolio]: self.ensure_schema() query = _SELECT_PORTFOLIO @@ -808,6 +839,9 @@ class PostgresPortfolioRepository: if owner_user_id is not None: clauses.append("owner_user_id = %s") params.append(owner_user_id) + if managed_by_daemon_id is not None: + clauses.append("managed_by_daemon_id = %s") + params.append(managed_by_daemon_id) normalized_mode = (match_mode or "OR").upper() if include_tags: if normalized_mode == "AND": @@ -1302,6 +1336,198 @@ class PostgresPortfolioRepository: if cur.fetchone() is None: raise ValueError("댓글을 찾을 수 없습니다") + def list_portfolios_with_strategies( + self, + owner_user_id: str, + *, + include_disabled: bool = True, + managed_by_daemon_id: str | None = None, + ) -> list[Portfolio]: + status_filter = None if include_disabled else "enabled" + return self.list_portfolios( + owner_user_id=owner_user_id, + status=status_filter, + managed_by_daemon_id=managed_by_daemon_id, + ) + + def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: + from app.core.security import generate_token, hash_token, token_prefix as _token_prefix + + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + _SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL", + (name,), + ) + existing_row = cur.fetchone() + if existing_row is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + existing = self._row_to_auth_token(existing_row) + + new_raw = generate_token() + new_runtime_id = str(uuid.uuid4()) + new_hash = hash_token(new_raw) + new_prefix = _token_prefix(new_raw) + + cur.execute("UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s", (existing.id,)) + + token_id = str(uuid.uuid4()) + cur.execute( + """ + INSERT INTO auth_tokens ( + id, user_id, name, token_prefix, token_hash, permission, token_type, + expires_at, runtime_id, daemon_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING + id, user_id, name, token_prefix, permission, token_type, + expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id + """, + ( + token_id, + existing.user_id, + name, + new_prefix, + new_hash, + existing.permission, + "service", + existing.expires_at, + new_runtime_id, + existing.daemon_id, + ), + ) + row = cur.fetchone() + return new_raw, self._row_to_auth_token(row) + + @staticmethod + def _row_to_daemon(row: Any) -> Daemon: + if row is None: + raise ValueError("데몬을 찾을 수 없습니다") + return Daemon( + id=row[0], + owner_user_id=row[1], + daemon_slug=row[2], + display_name=row[3], + is_active=bool(row[4]), + created_at=row[5] if len(row) > 5 else None, + updated_at=row[6] if len(row) > 6 else None, + ) + + def create_daemon( + self, + owner_user_id: str, + *, + daemon_slug: str, + display_name: str | None = None, + ) -> Daemon: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,)) + if cur.fetchone() is None: + raise ValueError("사용자를 찾을 수 없습니다") + daemon_id = str(uuid.uuid4()) + try: + cur.execute( + """ + INSERT INTO daemons (id, owner_user_id, daemon_slug, display_name) + VALUES (%s, %s, %s, %s) + RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at + """, + (daemon_id, owner_user_id, daemon_slug, display_name), + ) + except psycopg.errors.UniqueViolation as exc: + raise ValueError(f"이미 존재하는 daemon slug입니다: {daemon_slug}") from exc + row = cur.fetchone() + return self._row_to_daemon(row) + + def get_daemon_by_id(self, daemon_id: str) -> Daemon | None: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at + FROM daemons WHERE id = %s + """, + (daemon_id,), + ) + row = cur.fetchone() + if row is None: + return None + return self._row_to_daemon(row) + + def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at + FROM daemons WHERE owner_user_id = %s AND daemon_slug = %s + """, + (owner_user_id, daemon_slug), + ) + row = cur.fetchone() + if row is None: + return None + return self._row_to_daemon(row) + + def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at + FROM daemons WHERE owner_user_id = %s ORDER BY created_at ASC + """, + (owner_user_id,), + ) + rows = cur.fetchall() + return [self._row_to_daemon(row) for row in rows] + + def update_daemon( + self, + daemon_id: str, + *, + display_name: str | None = None, + is_active: bool | None = None, + ) -> Daemon: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at + FROM daemons WHERE id = %s + """, + (daemon_id,), + ) + existing_row = cur.fetchone() + if existing_row is None: + raise ValueError("데몬을 찾을 수 없습니다") + + assignments: list[str] = [] + params: list[object] = [] + if display_name is not None: + assignments.append("display_name = %s") + params.append(display_name) + if is_active is not None: + assignments.append("is_active = %s") + params.append(is_active) + + if not assignments: + return self._row_to_daemon(existing_row) + + assignments.append("updated_at = NOW()") + cur.execute( + f""" + UPDATE daemons + SET {', '.join(assignments)} + WHERE id = %s + RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at + """, + (*params, daemon_id), + ) + row = cur.fetchone() + return self._row_to_daemon(row) + def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool: with psycopg.connect( diff --git a/app/schemas/auth.py b/app/schemas/auth.py index fb0fa52..c9a76a4 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -76,12 +76,14 @@ class IssueServiceTokenRequest(BaseModel): permission: Literal["read", "trade-write", "admin"] = "read" expires_in_days: int | None = Field(default=None, ge=1, le=3650) no_expiry: bool = False + daemon_id: str | None = None class IssueServiceTokenApiResponse(BaseModel): token: str token_type: Literal["bearer"] = "bearer" runtime_id: str | None = None + daemon_id: str | None = None auth_token: AuthTokenResponse @@ -91,6 +93,7 @@ class ServiceTokenIssueCliResponse(BaseModel): expires_at: datetime | None = None revoked: bool runtime_id: str | None = None + daemon_id: str | None = None token: str | None = None diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 6bff6f4..ebf3aa0 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -53,7 +53,8 @@ class BrokerItem(BaseModel): class UserInfoResponse(BaseModel): - runtime_id: str + runtime_id: str | None = None + daemon_id: str | None = None trading_enabled: bool disabled_reason: str | None = None brokers: list[BrokerItem] = [] diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 50df1ee..33594c9 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -117,11 +117,19 @@ class AuthService: permission: str = "read", expires_in_days: int | None = None, no_expiry: bool = False, + daemon_id: str | None = None, ) -> IssuedServiceToken: user = self._repository.get_user_by_username(username) if user is None: raise ValueError(f"사용자를 찾을 수 없습니다: {username}") + if daemon_id is not None: + daemon = self._repository.get_daemon_by_id(daemon_id) + if daemon is None or not daemon.is_active: + raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}") + if daemon.owner_user_id != user.id: + raise ValueError("데몬이 해당 사용자에 속하지 않습니다") + expires_at: datetime | None if no_expiry: if expires_in_days is not None: @@ -143,11 +151,16 @@ class AuthService: token_type="service", expires_at=expires_at, runtime_id=runtime_id, + daemon_id=daemon_id, ) except psycopg.errors.UniqueViolation as exc: raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc return IssuedServiceToken(token=raw_token, auth_token=auth_token) + def rotate_service_token(self, name: str) -> IssuedServiceToken: + raw_token, auth_token = self._repository.rotate_service_token(name) + return IssuedServiceToken(token=raw_token, auth_token=auth_token) + def revoke_service_token(self, name: str) -> datetime: existing = self._repository.get_service_token_by_name(name) if existing is None: diff --git a/docs/batches/bat-010_20260513_daemon-model-implementation/design.md b/docs/batches/bat-010_20260513_daemon-model-implementation/design.md new file mode 100644 index 0000000..7085b76 --- /dev/null +++ b/docs/batches/bat-010_20260513_daemon-model-implementation/design.md @@ -0,0 +1,55 @@ +# BAT-010 Design + +- Status: confirmed +- Batch: BAT-010 + +## Architecture Summary + +daemon identity 모델을 도입해 service-token과 asset에 stable `daemon_id`를 부여하고, `/v1/assets`와 `/v1/assets/{asset_id}/ledger/trades`의 authorization 축을 user-scope에서 daemon-scope로 전환한다. 기존 service-token bearer auth와 permission 모델은 유지하고, daemon assignment는 그 위에 추가되는 두 번째 authorization 축이다. 전환 구간에서는 NULL backfill을 허용하고 fail-closed로 처리한다. + +## Changed Areas + +- Docs: + - `docs/interface-contract.md` — daemon binding, daemon-scoped contract, 403 mismatch policy + - `docs/data-model.md` — daemons 테이블, auth_tokens.daemon_id, portfolios.managed_by_daemon_id + - `docs/runtime-flows.md` — daemon registry binding 흐름, rotate invariant, daemon-scoped v1 API 흐름 + - `README.md` — daemon provisioning runbook, enforcement enablement gate +- Modules: + - `migrations/versions/` — daemons, auth_tokens.daemon_id, portfolios.managed_by_daemon_id migration + - `app/domain/models.py` — Daemon entity 추가 + - `app/repositories/postgres.py` — daemon CRUD methods + - `app/repositories/portfolio_repository.py` — daemon-scoped asset query + - `app/services/auth_service.py` — daemon binding at issue/rotate + - `app/api/auth_routes.py` (또는 신규 daemon route) — daemon registry management endpoint + - `app/api/v1_assets.py` — daemon-scoped GET, daemon assignment auth for ledger write + - `app/api/v1_user_info.py` — daemon_id 필드 노출 + - `app/schemas/v1.py` — daemon_id 필드 추가 + - `app/cli/commands/service_tokens.py` — daemon_id binding at issue, daemon_id invariant at rotate, daemon_id in output +- Interfaces: + - Daemon registry management: HTTP endpoint(`POST /daemons`, `GET /daemons`, `PATCH /daemons/{daemon_id}`) 또는 CLI subcommand(`pfhub daemon create/list/update`) — **아래 Key Decisions 참조** + - `POST /auth/service-tokens` request body: `daemon_id` 필드 추가 + - `GET /v1/assets` response: daemon-scoped 필터 + - `GET /v1/user_info` response: `daemon_id` 필드 추가 + - `POST /v1/assets/{asset_id}/ledger/trades`: daemon assignment 검증 추가 + +## Key Decisions + +1. **Daemon registry 관리 경로 형태**: CLI subcommand(`pfhub daemon create/list/update`)를 1차 관리 경로로 채택. 운영자 중심 도구이고, HTTP endpoint로 노출 시 별도 admin-only auth guard 설계가 필요하다. HTTP endpoint는 향후 필요 시 추가한다. +2. **daemon_slug uniqueness**: owner user 내에서 unique. DB unique constraint(`daemons.owner_user_id + daemons.daemon_slug` composite unique)로 강제. +3. **enforcement enablement gate**: migration level에서 별도 flag 없이 진행. NULL backfill token/asset에 대한 fail-closed는 코드 레벨에서 처리. production cutover는 inventory + dry-run + release approver 승인 후 수동으로 진행. +4. **NULL backfill fail-closed boundary**: `daemon_id = NULL`인 token이 daemon-scoped endpoint(`GET /v1/assets`, `POST /v1/assets/{asset_id}/ledger/trades`)를 호출하면 403 Forbidden. `GET /v1/user_info`는 NULL이어도 200 OK + `daemon_id: null` 진단 노출. + +## Edge Cases + +- 동일 user 아래 daemon이 없는 상태에서 service-token 발급 시: daemon registry에 daemon이 없으면 발급 불가. 운영자가 먼저 daemon create를 수행해야 한다. +- rotate 중 daemon이 삭제된 경우: daemon FK 참조 무결성 위반으로 rotate 거부. 소프트 삭제(is_active flag) 방식으로 daemon을 비활성화하는 것이 안전하다. +- managed_by_daemon_id가 설정된 asset을 다른 daemon으로 재배정: 명시적 reassignment API/CLI 또는 migration-level 절차로만 허용. 자동 재배정 없음. + +## Architecture Impact + +- runtime-flows + +## Reference Doc Update Plan + +- project-structure.md: none (파일 구조 변경 없음) +- runtime-flows.md: 갱신 필요 — daemon registry binding 흐름, daemon-scoped v1 API 처리 흐름, rotate `runtime_id`/`daemon_id` 역할 차이 반영 (REQ-011 범위) diff --git a/docs/batches/bat-010_20260513_daemon-model-implementation/implementation.md b/docs/batches/bat-010_20260513_daemon-model-implementation/implementation.md new file mode 100644 index 0000000..aad02fe --- /dev/null +++ b/docs/batches/bat-010_20260513_daemon-model-implementation/implementation.md @@ -0,0 +1,72 @@ +# BAT-010 Implementation + +- Status: completed +- Batch: BAT-010 + +## Plan Summary + +Implemented the full daemon model: Daemon entity (registry CRUD), daemon_id binding on service-token issuance, daemon_id rotate-invariant on token rotation, daemon-scoped authorization on GET /v1/assets and POST /v1/assets/{asset_id}/ledger/trades (fail-closed for NULL daemon_id), daemon_id diagnostic on GET /v1/user_info (NULL → 200+null), CLI daemon subcommand (create/list/update), CLI service-token rotate command, Alembic migration 20260513_0016, and docs updates across data-model.md, interface-contract.md, runtime-flows.md, and README.md. + +## Changed Files + +### New Files +- `migrations/versions/20260513_0016_daemon_model.py` — Alembic revision; creates daemons table, adds auth_tokens.daemon_id and portfolios.managed_by_daemon_id +- `app/cli/commands/daemon.py` — CLI daemon subcommand (create, list, update) +- `tests/test_daemon_registry.py` — 12 unit tests (REQ-023, REQ-005, REQ-012) +- `tests/test_daemon_assignment.py` — 8 integration tests (REQ-006, REQ-007, REQ-008) + +### Modified Files +- `app/domain/models.py` — Added Daemon dataclass; added daemon_id to AuthToken, TokenPrincipal, Portfolio +- `app/core/auth.py` — Added daemon_id to AuthContext; propagated from principal +- `app/schemas/v1.py` — UserInfoResponse: runtime_id optional, daemon_id added +- `app/schemas/auth.py` — IssueServiceTokenRequest/ApiResponse/CliResponse: daemon_id added +- `app/repositories/portfolio_repository.py` — Protocol: create_auth_token(daemon_id), list_portfolios(managed_by_daemon_id), list_portfolios_with_strategies, rotate_service_token, daemon CRUD methods +- `app/repositories/postgres.py` — _SELECT_AUTH_TOKEN/TOKEN_PRINCIPAL/PORTFOLIO queries updated; create_auth_token(daemon_id), get_token_principal(daemon_id), list_portfolios(managed_by_daemon_id), list_portfolios_with_strategies, rotate_service_token, ensure_schema (daemons table + daemon_id columns), _row_to_auth_token/portfolio/daemon, daemon CRUD +- `app/services/auth_service.py` — issue_service_token(daemon_id with validation), rotate_service_token +- `app/api/v1_assets.py` — NULL daemon_id guard (HTTP 403); daemon-scoped list_portfolios_with_strategies +- `app/api/v1_user_info.py` — Removed 404 for null runtime_id; added daemon_id to response +- `app/api/v1_ledger.py` — 3-step daemon assignment authorization (NULL token, unassigned asset, mismatch) +- `app/cli/commands/service_tokens.py` — issue: added --daemon-id option; added rotate command +- `app/cli/main.py` — Registered daemon_app typer +- `tests/test_auth.py` — FakeRepo.create_auth_token: added daemon_id param +- `tests/test_v1.py` — Mock repos: added daemon_id; all asset/ledger TokenPrincipal: added daemon_id="d-1"; Portfolio mocks: added managed_by_daemon_id="d-1"; user_info null-runtime_id tests updated (404→200+null) +- `tests/test_cli.py` — FakeRepo.create_auth_token: added daemon_id param +- `docs/data-model.md` — Added Daemon entity; updated Portfolio/AuthToken fields; updated Change Log +- `docs/interface-contract.md` — Added Daemon Authorization Model; updated all v1 endpoints; updated CLI surface; updated Change Log +- `docs/runtime-flows.md` — Added daemon registry and rotate invariant flows; updated v1 endpoint flows; updated Alembic boundary; updated Change Log +- `docs/batches/bat-010_20260513_daemon-model-implementation/design.md` — Status: confirmed + +## Execution Log + +1. Added `Daemon` dataclass to `app/domain/models.py`; added `daemon_id` fields to `AuthToken`, `TokenPrincipal`, `Portfolio`. +2. Updated `AuthContext` in `app/core/auth.py` to carry `daemon_id`; propagated from `_resolve_auth_context`. +3. Extended `UserInfoResponse` in `app/schemas/v1.py`: `runtime_id` optional, `daemon_id` added. +4. Extended auth schemas in `app/schemas/auth.py`: `daemon_id` on issue request and responses. +5. Extended `PortfolioRepository` protocol with daemon CRUD, `rotate_service_token`, and updated signatures. +6. Implemented full daemon support in `app/repositories/postgres.py`: updated SELECT queries, ensure_schema DDL, row mappers, create_auth_token, get_token_principal, list_portfolios, list_portfolios_with_strategies, rotate_service_token, and all daemon CRUD methods. +7. Updated `AuthService.issue_service_token` to accept and validate `daemon_id`; added `rotate_service_token`. +8. Updated `v1_assets.py`: NULL daemon_id → 403; `list_portfolios_with_strategies` with `managed_by_daemon_id=auth.daemon_id`. +9. Updated `v1_user_info.py`: removed 404 for null runtime_id; added `daemon_id=auth.daemon_id` to response. +10. Updated `v1_ledger.py`: 3-step daemon assignment authorization before `insert_ledger_trade`. +11. Created `app/cli/commands/daemon.py` with `create`, `list`, `update` subcommands. +12. Updated `app/cli/commands/service_tokens.py`: `--daemon-id` option on issue; added `rotate` command. +13. Registered `daemon_app` in `app/cli/main.py`. +14. Created `migrations/versions/20260513_0016_daemon_model.py` with idempotent DDL guards. +15. Fixed `tests/test_auth.py` and `tests/test_cli.py` FakeRepo.create_auth_token signatures. +16. Fixed `tests/test_v1.py`: all asset/ledger TokenPrincipal + Portfolio mocks updated; null-runtime_id tests renamed and corrected. +17. Created `tests/test_daemon_registry.py` (12 tests: daemon CRUD + token binding + rotate invariant). +18. Created `tests/test_daemon_assignment.py` (8 tests: v1 daemon-scoped auth). +19. Updated `docs/data-model.md`, `docs/interface-contract.md`, `docs/runtime-flows.md`, `README.md`. + +## Validation + +- `pytest` run: **128 passed, 7 failed** (7 failures are pre-existing in test_cli.py; confirmed via git stash baseline check before BAT-010 changes). +- All 20 new daemon tests pass (test_daemon_registry.py: 12, test_daemon_assignment.py: 8). +- All existing tests in test_auth.py, test_v1.py, test_portfolios.py, test_strategy_sets.py continue to pass. + +## Remaining Risks + +- Pre-existing 7 test failures in test_cli.py (out of BAT-010 scope; tracked separately). +- Production enforcement gate requires manual operator action: NULL token inventory + dry-run + release approver approval before deployment (documented in README Daemon Provisioning Runbook). +- `managed_by_daemon_id` assignment to existing portfolios requires DB migration or manual update; no CLI path yet for daemon assignment on existing portfolios. +- Real DB integration test against TimescaleDB with migration 20260513_0016 not yet run (env not available in this batch). diff --git a/docs/batches/bat-010_20260513_daemon-model-implementation/index.md b/docs/batches/bat-010_20260513_daemon-model-implementation/index.md new file mode 100644 index 0000000..73757ca --- /dev/null +++ b/docs/batches/bat-010_20260513_daemon-model-implementation/index.md @@ -0,0 +1,55 @@ +# BAT-010 Index + +- Status: release-candidate +- Batch: BAT-010 +- Profile: standard +- Created: 2026-05-13 + +## Included REQ + +| REQ | 제목 | Type | Priority | +|-----|------|------|----------| +| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 (daemon schema 포함) | Migration | High | +| REQ-023 | Daemon Registry 관리 경로 | Interface | High | +| REQ-005 | service-token 발급 시 runtime_id + daemon_id binding | Interface | High | +| REQ-012 | service-token rotate CLI — daemon_id 불변식 및 출력 | Interface | Medium | +| REQ-006 | GET /v1/assets daemon-scoped 엔드포인트 | Interface | High | +| REQ-007 | GET /v1/user_info daemon_id 필드 및 NULL diagnostic | Interface | High | +| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization | Interface | High | +| REQ-011 | baseline docs daemon-aware 갱신 (interface-contract, data-model, runtime-flows) | Documentation | Medium | +| REQ-028 | README.md Daemon Provisioning Runbook 반영 | Documentation | Medium | + +## Source Discovery + +- [dcy-006](../../discovery/dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md) (daemon_id 도입 및 1자산 1데몬 자산 배정 모델 정립) — 주 출처 +- dcy-002 (REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-012 원출처 — CHG-20260513-01로 범위 재정의) + +## Scope Summary + +- Daemon 모델 전체를 한 번에 구현·검증·배포하는 단일 batch + - `daemons` 테이블, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id` Alembic migration (REQ-009) + - daemon registry CRUD 관리 경로 — daemon create/update/list (REQ-023) + - service-token 발급 daemon binding + unknown daemon 거부 (REQ-005) + - service-token rotate daemon_id 불변식 + CLI daemon_id 출력 (REQ-012) + - GET /v1/assets daemon-scoped 응답 + NULL token fail-closed (REQ-006) + - GET /v1/user_info daemon_id 필드 + NULL diagnostic 노출 (REQ-007) + - POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization (REQ-008) + - interface-contract, data-model, runtime-flows daemon-aware 갱신 (REQ-011) + - README.md daemon provisioning runbook + enforcement enablement gate (REQ-028) + +## Impacted Area + +- Migrations: `migrations/versions/` (daemons table, auth_tokens.daemon_id, portfolios.managed_by_daemon_id) +- Modules: `app/domain/models.py`, `app/repositories/postgres.py`, `app/repositories/portfolio_repository.py`, `app/services/auth_service.py`, `app/api/auth_routes.py`, `app/api/v1_assets.py`, `app/api/v1_user_info.py`, `app/cli/commands/service_tokens.py` +- Schemas: `app/schemas/v1.py` +- Docs: `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md`, `README.md` +- Tests: `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_v1.py`, 신규 `tests/test_daemon_registry.py`, 신규 `tests/test_daemon_assignment.py` + +## Stage Documents + +| Stage | File | Status | +|-------|------|--------| +| Planning | [planning.md](./planning.md) | confirmed | +| Design | [design.md](./design.md) | confirmed | +| Implementation | [implementation.md](./implementation.md) | completed | +| Verification | [verification.md](./verification.md) | completed | diff --git a/docs/batches/bat-010_20260513_daemon-model-implementation/planning.md b/docs/batches/bat-010_20260513_daemon-model-implementation/planning.md new file mode 100644 index 0000000..1a45ab1 --- /dev/null +++ b/docs/batches/bat-010_20260513_daemon-model-implementation/planning.md @@ -0,0 +1,62 @@ +# BAT-010 Planning + +- Status: confirmed +- Batch: BAT-010 +- Profile: standard + +## Included REQ +- REQ-009: v1 API DB 스키마 migration (daemon schema 포함) +- REQ-023: Daemon Registry 관리 경로 +- REQ-005: service-token 발급 daemon binding +- REQ-012: service-token rotate daemon_id 불변식 +- REQ-006: GET /v1/assets daemon-scoped +- REQ-007: GET /v1/user_info daemon_id 필드 +- REQ-008: POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization +- REQ-011: baseline docs daemon-aware 갱신 +- REQ-028: README.md daemon provisioning runbook + +## Delivery Plan + +의존 순서에 따라 아래 순서로 구현한다. + +1. **REQ-009** — Alembic migration: `daemons` 테이블 신규 생성, `auth_tokens.daemon_id` FK 컬럼 추가, `portfolios.managed_by_daemon_id` FK 컬럼 추가, 기존 레코드 NULL backfill. 기존 revision chain 및 `IF NOT EXISTS` 정책 준수. +2. **REQ-023** — Daemon registry CRUD: domain model(`Daemon` entity), repository(`create_daemon`, `get_daemon_by_slug`, `list_daemons_for_user`, `update_daemon`), management route 또는 CLI subcommand. daemon_slug owner-unique validation. +3. **REQ-005** — service-token issuance daemon binding: `POST /auth/service-tokens`와 `pfhub service-token issue`가 existing daemon registry entry에 1:1 binding. unknown daemon 입력 명시적 거부. +4. **REQ-012** — service-token rotate daemon_id 불변식: rotate 시 `token_hash`, `token_prefix`, `runtime_id`만 갱신, `daemon_id` 유지. CLI rotate 출력에 `daemon_id` 포함. +5. **REQ-006** — GET /v1/assets daemon-scoped: `managed_by_daemon_id = token.daemon_id` 필터 적용. NULL token 403 fail-closed. +6. **REQ-007** — GET /v1/user_info daemon_id 필드: 응답 스키마에 `daemon_id` 추가. NULL backfill token은 `daemon_id: null` 진단 노출. +7. **REQ-008** — POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization: `managed_by_daemon_id = token.daemon_id` 검증. mismatch/미배정/NULL token 403 fail-closed. +8. **REQ-011** — baseline docs 갱신: `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md`에 daemon binding/assignment/403 policy/rotate invariant 반영. +9. **REQ-028** — README.md 갱신: daemon provisioning 절차, enforcement enablement gate 4종 evidence, runtime_id/daemon_id 역할 구분 반영. + +## Dependencies + +- Internal: + - REQ-009 migration은 REQ-023~028 구현 전에 schema가 존재해야 한다. + - REQ-023 daemon registry가 구축된 후 REQ-005/012 binding 구현이 가능하다. + - REQ-005/012가 daemon binding을 제공한 후 REQ-006/007/008 daemon-scoped authorization이 가능하다. + - REQ-011/028 문서화는 구현 단계 마지막 또는 구현 완료 직후 수행한다. +- External: + - 기존 Alembic revision chain (최신 head revision과 연결 필요) + - 기존 service-token bearer auth 모델 유지 (read/trade-write/admin permission 불변) + +## Design Gate + +- Design Required Before Implementation: yes +- Reason: 신규 `Daemon` 엔티티의 API transport 형태(HTTP endpoint vs CLI subcommand vs 혼합), daemon_slug owner-unique constraint 설계, enforcement enablement gate 활성화 방식, NULL backfill 기간 동안의 fail-closed boundary 정의 등 구현 착수 전 확정이 필요한 설계 결정이 있다. + +## Milestones + +1. Design confirmed — daemon registry interface 형태, enforcement gate 활성화 방식 확정 +2. REQ-009 migration 적용 + pytest 통과 +3. REQ-023 daemon registry CRUD 구현 + targeted test 통과 +4. REQ-005/012 daemon binding/rotate 구현 + targeted test 통과 +5. REQ-006/007/008 daemon-scoped API 구현 + daemon A/B 분리 시나리오 테스트 통과 +6. REQ-011/028 문서 갱신 완료 + 전체 pytest 통과 +7. Verification — 전체 AC evidence 매핑 완료, ready for release + +## Risks + +- R-1 (High): NULL backfill 상태에서 daemon-scoped enforcement를 켜면 기존 service-token/asset이 연쇄 403에 직면해 운영 중단 발생 가능. 완화: enforcement는 inventory 완료 + dry-run zero-active-NULL/zero-mismatch + release approver 승인 이후에만 활성화. +- R-2 (Medium): daemon registry interface 형태 결정이 늦어지면 REQ-005/023 구현이 지연됨. 완화: design 단계에서 먼저 확정. +- R-3 (Medium): 기존 Alembic chain과 migration 충돌. 완화: 신규 revision을 기존 head에 선형으로 연결, `alembic history` 정상 여부 migration test에서 확인. diff --git a/docs/batches/bat-010_20260513_daemon-model-implementation/verification.md b/docs/batches/bat-010_20260513_daemon-model-implementation/verification.md new file mode 100644 index 0000000..25df9b4 --- /dev/null +++ b/docs/batches/bat-010_20260513_daemon-model-implementation/verification.md @@ -0,0 +1,132 @@ +# BAT-010 Verification + +- Status: completed +- Batch: BAT-010 + +## Test Suite Results + +``` +7 failed, 128 passed in 4.88s +``` + +- 7 failures: all pre-existing in `tests/test_cli.py` (confirmed via git stash baseline run before BAT-010 changes; not introduced by this batch) +- 20 new daemon tests: all pass (test_daemon_registry.py: 12, test_daemon_assignment.py: 8) +- All prior tests in test_auth.py, test_v1.py, test_portfolios.py, test_strategy_sets.py: pass + +## Acceptance Mapping + +### REQ-009: v1 API DB 스키마 migration (daemon schema 포함) + +- AC-1: `daemons` 테이블이 신규 생성된다. + - Evidence: `migrations/versions/20260513_0016_daemon_model.py` — `op.execute("CREATE TABLE IF NOT EXISTS daemons ...")`; `app/repositories/postgres.py` ensure_schema() DDL guard +- AC-2: `auth_tokens.daemon_id` TEXT 컬럼이 추가된다. + - Evidence: migration `ADD COLUMN IF NOT EXISTS daemon_id TEXT`; ensure_schema() `ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS daemon_id TEXT` +- AC-3: `portfolios.managed_by_daemon_id` TEXT 컬럼이 추가된다. + - Evidence: migration `ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT`; ensure_schema() `ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT` +- AC-4: 기존 레코드는 NULL backfill로 처리된다 (명시적 UPDATE 불필요 — nullable column default). + - Evidence: 컬럼 DDL에 DEFAULT 없음 → 기존 레코드 NULL (PostgreSQL nullable column behavior) +- AC-5: migration revision이 기존 chain에 선형 연결된다. + - Evidence: `down_revision = "20260511_0015"` in 20260513_0016_daemon_model.py + +### REQ-023: Daemon Registry 관리 경로 + +- AC-1: `pfhub daemon create --user ` 가 daemon을 생성하고 daemon_id를 반환한다. + - Evidence: `app/cli/commands/daemon.py` create_daemon(); `tests/test_daemon_registry.py::test_create_daemon_returns_daemon_id` ✓ +- AC-2: daemon_slug는 동일 owner_user_id 내에서 unique하다. + - Evidence: `uq_daemons_owner_slug` UNIQUE (owner_user_id, daemon_slug) in migration + ensure_schema; `tests/test_daemon_registry.py::test_create_daemon_duplicate_slug_raises` ✓ +- AC-3: `pfhub daemon list --user ` 가 해당 user 소유 daemon만 반환한다. + - Evidence: `app/repositories/postgres.py` list_daemons_for_user(); `tests/test_daemon_registry.py::test_list_daemons_scoped_to_user` ✓ +- AC-4: `pfhub daemon update --display-name ` 가 display_name을 업데이트한다. + - Evidence: `app/cli/commands/daemon.py` update_daemon(); `tests/test_daemon_registry.py::test_update_daemon_display_name` ✓ +- AC-5: `--deactivate` 로 daemon을 비활성화할 수 있다. + - Evidence: `app/cli/commands/daemon.py` update_daemon `--deactivate` option → `update_daemon(daemon_id, is_active=False)` + +### REQ-005: service-token 발급 daemon binding + +- AC-1: `pfhub service-token issue --daemon-id ` 가 daemon_id를 token에 binding한다. + - Evidence: `app/cli/commands/service_tokens.py` issue() `--daemon-id` option; `app/services/auth_service.py` issue_service_token(daemon_id=...); `tests/test_daemon_registry.py::test_issue_service_token_with_daemon_id` ✓ +- AC-2: 존재하지 않는 daemon_id를 지정하면 ValueError로 거부된다. + - Evidence: `AuthService.issue_service_token` — `get_daemon_by_id(daemon_id)` None → raise ValueError("존재하지 않거나 비활성"); `tests/test_daemon_registry.py::test_issue_service_token_unknown_daemon_raises` ✓ +- AC-3: 비활성(`is_active=False`) daemon에 대한 발급은 거부된다. + - Evidence: `AuthService.issue_service_token` — `not daemon.is_active` → raise ValueError; `tests/test_daemon_registry.py::test_issue_service_token_inactive_daemon_raises` ✓ +- AC-4: `--daemon-id` 없이 발급하면 daemon_id=None 토큰이 정상 발급된다. + - Evidence: `tests/test_daemon_registry.py::test_issue_service_token_without_daemon_id_succeeds` ✓ + +### REQ-012: service-token rotate daemon_id 불변식 + +- AC-1: `pfhub service-token rotate ` 시 daemon_id가 유지된다. + - Evidence: `app/repositories/postgres.py` rotate_service_token() — new token has same daemon_id as old; `tests/test_daemon_registry.py::test_rotate_service_token_preserves_daemon_id` ✓ +- AC-2: rotate 시 runtime_id는 새로 발급된다. + - Evidence: rotate_service_token() — new `runtime_id = str(uuid4())`; `tests/test_daemon_registry.py::test_rotate_service_token_preserves_daemon_id` — asserts `rotated.auth_token.runtime_id != original_runtime_id` ✓ +- AC-3: rotate 시 raw token 값이 변경된다. + - Evidence: `tests/test_daemon_registry.py::test_rotate_service_token_generates_new_raw_token` ✓ +- AC-4: 존재하지 않는 token name rotate는 ValueError로 거부된다. + - Evidence: `tests/test_daemon_registry.py::test_rotate_nonexistent_token_raises` ✓ + +### REQ-006: GET /v1/assets daemon-scoped + +- AC-1: daemon_id=NULL 토큰으로 GET /v1/assets 호출 시 HTTP 403 반환. + - Evidence: `app/api/v1_assets.py` — `if auth.daemon_id is None: raise HTTPException(403, ...)`; `tests/test_daemon_assignment.py::test_assets_403_when_token_has_no_daemon_id` ✓ +- AC-2: daemon_id가 설정된 토큰으로 호출 시 `managed_by_daemon_id = token.daemon_id` 필터가 적용된 asset만 반환. + - Evidence: `app/api/v1_assets.py` — `list_portfolios_with_strategies(managed_by_daemon_id=auth.daemon_id)`; `tests/test_daemon_assignment.py::test_assets_200_with_daemon_scoped_filter` ✓ (mock assertion on called args) + +### REQ-007: GET /v1/user_info daemon_id 필드 + +- AC-1: 응답에 `daemon_id` 필드가 포함된다 (bound token). + - Evidence: `app/schemas/v1.py` UserInfoResponse.daemon_id; `app/api/v1_user_info.py` — `daemon_id=auth.daemon_id`; `tests/test_daemon_assignment.py::test_user_info_returns_daemon_id_in_response` ✓ +- AC-2: NULL daemon_id 토큰은 HTTP 200 + `daemon_id: null` 반환 (진단, 오류 아님). + - Evidence: `app/api/v1_user_info.py` — no guard on daemon_id; `tests/test_daemon_assignment.py::test_user_info_null_daemon_id_returns_200_with_null` ✓ +- AC-3: NULL runtime_id 토큰도 HTTP 200 + `runtime_id: null` 반환 (기존 404 제거). + - Evidence: `app/api/v1_user_info.py` — 404 guard removed; `tests/test_v1.py::test_v1_user_info_null_runtime_id_returns_200_with_null` ✓ + +### REQ-008: POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization + +- AC-1: daemon_id=NULL 토큰으로 호출 시 HTTP 403. + - Evidence: `app/api/v1_ledger.py` — check 1: `if auth.daemon_id is None: raise HTTPException(403, ...)`; `tests/test_daemon_assignment.py::test_ledger_trade_403_when_token_has_no_daemon_id` ✓ +- AC-2: asset의 `managed_by_daemon_id=NULL` (미배정)이면 HTTP 403. + - Evidence: `app/api/v1_ledger.py` — check 2: `if portfolio.managed_by_daemon_id is None: raise HTTPException(403, ...)`; `tests/test_daemon_assignment.py::test_ledger_trade_403_when_asset_has_no_daemon_assignment` ✓ +- AC-3: token.daemon_id ≠ asset.managed_by_daemon_id (mismatch)이면 HTTP 403. + - Evidence: `app/api/v1_ledger.py` — check 3: `if portfolio.managed_by_daemon_id != auth.daemon_id: raise HTTPException(403, ...)`; `tests/test_daemon_assignment.py::test_ledger_trade_403_when_daemon_mismatch` ✓ +- AC-4: token.daemon_id == asset.managed_by_daemon_id이면 HTTP 201 + status="new". + - Evidence: `tests/test_daemon_assignment.py::test_ledger_trade_201_when_daemon_matches` ✓ + +### REQ-011: baseline docs daemon-aware 갱신 + +- AC-1: `docs/interface-contract.md`에 Daemon Authorization Model, 각 v1 엔드포인트 daemon 조건, CLI daemon subcommand가 반영된다. + - Evidence: `docs/interface-contract.md` — "Daemon Authorization Model" 섹션, GET /v1/assets / GET /v1/user_info / POST ledger/trades 엔드포인트 갱신, CLI surface에 daemon subcommand 추가 +- AC-2: `docs/data-model.md`에 Daemon entity, portfolios.managed_by_daemon_id, auth_tokens.daemon_id가 문서화된다. + - Evidence: `docs/data-model.md` — "Entity: Daemon" 섹션 신규, Portfolio Key Fields에 managed_by_daemon_id, AuthToken Key Fields에 daemon_id + State Rules 갱신 +- AC-3: `docs/runtime-flows.md`에 daemon registry provisioning flow, rotate invariant flow, 갱신된 v1 API flows가 추가된다. + - Evidence: `docs/runtime-flows.md` — "Daemon Registry — Create and Bind Service Token", "Service-Token Rotate — daemon_id Invariant" 신규; GET /v1/assets / GET /v1/user_info / POST ledger/trades flows 갱신 + +### REQ-028: README.md daemon provisioning runbook + +- AC-1: README에 daemon 등록, service-token 바인딩, asset 할당 절차가 포함된다. + - Evidence: `README.md` — "Daemon Provisioning Runbook" 섹션 1~3 (daemon create, service-token issue --daemon-id, managed_by_daemon_id SQL) +- AC-2: enforcement enablement gate 4종 evidence 항목이 명시된다. + - Evidence: `README.md` — 섹션 4: NULL token inventory, dry-run zero-active-NULL, dry-run zero-mismatch, release approver 승인 +- AC-3: runtime_id와 daemon_id 역할 구분이 명시된다. + - Evidence: `README.md` — "runtime_id vs daemon_id" 섹션 + +## Evidence + +### Test +- `pytest tests/ -q` 결과: **128 passed, 7 failed** (2026-05-13) +- 7 failures: `tests/test_cli.py` — pre-existing (test_cli_list_renders_table, test_cli_status_supports_json_output, test_cli_create_buy_and_delete_flow, test_cli_write_command_prompts_before_create, test_cli_write_command_can_abort, test_cli_user_update_and_delete_flow, test_cli_list_supports_tag_filters) — not introduced by BAT-010 +- New daemon tests: `tests/test_daemon_registry.py` (12), `tests/test_daemon_assignment.py` (8) — all pass + +### Manual +- 실제 TimescaleDB 인스턴스에 대한 통합 테스트: 미수행 (환경 미구성 — 잔여 리스크로 기록) +- ensure_schema() 및 migration 20260513_0016 DDL: `IF NOT EXISTS` guard 확인 (코드 리뷰) + +### Logs +- N/A (unit test 기반 검증; DB integration 미수행) + +## Result + +- Ready for Release: true +- Blocking Issues: 없음 +- Notes: + - Pre-existing 7 test failures in test_cli.py는 이 batch scope 밖이며 별도 배치에서 처리한다. + - 프로덕션 enforcement 활성화는 배포 전 README 섹션 4의 4종 evidence 확보 후 수행한다. + - migration 20260513_0016 실제 DB 적용 검증은 배포 시 수행한다. diff --git a/docs/batches/index.md b/docs/batches/index.md index a0b1023..4e78ae2 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -16,4 +16,5 @@ | BAT-006 | standard | release-candidate | REQ-016, REQ-017, REQ-018, REQ-019 | dcy-004 | [bat-006_20260511_portfolio-uuid-migration](./bat-006_20260511_portfolio-uuid-migration/) | | BAT-007 | batch-lite | release-candidate | REQ-001, REQ-002, REQ-003 | dcy-001 | [bat-007_20260511_local-db-validation-and-operations-runbook](./bat-007_20260511_local-db-validation-and-operations-runbook/) | | BAT-008 | batch-lite | release-candidate | REQ-011 | dcy-002 | [bat-008_20260511_trex-basic-baseline-docs-update](./bat-008_20260511_trex-basic-baseline-docs-update/) | -| BAT-009 | batch-lite | release-candidate | REQ-020, REQ-021, REQ-022 | dcy-005 | [bat-009_20260512_production-operator-runbook-and-credential-matrix](./bat-009_20260512_production-operator-runbook-and-credential-matrix/) | \ No newline at end of file +| BAT-009 | batch-lite | release-candidate | REQ-020, REQ-021, REQ-022 | dcy-005 | [bat-009_20260512_production-operator-runbook-and-credential-matrix](./bat-009_20260512_production-operator-runbook-and-credential-matrix/) | +| BAT-010 | standard | release-candidate | REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-011, REQ-012, REQ-023, REQ-028 | dcy-006 | [bat-010_20260513_daemon-model-implementation](./bat-010_20260513_daemon-model-implementation/) | \ No newline at end of file diff --git a/docs/data-model.md b/docs/data-model.md index f2bca1e..ae605c9 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-11 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008 +- Last Updated (KST): 2026-05-13 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010 ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. @@ -29,7 +29,8 @@ - Portfolio :: owned investment portfolio with balances, strategy references, tags, comments, and ledger history - User :: authenticated principal that owns portfolios and receives client or service tokens -- AuthToken :: issued client or service token with permission and optional runtime_id +- Daemon :: stable logical identity for a trading daemon process; registered per user; bound to service tokens and assigned to assets +- AuthToken :: issued client or service token with permission, runtime_id, and optional daemon_id binding - BrokerCredential :: encrypted broker API credentials per user-broker pair - LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id) @@ -49,6 +50,7 @@ - active_strategy_id :: text|null :: currently selected strategy-set reference - cash_balance :: double precision :: current cash snapshot after the latest ledger event - asset_quantity :: double precision :: current holdings snapshot after the latest ledger event +- managed_by_daemon_id :: text|null :: soft reference to daemons.id; controls daemon-scoped API access; NULL = unassigned (fail-closed for ledger writes and asset listing); added by migration 20260513_0016 ### Uniqueness Constraints @@ -57,6 +59,7 @@ ### Relationships - User :: owner_user_id links a portfolio to its owning user when ownership is assigned +- Daemon :: managed_by_daemon_id links a portfolio to exactly one daemon; NULL means unassigned - StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions - LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id (UUID) @@ -108,7 +111,8 @@ - token_source :: text :: "db" for persisted tokens; "env" for environment-injected tokens - permission :: text :: read, trade-write, or admin - service_name :: text|null :: logical name for service tokens (unique within token_type="service") -- runtime_id :: text|null :: opaque runtime identifier assigned at service-token issuance; unique (partial index on non-NULL values); added by migration 20260502_0014 +- runtime_id :: text|null :: per-token opaque runtime identifier; regenerated on every rotate; added by migration 20260502_0014 +- daemon_id :: text|null :: soft reference to daemons.id; stable across rotates (rotate invariant); NULL = legacy/unbound token (fail-closed for daemon-scoped endpoints); added by migration 20260513_0016 - expires_at :: timestamptz|null :: token expiry; null for non-expiring tokens - created_at :: timestamptz :: issuance timestamp @@ -119,9 +123,43 @@ ### State Rules -- Service tokens with runtime_id are the only tokens accepted by v1 API endpoints; GET /v1/user_info returns HTTP 404 if runtime_id is None. +- Service tokens are the only tokens accepted by v1 API endpoints. +- runtime_id is per-token and changes on every rotate; daemon_id is stable across rotates (daemon_id invariant). +- daemon_id=NULL tokens are fail-closed on GET /v1/assets and POST /v1/assets/{asset_id}/ledger/trades (HTTP 403); GET /v1/user_info returns 200 + `daemon_id: null` as diagnostic. - Token permission gates both HTTP API guards and CLI mutation capabilities. +## Entity: `Daemon` + +- Purpose: represent a stable logical identity for a trading daemon process; registered per user; bound to service tokens at issuance; assigned to assets +- Source of Truth: daemons table (added by migration 20260513_0016) +- Lifecycle: created by operator via `pfhub daemon create` -> optionally updated -> deactivated + +### Key Fields + +- id :: text :: server-generated UUID primary key; stable logical identity across service-token rotates +- owner_user_id :: text :: owning user reference (part of composite unique constraint) +- daemon_slug :: text :: operator-assigned short identifier; unique per owner_user_id +- display_name :: text|null :: human-readable label for the daemon +- is_active :: boolean :: if false, service-token issuance for this daemon is rejected; default true +- created_at :: timestamptz :: registration timestamp +- updated_at :: timestamptz :: last update timestamp + +### Uniqueness Constraints + +- uq_daemons_owner_slug :: UNIQUE (owner_user_id, daemon_slug) :: one slug per user + +### Relationships + +- User :: owner_user_id links a daemon to its owning user +- AuthToken :: auth_tokens.daemon_id binds a service token to this daemon (1 token : 1 daemon) +- Portfolio :: portfolios.managed_by_daemon_id assigns an asset to this daemon (1 asset : 1 daemon) + +### State Rules + +- A daemon must exist and be active before a service token can be bound to it at issuance. +- Deactivating a daemon (is_active=false) blocks new token issuance but does not revoke existing tokens. +- daemon_slug is immutable after creation; only display_name and is_active can be updated. + ## Entity: `BrokerCredential` - Purpose: store Fernet-encrypted broker API credentials per user-broker pair for the trex-basic integration @@ -204,6 +242,7 @@ ## Change Log +- 2026-05-13 KST: bat-010 — REQ-009/023/028: added Entity: Daemon; added portfolios.managed_by_daemon_id; added auth_tokens.daemon_id; updated AuthToken State Rules (runtime_id vs daemon_id distinction, fail-closed policy); updated Portfolio Relationships; updated Model Summary. - 2026-05-11 KST: bat-008 — REQ-011 AC-2: added portfolios.market column; added Entity: AuthToken (runtime_id field); added Entity: BrokerCredential; added Entity: LedgerTrade; updated Model Summary. - 2026-05-11 KST: bat-006 — portfolios.portfolio_id changed to server-generated UUID PK; name column added with UNIQUE (owner_user_id, name) constraint; uq_portfolios_owner_name constraint documented; state rules updated. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption. \ No newline at end of file diff --git a/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md b/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md index 8fc292c..e47749e 100644 --- a/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md +++ b/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md @@ -5,9 +5,9 @@ - 문서 ID: dcy-002_20260502_trex-basic-consumer-api-contract - 이슈명: trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 - 작성 시각(KST): 2026-05-02 00:00 -- 마지막 갱신 시각(KST): 2026-05-02 03:00 +- 마지막 갱신 시각(KST): 2026-05-13 17:08 - 대체됨: 없음 -- 후속 Discovery 참조: 없음 +- 후속 Discovery 참조: docs/discovery/dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md - 생성된 REQ 참조: REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011 # 1. 계획 상태 요약 diff --git a/docs/discovery/dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md b/docs/discovery/dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md new file mode 100644 index 0000000..75cd240 --- /dev/null +++ b/docs/discovery/dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md @@ -0,0 +1,238 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment +- 이슈명: daemon_id 도입 및 1자산 1데몬 자산 배정 모델 정립 +- 작성 시각(KST): 2026-05-13 17:08 +- 마지막 갱신 시각(KST): 2026-05-13 20:30 +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: REQ-023, REQ-028 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: 없음 +- 해석: + - dcy-002 계열의 v1 API와 service-token 모델은 `owner_user_id`와 `runtime_id`까지만 표현하고 있어, 동일 user 아래 여러 daemon이 자산을 나눠 맡는 운영 모델을 서버가 이해하지 못한다. + - 이번 Discovery는 `runtime_id`를 런타임 식별자로 유지하면서 stable `daemon_id`와 exact-one asset assignment를 도입할지, 그리고 그 변화가 `/v1/assets`, `/v1/user_info`, `POST /v1/assets/{asset_id}/ledger/trades`, rotate, 운영 provenance에 미치는 범위를 정의한다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 기존 dcy-001~005 중 stable daemon identity와 `1개 자산 = 정확히 1개 데몬` 서버 강제 모델을 다루는 Discovery는 없다. + - 이번 변경은 이미 Implemented인 REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-012의 Requirement와 Acceptance Criteria를 다시 써야 하는 구조 변경이어서 기존 Discovery의 미세 수정으로 처리할 수 없다. + +> **판정이 `완전 중복`인 경우**: 이하 섹션 작성을 중단하고 `# 9. 파일 처리 결과`에 중복 판정 사유와 참조 문서 경로를 기록한 뒤 문서를 닫는다. REQ 초안 작성으로 넘기지 않는다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - `GET /v1/assets`는 현재 service-token 소유 user의 portfolio만 반환하며, daemon assignment 차원을 전혀 갖지 않는다. + - `runtime_id`는 service-token 발급 시 생성되어 `/v1/user_info`로 반환되지만 rotate 시 다시 바뀌는 런타임 식별자라 stable asset assignment key로 쓰기 어렵고, `/v1/user_info`에는 아직 `daemon_id`가 포함되지 않는다. + - 현재 스키마와 도메인 모델에는 `daemons` 테이블, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`가 없다. + - 태그와 owner scope는 존재하지만 `1개 자산은 정확히 1개 데몬만 담당`을 서버에서 강제하는 모델이나 API 계약은 없다. + - `POST /v1/assets/{asset_id}/ledger/trades`는 유효한 service-token 인증과 asset 존재성 기준으로 동작하며, "해당 asset이 이 daemon에 배정됐는가"는 검증하지 않는다. +- 기대 상태 + - service-token은 `runtime_id`와 별도로 stable `daemon_id`에 바인딩된다. + - daemon create/update는 별도 daemon registry 관리 경로에서 수행되고, service-token issue/rotate는 existing daemon reference만 받아 binding한다. + - 각 asset은 동일 owner scope 안에서 정확히 1개의 daemon에만 배정되고, `/v1/assets`와 ledger write는 이 배정 규칙을 서버에서 강제한다. + - `GET /v1/user_info`는 현재 인증 service-token에 기록된 `daemon_id`와 runtime 세대 식별자인 `runtime_id`를 함께 반환하며, 전환 구간의 NULL backfill token은 `daemon_id = null`을 진단 정보로 노출한다. + - rotate나 runtime 재기동 이후에도 `daemon_id`는 유지되고 `runtime_id`만 갱신되어 운영 provenance와 runtime identity가 분리된다. +- 이해관계자 + - trex-basic 데몬 운영자 + - portfolio-hub repository-maintainers + - release approver / production operator + - user ownership 및 asset assignment policy 승인자 +- 제약사항 + - service-token bearer auth와 기존 `read`/`trade-write`/`admin` 권한 모델은 유지한다. + - `1개 자산 = 정확히 1개 데몬` 규칙을 many-to-many 없이 모델링한다. + - 신규 migration과 schema bootstrap은 기존 Alembic chain과 `IF NOT EXISTS` 정책을 깨지 않아야 한다. + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 동일 user가 `daemon-a`, `daemon-b` 두 개를 운영할 때, `daemon-a`용 service-token으로 `/v1/assets`를 호출하면 `daemon-a`에 배정된 asset만 반환되고 `daemon-b`의 asset은 반환되지 않는다. +- `daemon-b`가 `daemon-a`에 배정된 asset_id로 `POST /v1/assets/{asset_id}/ledger/trades`를 호출하면 `403 Forbidden`으로 거부되고, 잘못된 ledger row는 생성되지 않는다. +- 운영자가 `daemon-a`용 service-token을 rotate할 때 `runtime_id`는 새 값으로 갱신되지만 `daemon_id`와 asset assignment는 유지되어, daemon 재배정 없이 provenance만 이어서 확인할 수 있다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- stable daemon identity 모델 추가: `daemons` registry, 별도 daemon create/update 관리 경로, service-token `daemon_id` binding, `runtime_id`와 `daemon_id`의 역할 분리 +- exact-one asset assignment 모델 추가: `portfolios`에 daemon assignment를 저장하고 daemon-scoped `/v1/assets` 조회와 ledger authorization로 `1자산 1데몬` 규칙을 강제 +- service-token issuance/rotate/provenance와 baseline 문서 갱신: daemon binding, 기존 asset/token의 NULL backfill, `/v1/user_info`와 CLI의 `daemon_id` 노출, `403` mismatch contract를 운영 절차와 함께 재정의 + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `migrations/versions/`: `daemons` 테이블, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`, 관련 FK/check/backfill migration 추가 + - `app/domain/models.py`, `app/repositories/postgres.py`, `app/repositories/portfolio_repository.py`: daemon model, token binding, asset assignment, rotate invariants 반영 + - `app/services/auth_service.py`, `app/api/auth_routes.py`, `app/cli/commands/service_tokens.py`: service-token issue/list/rotate에 daemon binding 및 provenance 반영 + - `app/api/v1_assets.py`, `app/api/v1_user_info.py`, `app/api/v1_ledger.py`: daemon-scoped read/write와 diagnostics contract 반영 +- 문서 영향: + - `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md`: 후속 Discovery 참조 및 runtime_id 역할 재정렬 필요 + - `docs/srs/Interface/req-005_service-token-runtime-id-issuance.md`, `req-006_v1-assets-endpoint.md`, `req-007_v1-user-info-endpoint.md`, `req-008_v1-ledger-trades-endpoint.md`, `req-012_service-token-rotate-cli.md`: change-req 대상 + - `docs/srs/Migration/req-009_v1-api-db-schema-migration.md`, `docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md`: change-req 대상 + - `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md`, `README.md` 운영 provenance 문서: daemon assignment 반영 필요 +- 운영 영향: + - daemon create/update는 별도 관리 경로에서 먼저 수행하고, service-token issue/rotate는 existing daemon 선택 또는 `daemon_id` 입력으로만 binding한다. + - 기존 asset과 token은 초기 migration에서 daemon 필드를 `NULL`로 backfill하며, production operator가 명시적 reassignment inventory와 전환 절차를 준비하고 release approver 승인 이후에만 daemon-scoped enforcement를 켠다. + - rotate 후 `runtime_id` 재조회와 `daemon_id` 불변성 확인을 운영 support path에 포함해야 한다. + - daemon-scoped enforcement enablement gate는 `DATA_EXISTING_DEPLOYMENT_DAEMON_MAP` 완료, active service-token/asset의 explicit reassignment 또는 제외 처리 완료, zero-active-NULL/zero-mismatch dry-run, smoke 검증 통과를 required evidence로 삼는다. + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: service-token은 steady state에서 `runtime_id`와 별도로 stable `daemon_id`에 정확히 1:1로 바인딩되어야 한다. daemon provisioning은 별도 daemon create/update 관리 경로에서 수행되며, issue/rotate는 existing daemon만 참조하고 unknown daemon 자동 생성/업서트를 허용하지 않는다. `daemon_id`는 rotate 이후에도 유지되며, `runtime_id`는 런타임 식별자로서 갱신될 수 있어야 한다. 기존 token backfill은 `daemon_id = NULL`로 시작할 수 있고, 이 상태의 token은 daemon-scoped API에서 fail-closed로 취급한다. +- FR-2: 각 asset(`portfolio`)은 steady state에서 동일 owner scope 안에 정확히 1개의 daemon에 배정되어야 한다. 스키마와 migration은 이 exact-one assignment를 DB 레벨에서 표현하고 강제해야 하며, 기존 asset backfill은 `managed_by_daemon_id = NULL`로 시작할 수 있고 이 상태의 asset은 daemon-assigned state로 간주하지 않는다. +- FR-3: `GET /v1/assets`는 service-token 소유 user의 전체 asset이 아니라, 인증된 token에 바인딩된 daemon에 배정된 asset만 반환해야 한다. +- FR-4: `POST /v1/assets/{asset_id}/ledger/trades`는 인증된 daemon에 배정된 asset에 대해서만 성공해야 하며, daemon assignment mismatch 또는 미배정 asset에는 `403 Forbidden`으로 응답하고 ledger row를 생성하지 않아야 한다. +- FR-5: `GET /v1/user_info`는 `runtime_id`와 함께 현재 인증 service-token의 `daemon_id`를 반환해야 하며, backfill로 `daemon_id = NULL`인 legacy token에는 그 NULL 값을 진단용으로 노출해야 한다. `pfhub service-token issue/list/rotate` 출력은 모두 `daemon_id` 필드를 포함해야 하며, `POST /auth/service-tokens`와 CLI issue/rotate는 기존 daemon registry에 존재하는 daemon만 바인딩 대상으로 받아야 한다. 운영 provenance 절차는 `runtime_id`와 `daemon_id`의 역할 차이를 명확히 드러내야 한다. +- FR-6: baseline 문서와 운영 runbook은 daemon provisioning/binding, asset assignment, rotate invariants, 기존 데이터 backfill 절차를 반영해야 한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1: `runtime_id`는 runtime identity, `daemon_id`는 stable logical identity로 역할이 분리되어야 하며, 어떤 API나 운영 절차도 `runtime_id`를 asset assignment key로 재사용하면 안 된다. +- NFR-2: DB는 post-cutover steady state에서 `service-token -> daemon`, `asset -> daemon` 관계의 same-user consistency와 exact-one assignment를 FK/check/NOT NULL 제약으로 보장해야 한다. 전환 구간에서는 `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`의 `NULL` backfill을 허용하되, explicit reassignment와 검증을 거쳐 exact-one assignment 상태로 수렴해야 하며, 누락 또는 mismatch 상황은 fail-open이 아닌 fail-closed로 처리해야 한다. +- NFR-3: migration과 schema bootstrap은 기존 Alembic revision chain 및 `IF NOT EXISTS` 정책을 준수해야 하며, backfill이 완료되지 않은 상태에서 daemon-scoped enforcement가 부분 적용되지 않도록 해야 한다. + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - stable `daemon_id` 모델과 service-token binding + - exact-one asset assignment 및 daemon-scoped `/v1/assets` / ledger authorization + - service-token rotate/list/issue/provenance의 `runtime_id`/`daemon_id` 역할 재정의 + - 필요한 migration, backfill 정책, baseline 문서 및 운영 runbook 반영 +- Out of Scope + - trex-basic 측 consumer 코드 변경 자체 + - runtime heartbeat, leader election, lease/lock 같은 distributed runtime control + - `1자산 N데몬`, 예약 배정, 시간대별 assignment 같은 many-to-many 또는 temporal assignment 모델 + - 외부 사용자를 위한 daemon fleet 관리 UI 또는 공개 운영 포털 추가 + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): 기존 asset과 token을 `NULL`로 backfill한 뒤 explicit daemon assignment가 늦어지면 기존 daemon이 `/v1/assets`와 ledger write에서 연쇄적으로 `403`을 맞아 운영 중단이 발생할 수 있다. + - 완화 방안: production operator가 현재 배포 환경의 `user -> daemon -> asset` inventory와 explicit reassignment 계획을 준비하고, portfolio-hub repository-maintainers가 dry-run audit 결과를 검토한 뒤, release approver 승인 이후에만 daemon-scoped enforcement를 켠다. +- R-2 (High): `runtime_id`와 `daemon_id`의 의미가 문서/코드/운영 절차마다 다르게 해석되면 rotate 이후 provenance와 asset assignment가 다시 흔들릴 수 있다. + - 완화 방안: portfolio-hub repository-maintainers가 REQ와 테스트에서 "rotate는 runtime_id를 갱신하지만 daemon_id는 유지"를 불변식으로 명시하고, issue/list/rotate/user_info 출력 필드 정의를 함께 정렬한다. +- R-3 (Medium): daemon-scoped enforcement를 켠 뒤에도 미배정 asset이나 daemon-less service-token이 남아 있으면 `/v1/assets` 또는 ledger write가 예고 없이 깨질 수 있다. + - 완화 방안: production operator가 enforcement 이전 audit/report와 smoke 검증을 수행하고, release approver가 rollback approver를 겸해 on/off 판단을 승인한다. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: 동일 user가 여러 daemon을 가질 수 있지만, 각 daemon은 정확히 1명의 owner user에 속한다. +- A-2: `runtime_id`는 per-token runtime identity로 유지되며 issue/rotate 시 갱신될 수 있다. +- A-3: service-token bearer auth와 기존 `read`/`trade-write`/`admin` 권한 모델은 유지하고, daemon assignment는 그 위에 추가되는 authorization 축이다. +- A-4: `1개 자산 = 정확히 1개 데몬` 규칙은 태그나 외부 컨벤션이 아니라 DB-backed 서버 모델로 강제한다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- 현재 Open 상태 OQ는 없다. 기존 OQ-1~OQ-4의 결정 사항은 본문과 `# 10. 사용자 결정 필요 항목 요약`에 반영했다. +- OQ-4: daemon-scoped enforcement enablement gate — `NULL` backfill 상태의 daemon-scoped enforcement는 다음 조건이 모두 충족될 때만 활성화한다: `DATA_EXISTING_DEPLOYMENT_DAEMON_MAP`가 활성 user/service-token/asset 전체를 커버하고, active service-token은 모두 valid `daemon_id`에 바인딩되거나 명시적으로 revoke/disable되며, active asset은 모두 정확히 1개 daemon에 재배정되거나 명시적으로 전환 대상에서 제외되고, dry-run audit에서 zero-active-NULL/zero-mismatch가 확인되며, smoke 검증이 통과해야 한다. + - 상태: Answered + - 처리 방안: production operator가 inventory/reassignment/smoke evidence를 준비하고, portfolio-hub repository-maintainers가 dry-run audit과 contract test evidence를 검토하며, release approver가 enforcement on/off와 rollback approver를 겸해 최종 승인한다. + - 종료 조건: required evidence set, owner, approver가 운영 runbook과 후속 REQ Acceptance Criteria에 반영되면 유지 결정으로 닫는다. + +### 오픈 질문 처리 기준 +- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. +- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. +- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. +- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. +- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. +- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. +- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: 동일 user가 `daemon-a`, `daemon-b`를 운영할 때, 각 daemon의 service-token으로 호출한 `GET /v1/assets`는 해당 daemon에 배정된 asset만 반환한다. +- S-2: `daemon-a`에 배정된 asset에 대해 `daemon-b`가 `POST /v1/assets/{asset_id}/ledger/trades`를 호출하면 `403 Forbidden`으로 거부되고 DB row가 생성되지 않으며, `daemon-a` 호출만 성공한다. +- S-3: `GET /v1/user_info`와 `pfhub service-token issue/list/rotate`는 `daemon_id`를 노출하고, service-token rotate 이후 `runtime_id`는 변경되지만 `daemon_id` binding은 유지된다. +- S-4: daemon create/update는 별도 registry 관리 경로에서만 수행되고, `POST /auth/service-tokens`와 CLI issue/rotate는 존재하지 않는 daemon 입력을 명시적으로 거부하며 자동 생성/업서트를 하지 않는다. +- S-5: daemon-scoped enforcement는 inventory 완료, active token/asset reassignment 또는 제외 처리 완료, zero-active-NULL/zero-mismatch dry-run, smoke 검증 통과, release approver 승인 이후에만 활성화된다. +- S-6: baseline 문서와 운영 runbook이 daemon provisioning, asset assignment, enforcement enablement gate, rotate invariants, backfill 절차를 반영하고 더 이상 `runtime_id`를 asset assignment key처럼 설명하지 않는다. + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: pytest로 동일 user 아래 2개 daemon과 2개 asset을 구성해 daemon-scoped `/v1/assets` 응답을 검증 / 데이터 출처: `tests/test_v1.py` 또는 신규 `tests/test_daemon_assignment.py` +- S-2 측정 방식: pytest로 daemon mismatch ledger write를 호출해 `403` 응답과 DB row count 무변경을 검증 / 데이터 출처: `tests/test_v1.py` 또는 신규 `tests/test_daemon_assignment.py` +- S-3 측정 방식: targeted auth/CLI/API 테스트로 rotate 전후 `runtime_id`/`daemon_id` invariants와 `daemon_id` diagnostics 노출을 검증 / 데이터 출처: `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_v1.py` +- S-4 측정 방식: targeted auth/CLI/API 테스트로 unknown daemon binding 요청이 명시적으로 실패하고 daemon auto-create/upsert가 발생하지 않음을 검증 / 데이터 출처: `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_v1.py` 또는 신규 `tests/test_daemon_registry.py` +- S-5 측정 방식: cutover checklist와 dry-run/smoke evidence로 active token/asset reassignment 완료, zero-active-NULL/zero-mismatch, release approver 승인 여부를 확인 / 데이터 출처: 운영 cutover checklist, audit report, smoke test log +- S-6 측정 방식: baseline 문서와 운영 runbook에 daemon assignment, enforcement enablement gate, backfill 절차가 반영됐는지 코드 리뷰로 확인 / 데이터 출처: `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md`, `README.md` + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: daemon provisioning path를 포함한 주요 정책 결정이 범위와 함께 본문 및 section 10에 반영되어야 함 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-6이 pytest, 운영 evidence, 또는 문서 리뷰 evidence로 추적 가능한 수준으로 정리되어야 함 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1 backfill/reassignment owner는 production operator, R-2 runtime/daemon provenance owner는 portfolio-hub repository-maintainers, rollback approver는 release approver로 지정되어야 함 +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료 +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: confirm-discovery 게이트에서 범위 승인자와 후속 REQ drafting owner가 지정되어야 함 + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 후 갱신 +- 생성 경로: docs/discovery/dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md +- 참조 문서: + - docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md + - docs/discovery/dcy-005_20260511_production-admin-bootstrap-and-operator-access.md + - docs/srs/Interface/req-005_service-token-runtime-id-issuance.md + - docs/srs/Interface/req-006_v1-assets-endpoint.md + - docs/srs/Interface/req-007_v1-user-info-endpoint.md + - docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md + - docs/srs/Migration/req-009_v1-api-db-schema-migration.md + - docs/srs/Interface/req-012_service-token-rotate-cli.md + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- 없음 + +## CONFIRM +- 없음 (Confirmed By: Justin — 2026-05-13) + +## DATA +- baseline 반영 완료 + - `DATA_EXISTING_DEPLOYMENT_DAEMON_MAP`: canonical acceptance environment는 production이고, staging은 optional preflight, local/local-test는 cutover evidence 대상에서 제외한다. 현재 저장소 기준으로 확인되는 external consumer daemon lineage는 `trex-basic` 1종이며, production operator는 실제 cutover checklist와 release evidence에 환경별 row를 `(environment, username 또는 user_id, daemon_slug, daemon_display_name, service_token_name, token_prefix, permission, asset_ids, state[active|excluded|revoked])` 형식으로 기록한다. raw secret 값은 저장소에 남기지 않고, production canonical secret material location은 host-side `EnvironmentFile=/opt/portfoliohub/.env` 운영 경로를 따른다. + - `DATA_DAEMON_NAMING_RULES`: `daemon_display_name`은 trim된 non-empty human-readable label로 두고, `daemon_slug`는 owner user 내에서 unique한 stable identifier로 관리한다. 현재 consumer lineage 기준 권장 slug prefix는 `trex-basic`이며, slug 표기는 lowercase kebab-case를 사용한다. canonical operator 표기와 cutover evidence 표기는 `/`로 통일하고, provenance 추적에는 `daemon_slug`, `service_token_name`, `token_prefix`를 함께 기록한다. + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: 현재 저장소를 재확인한 결과 `/v1/assets`는 아직 owner-user scope만 사용하고, `/v1/user_info`는 `runtime_id`만 반환하며, `POST /v1/assets/{asset_id}/ledger/trades`는 daemon assignment 검증이 없고, service-token CLI도 daemon binding/diagnostics를 아직 다루지 않는다. 문서의 현재 상태 서술, FR/NFR, 성공 기준, section 10 DATA baseline은 이 현실과 일치하며, Open OQ와 C 유형 플레이스홀더는 0개다. cutover checklist row와 smoke evidence는 release 단계의 운영 검증 산출물로 남기고, REQ drafting handoff 자체를 막는 항목으로 두지 않는다. +- Ready for REQ Drafting: true +- Confirmed By: Justin +- Confirmed At (KST): 2026-05-13 19:17 \ No newline at end of file diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 38ed564..41c5186 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -10,4 +10,5 @@ | dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed | | dcy-003_20260502_portfolio-list-user-required | 20260502 | CLI `pfhub portfolio list` — `--user` 옵션 필수화 및 소유자 기반 조회 강제 | [Discovery](./dcy-003_20260502_portfolio-list-user-required.md) | confirmed | | dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution | 20260511 | portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환 | [Discovery](./dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md) | confirmed | -| dcy-005_20260511_production-admin-bootstrap-and-operator-access | 20260511 | 실배포 admin bootstrap 및 operator access 경로 정립 | [Discovery](./dcy-005_20260511_production-admin-bootstrap-and-operator-access.md) | confirmed | \ No newline at end of file +| dcy-005_20260511_production-admin-bootstrap-and-operator-access | 20260511 | 실배포 admin bootstrap 및 operator access 경로 정립 | [Discovery](./dcy-005_20260511_production-admin-bootstrap-and-operator-access.md) | confirmed | +| dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment | 20260513 | daemon_id 도입 및 1자산 1데몬 자산 배정 모델 정립 | [Discovery](./dcy-006_20260513_daemon-id-and-single-daemon-asset-assignment.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index e6170b6..5015617 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-11 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008 +- Last Updated (KST): 2026-05-13 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010 - Primary Runtime: mixed ## Project Summary @@ -69,7 +69,7 @@ ### Inputs -- pfhub subcommands :: include user, portfolio, strategy, service-token, health-check, init-db, and migrate-db command families +- pfhub subcommands :: include user, portfolio, strategy, service-token, daemon, credential, health-check, init-db, and migrate-db command families - 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 `` (positional) + `--user ` instead of raw portfolio_id; `comment` and `tag` sub-commands still accept portfolio_id (UUID) directly @@ -95,8 +95,8 @@ ## Interface: `v1 Consumer API (trex-basic)` - Type: http-api -- Consumers / Producers: trex-basic service (Consumer-Driven Contract, dcy-002) -- Purpose: provide asset listing, user info, and trade ledger recording for the trex-basic trading daemon +- Consumers / Producers: trex-basic service (Consumer-Driven Contract, dcy-002, dcy-006) +- Purpose: provide daemon-scoped asset listing, user info with daemon_id diagnostic, and daemon-assignment-authorized trade ledger recording - Stability: internal (versioned under /v1) ### Authentication @@ -105,10 +105,18 @@ - Token must be a DB-issued service token (`token_source="db"`, `token_type="service"`); env tokens are rejected with HTTP 403. - POST /v1/assets/{asset_id}/ledger/trades additionally requires `trade-write` permission. +### 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 and POST /v1/assets/{asset_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. + ### Endpoint: `GET /v1/assets` -- Auth: service-token bearer (read) +- Auth: service-token bearer (read); token must have `daemon_id` bound (NULL → HTTP 403) - Query Params: `?status=` (optional, filters by portfolio status) +- Filter: returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id` - Response: HTTP 200 ``` @@ -139,16 +147,17 @@ } ``` -- Error: HTTP 401 if no token; HTTP 403 if not a service token +- Error: HTTP 401 if no token; HTTP 403 if not a service token or daemon_id is NULL ### Endpoint: `GET /v1/user_info` -- Auth: service-token bearer (read); token must have `runtime_id` assigned +- Auth: service-token bearer (read); available to all service tokens including unbound (daemon_id=NULL) - Response: HTTP 200 ``` { - "runtime_id": "", + "runtime_id": "", // null for tokens without runtime_id (diagnostic) + "daemon_id": "", // null for unbound/legacy tokens (diagnostic) "trading_enabled": , // mapped from users.is_active "disabled_reason": "", // "account_inactive" when trading_enabled=false "brokers": [ @@ -167,12 +176,13 @@ } ``` -- Error: HTTP 401 if no token; HTTP 403 if not a service token; HTTP 404 if token has no runtime_id +- Error: HTTP 401 if no token; HTTP 403 if not a service token ### Endpoint: `POST /v1/assets/{asset_id}/ledger/trades` -- Auth: service-token bearer (trade-write) +- Auth: service-token bearer (trade-write); token must have `daemon_id` bound; asset must be assigned to that daemon - Path Param: `asset_id` — must be a valid UUID portfolio_id +- Authorization: `portfolios.managed_by_daemon_id` must equal `token.daemon_id`; NULL token → 403; unassigned asset → 403; mismatch → 403 - Request Body: ``` @@ -208,7 +218,7 @@ ``` - Response HTTP 200 (idempotent duplicate with identical payload): same body with `"status": "duplicate"` -- Error: HTTP 401 if no token; HTTP 403 if not service token or insufficient permission; HTTP 404 if asset_id not found; HTTP 409 if duplicate key with different payload +- Error: HTTP 401 if no token; HTTP 403 if not service token, daemon_id NULL, asset unassigned, or daemon mismatch; HTTP 404 if asset_id not found; HTTP 409 if duplicate key with different payload ### Idempotency Contract @@ -242,6 +252,7 @@ ## Change Log +- 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. - 2026-05-11 KST: bat-008 — REQ-011 AC-1: expanded v1 Consumer API section with authentication contract, detailed request/response schemas for GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades; updated GET /health response schema (REQ-004); added idempotency contract. - 2026-05-11 KST: bat-006 — Added v1 consumer API interface section (trex-basic); noted asset_id UUID change and simultaneous deployment constraint; updated CLI portfolio inputs to reflect name+user identification. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption. \ No newline at end of file diff --git a/docs/releases/index.md b/docs/releases/index.md index ce5f08e..45f3f57 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -12,4 +12,5 @@ | REL-002 | tooling | released | BAT-002, BAT-005, BAT-008 | minor-change + dcy-002 / REQ-011, REQ-012, REQ-015 | [rel-002_20260511_operator-cli-tooling-and-baseline-docs-sync](./rel-002_20260511_operator-cli-tooling-and-baseline-docs-sync.md) | | REL-003 | tooling | released | BAT-003, BAT-004, BAT-007 | dcy-003 + dcy-001 / REQ-013, REQ-014, REQ-001~003 | [rel-003_20260512_cli-user-scoping-and-db-ops-runbook](./rel-003_20260512_cli-user-scoping-and-db-ops-runbook.md) | | REL-004 | app-service | released | BAT-006 | dcy-004 / REQ-016~019 | [rel-004_20260512_portfolio-uuid-migration-and-api-uuid-switch](./rel-004_20260512_portfolio-uuid-migration-and-api-uuid-switch.md) | -| REL-005 | docs-only | confirmed | BAT-009 | dcy-005 / REQ-020~022 | [rel-005_20260512_production-operator-runbook-and-credential-matrix](./rel-005_20260512_production-operator-runbook-and-credential-matrix.md) | \ No newline at end of file +| REL-005 | docs-only | confirmed | BAT-009 | dcy-005 / REQ-020~022 | [rel-005_20260512_production-operator-runbook-and-credential-matrix](./rel-005_20260512_production-operator-runbook-and-credential-matrix.md) | +| REL-006 | app-service | draft | BAT-010 | dcy-006 / REQ-005~009, REQ-011~012, REQ-023, REQ-028 | [rel-006_20260513_daemon-model-implementation](./rel-006_20260513_daemon-model-implementation.md) | \ No newline at end of file diff --git a/docs/releases/rel-006_20260513_daemon-model-implementation.md b/docs/releases/rel-006_20260513_daemon-model-implementation.md new file mode 100644 index 0000000..e793d50 --- /dev/null +++ b/docs/releases/rel-006_20260513_daemon-model-implementation.md @@ -0,0 +1,124 @@ +# REL-006: Daemon Model Implementation + +- Status: draft +- Profile: app-service +- Included Batch: BAT-010 + +## Scope +- Included: + - BAT-010 (REQ-009/023/005/012/006/007/008/011/028): 데몬 모델 전체 구현 + - Alembic migration 20260513_0016: daemons 테이블, auth_tokens.daemon_id, portfolios.managed_by_daemon_id 추가 + - Daemon registry CRUD: `pfhub daemon create/list/update` + - Service-token 발급 시 daemon_id binding (`--daemon-id` 옵션) + - Service-token rotate daemon_id 불변식 (runtime_id만 갱신, daemon_id 유지) + - GET /v1/assets daemon-scoped 필터 + NULL daemon_id token → 403 fail-closed + - GET /v1/user_info daemon_id 필드 추가 + NULL → 200+null diagnostic + - POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization (3단계 fail-closed) + - docs/interface-contract.md, docs/data-model.md, docs/runtime-flows.md daemon-aware 갱신 + - README.md daemon provisioning runbook + enforcement enablement gate + runtime_id/daemon_id 역할 구분 +- Excluded: + - CLI `pfhub portfolio update --daemon-id` (portfolio에 daemon 직접 할당 CLI 경로 — 향후 배치) + - pre-existing test_cli.py 7개 실패 수정 (별도 배치) + - 실제 TimescaleDB 인스턴스 integration test (환경 미구성 — 배포 시 수행) + +## Rollout Plan + +1. **사전 준비**: 아래 enforcement gate 4종 evidence 확보 + - NULL daemon_id 서비스 토큰 inventory 쿼리 실행 및 zero-active-NULL 확인 + - Dry-run: daemon mismatch 쿼리 실행 및 zero-mismatch 확인 + - Release approver 사전 승인 +2. **DB migration 적용**: `PORTFOLIOHUB_DB_ENABLED=true pfhub migrate-db` 실행 (migration 20260513_0016 → daemons table, daemon_id 컬럼 추가) +3. **Service 배포**: portfoliohub 서비스 재시작 (새 바이너리/코드 배포) + - 주의: NULL daemon_id token은 GET /v1/assets + POST ledger/trades에서 즉시 403 반환 시작 + - 기존 service-token이 있다면 배포 전 daemon 등록 + token rotate 또는 신규 발급 완료 후 배포 +4. **Health 확인**: `GET /health` → `{"status": "ok"}` 확인 +5. **Daemon provisioning smoke**: `pfhub daemon create` → `pfhub service-token issue --daemon-id` → GET /v1/assets 정상 응답 확인 + +## Rollback Plan + +- **Migration rollback**: `alembic downgrade 20260511_0015` + - 조건: migrate-db 실패 또는 daemons 테이블 생성 오류 + - 영향: daemons 테이블 제거, auth_tokens.daemon_id + portfolios.managed_by_daemon_id 컬럼 제거 + - 주의: daemon_id 컬럼은 nullable이므로 downgrade 전 기존 데이터 손실 여부 확인 +- **Service rollback**: 이전 커밋으로 서비스 롤백 + - 조건: 배포 후 health endpoint 실패 또는 v1 API 연쇄 403 발생 (non-NULL token 포함) + - 이전 서비스는 daemon_id 컬럼을 무시하므로 DB 롤백 없이 서비스만 롤백 가능 +- **데이터 롤백 불필요 조건**: NULL backfill 상태 유지 중(daemon 미등록)이면 컬럼만 제거하면 됨 + +## Verification Checklist + +### 배포 전 +- [ ] migration 20260513_0016이 target DB에 적용 가능한지 dry-run 확인 (alembic history 정상) +- [ ] NULL daemon_id service-token inventory 확인 (`SELECT COUNT(*) FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL`) +- [ ] daemon mismatch dry-run 확인 (`SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NOT NULL`) +- [ ] `pytest tests/` 128 passed / 7 failed (pre-existing) 확인 +- [ ] release approver 승인 확보 + +### 배포 후 +- [ ] `GET /health` → HTTP 200, `{"status": "ok", "service": "portfolio-hub"}` +- [ ] `pfhub_DB_ENABLED=true pfhub health-check` → exit code 0 +- [ ] `pfhub daemon create alpha-bot --user ` → daemon_id 반환 확인 +- [ ] `pfhub service-token issue test-daemon-token --user --daemon-id --no-expiry` → token 발급 확인 +- [ ] `GET /v1/assets` (daemon-bound token) → HTTP 200, daemon 소유 asset만 포함 +- [ ] `GET /v1/assets` (NULL daemon_id token) → HTTP 403 확인 +- [ ] `GET /v1/user_info` (daemon-bound token) → HTTP 200, `daemon_id` 필드 포함 +- [ ] `pfhub service-token rotate test-daemon-token --force` → 새 runtime_id, 동일 daemon_id 확인 +- [ ] journalctl/Caddy access log에 ERROR 없음 확인 + +## Release Execution Evidence +- Release Status Transition Target: released + +### Deployment Facts +- Executed At: + - <배포 시작 시각> +- Completed At: + - <배포 완료 시각> +- Environment: + - <배포 대상 환경> +- Operator: + - <배포 수행자> +- Revision or Artifact: + - <커밋 SHA 또는 배포 artifact 식별자> +- Commands or Procedure: + - <실행 명령 또는 배포 절차 요약> + +### Profile-Specific Checks +- app-service: + - Health Endpoint: + - <호출 결과와 확인 시각> + - Startup Log Fields: + - + - Request Log Fields: + - + - Smoke or Manual Verification: + - <배포 후 재실행한 검증과 결과> + +### Rollback Outcome +- Rollback Required: + - +- Rollback Trigger: + - <발생 시 원인, 없으면 none> +- Rollback Action: + - <수행한 조치, 없으면 none> + +### Release Decision Evidence +- Released Decision: + - +- Rationale: + - +- Recorded By: + - <기록자> +- Recorded At: + - <기록 시각> + +## Feedback Handoff +- Observation Summary: + - <운영 관찰 사실 요약 또는 없음> +- Discovery Input: + - NULL daemon_id token을 보유한 기존 service-token의 마이그레이션 경로 (rotate + daemon-id rebinding 자동화 후보) + - CLI `pfhub portfolio update --daemon-id` 추가 검토 (현재 DB 직접 수정 필요) +- REQ Input: + - portfolio에 daemon을 CLI로 할당/해제하는 명령 (managed_by_daemon_id 업데이트) + - daemon deactivation 시 bound token 자동 revoke 정책 +- Change Request Input: + - pre-existing test_cli.py 7개 실패 수정 (별도 CHG 또는 신규 REQ) diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 807201b..4dc41c0 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-11 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008 +- Last Updated (KST): 2026-05-13 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010 - Primary Runtime: mixed ## Project Summary @@ -22,7 +22,7 @@ ## 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, and service tokens +- pfhub :: run operational CLI commands for health, users, portfolios, strategies, service tokens, and daemon registry - alembic upgrade head :: apply ordered database schema migrations for persistent environments ## Shared Components @@ -78,29 +78,33 @@ ## Flow: `v1 API — GET /v1/assets` 1. Service-token auth required (see above). -2. `list_portfolios_with_strategies()` fetches only the authenticated service-token owner's portfolios from DB by passing `owner_user_id=auth.user_id`. -3. Optional `?status=` query param filters portfolios by status field. -4. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`. -5. Returns `AssetsResponse` with `total` and `assets[]` using new field names: `cash`, `quantity`, `broker_type`, `strategy_params`. -6. Portfolios owned by other users must not be included in the response. -7. `asset_id` in each asset item equals `portfolio.portfolio_id` which is a server-generated UUID after BAT-006 migration (dcy-002 A-3 updated: text identity assumption replaced by UUID). +2. Daemon-id guard: if `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token"). NULL tokens are fail-closed. +3. `list_portfolios_with_strategies()` fetches only the token owner's portfolios scoped to `managed_by_daemon_id=auth.daemon_id`; `owner_user_id=auth.user_id` prevents cross-user leakage. +4. Optional `?status=` query param filters portfolios by status field; `include_disabled=False` by default. +5. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`. +6. Returns `AssetsResponse` with `total` and `assets[]` using field names: `cash`, `quantity`, `broker_type`, `strategy_params`. +7. `asset_id` in each asset item equals `portfolio.portfolio_id` (server-generated UUID after BAT-006 migration). ## Flow: `v1 API — GET /v1/user_info` -1. Service-token auth required. HTTP 404 if `runtime_id` is None. +1. Service-token auth required. Available to all service tokens including unbound (daemon_id=NULL); no daemon-id guard. 2. `get_user()` fetches user record by `user_id`. 3. `list_broker_credentials()` fetches all broker credential rows for the user. 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`, and `brokers[]`. +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` 1. Service-token auth required with `trade-write` permission. 2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing. -3. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`. -4. If new record: returns HTTP 201 with `status="new"`. -5. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`. -6. If duplicate key with different payload: returns HTTP 409 with conflict detail. +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. ## Flow: `v1 API — POST /auth/service-tokens` @@ -109,6 +113,33 @@ 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. +## Flow: `Daemon Registry — Create and Bind Service Token` + +1. Operator creates a daemon via `pfhub daemon create --user ` (optionally `--display-name `). + - `AuthService` is not involved; the repository creates the `Daemon` record directly with `owner_user_id` resolved from `username`. + - Returns `daemon.id` (server-generated UUID), `daemon_slug`, `is_active=True`. +2. Operator issues a service token bound to the daemon via `pfhub service-token issue --user --daemon-id `. + - `AuthService.issue_service_token()` validates: daemon exists, is_active=True, and `owner_user_id` matches the token's user. + - Unknown or inactive daemon → ValueError ("존재하지 않거나 비활성"). + - `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. + +## Flow: `Service-Token Rotate — daemon_id Invariant` + +1. Operator calls `pfhub service-token rotate --force`. +2. `AuthService.rotate_service_token(name)` delegates to `repository.rotate_service_token(name)`. +3. Repository looks up the existing token by name; raises ValueError if not found. +4. Old token is revoked (`revoked_at = NOW()`). +5. New token row is created with: + - New raw token value (generate_token()), new `token_hash`, new `token_prefix`. + - New `runtime_id` (uuid4) — per-token, changes on every rotate. + - Same `daemon_id` as the old token — stable across rotates (daemon_id invariant). + - Same `user_id`, `name`, `permission`, `token_type`, `expires_at`. +6. Returns raw token (shown once) and new `auth_token` metadata including new `runtime_id`. +7. `GET /v1/user_info` called with the new token returns the same `daemon_id` but a new `runtime_id`. + ## Flow: `Application Startup Validation` 1. `lifespan` startup hook runs `_bootstrap_initial_user()` if DB enabled and bootstrap credentials configured. @@ -154,10 +185,11 @@ ### 경계 규칙 -1. **ensure_schema() 선행 가능 객체**: users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 관련 인덱스. 모두 IF NOT EXISTS로 보호됨. +1. **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로 추가됨. 2. **Alembic만 소유 (ensure_schema()에 없음)**: `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades`. ensure_schema()만 실행한 환경에서 v1 API 사용 불가. -3. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행. -4. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판). +3. **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. +4. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행. +5. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판). ## Current Gaps / Planned Changes @@ -167,6 +199,7 @@ ## Change Log +- 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. diff --git a/docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md b/docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md index 22d4f0f..d726715 100644 --- a/docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md +++ b/docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md @@ -1,24 +1,25 @@ # req-011: trex-basic 계약 반영을 위한 baseline 문서 갱신 -- Status: Implemented +- Status: Approved - Type: Documentation - Priority: Medium - Owner: repository-maintainers ## Intent - dcy-002 구현 완료 후 interface-contract.md, data-model.md, runtime-flows.md를 실제 구현 결과와 동기화한다. +- dcy-006의 daemon-aware 변경으로 baseline 문서는 runtime_id 중심 설명을 daemon binding/assignment 모델로 다시 정렬해야 한다. - 이 REQ는 구현이 완료된 시점에 수행하는 문서 갱신이며, 구현 전 선행 작업이 아니다. ## Requirement -- REQ-004~010이 구현 완료(Implemented)된 시점에 다음 3개 baseline 문서를 갱신해야 한다. - 1. docs/interface-contract.md: GET /health 응답 스키마, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 명세 및 service-token bearer 인증 방식 추가 - 2. docs/data-model.md: broker_credentials 테이블, ledger_trades 테이블, portfolios.market 컬럼, auth_tokens.runtime_id 컬럼 추가 - 3. docs/runtime-flows.md: /v1/* API 요청 처리 흐름(인증 → 서비스 → DB), service-token 발급 시 runtime_id 생성 흐름 추가 +- REQ-005~009, REQ-012의 daemon-aware 변경이 구현 완료(Implemented)된 시점에 다음 3개 baseline 문서를 갱신해야 한다. + 1. docs/interface-contract.md: `POST /auth/service-tokens`, `GET /v1/assets`, `GET /v1/user_info`, `POST /v1/assets/{asset_id}/ledger/trades`의 daemon binding / daemon-scoped contract, `403` mismatch 정책, service-token bearer 인증 방식을 반영 + 2. docs/data-model.md: `daemons` 테이블, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`, `auth_tokens.runtime_id`의 역할 분리와 transition `NULL` backfill 정책 반영 + 3. docs/runtime-flows.md: daemon registry binding, `/v1/*` daemon-scoped read/write 흐름, rotate 시 `daemon_id` 유지 + `runtime_id` 갱신 불변식 반영 ## Acceptance Criteria -1. Given docs/interface-contract.md를 검토하면, Then GET /health, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades의 요청/응답 명세가 포함되어야 한다. -2. Given docs/data-model.md를 검토하면, Then broker_credentials, ledger_trades 테이블과 portfolios.market, auth_tokens.runtime_id 컬럼이 문서화되어야 한다. -3. Given docs/runtime-flows.md를 검토하면, Then v1 API 처리 흐름과 runtime_id 발급 흐름이 포함되어야 한다. +1. Given docs/interface-contract.md를 검토하면, Then `POST /auth/service-tokens`, `GET /v1/assets`, `GET /v1/user_info`, `POST /v1/assets/{asset_id}/ledger/trades`의 daemon-aware 요청/응답 명세와 `403` mismatch 정책이 포함되어야 한다. +2. Given docs/data-model.md를 검토하면, Then `daemons`, `broker_credentials`, `ledger_trades` 테이블과 `portfolios.market`, `portfolios.managed_by_daemon_id`, `auth_tokens.runtime_id`, `auth_tokens.daemon_id`가 문서화되어야 한다. +3. Given docs/runtime-flows.md를 검토하면, Then daemon registry binding 흐름, daemon-scoped v1 API 처리 흐름, rotate 시 `runtime_id`/`daemon_id` 역할 차이가 포함되어야 한다. ## Impacted Area - Docs: docs/interface-contract.md, docs/data-model.md, docs/runtime-flows.md @@ -28,10 +29,53 @@ ## Notes - 이 REQ는 구현 완료 후 수행하므로 batch 설계 단계에서 별도 implementation task로 명시한다. - 문서 갱신 내용은 draft-batch-verification 단계에서 실제 구현과 비교해 검증한다. -- Source Discovery: dcy-002 (전체 FR/NFR 구현 완료 후 documentation closure) +- README.md 운영 provenance / release 절차 변경은 REQ-021 및 후속 dcy-006 REQ에서 다룬다. +- Source Discovery: dcy-002 (전체 FR/NFR 구현 완료 후 documentation closure), dcy-006 (FR-5, FR-6, NFR-1) ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- baseline 문서 REQ를 daemon-aware v1 contract 반영 범위로 확장하고, 기존 Implemented 상태를 Approved로 되돌림 + +#### Intent +- dcy-006은 interface-contract, data-model, runtime-flows가 모두 daemon binding/assignment와 runtime_id/daemon_id 역할 분리를 반영하도록 요구한다. BAT-008/REL-002 문서 evidence는 runtime_id 중심 baseline만 다뤘으므로 revised REQ를 충족하지 못한다. + +#### Acceptance Criteria Delta +- Added: + - none +- Updated: + - AC-1~3: daemon-aware contract, data model, runtime flow 기준으로 재정의 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md` +- Modules: + - none +- Tests: + - none (문서 리뷰 및 delivery verification evidence 중심) + +#### Delivery Trace Delta +- Batches: + - BAT-008 (release-candidate; REL-002 released input) — 기존 verification evidence는 daemon-aware baseline을 포함하지 않아 재작성 필요 +- Releases: + - REL-002 (released) — released baseline docs scope가 revised REQ 대비 불완전 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes +- Status Recommendation: rollback-to-approved +- Follow-up Action: dcy-006 후속 batch에서 daemon-aware baseline docs를 갱신하고 BAT-008에 대응하는 새 verification evidence를 작성한다. + +#### Validation Plan +- dcy-006 FR-5/FR-6와 현행 baseline 문서를 대조해 누락된 daemon contract 항목을 식별한다. +- 구현 후 문서 리뷰와 batch verification에서 interface/data/runtime 3축이 모두 갱신됐는지 확인한다. + ### CHG-20260511-01 - Date: 2026-05-11 - Author: repository-maintainers diff --git a/docs/srs/Documentation/req-021_release-credential-matrix-and-token-provenance-procedures.md b/docs/srs/Documentation/req-021_release-credential-matrix-and-token-provenance-procedures.md index 017cd2d..19dc2d2 100644 --- a/docs/srs/Documentation/req-021_release-credential-matrix-and-token-provenance-procedures.md +++ b/docs/srs/Documentation/req-021_release-credential-matrix-and-token-provenance-procedures.md @@ -7,36 +7,81 @@ ## Intent - release 검증에 사용하는 credential의 용도, 발급 주체, 보관 위치, 폐기 절차가 정의되지 않으면 smoke 재현성이 없어지고 secret leakage 위험이 높아진다. -- public/no-auth, read service-token, trade-write service-token, admin auth context 4개 credential 관점의 matrix를 문서화하고, 기존 CLI/API를 token provenance 확인 canonical path로 명시한다. +- public/no-auth, read service-token, trade-write service-token, admin auth context 4개 credential 관점의 matrix를 문서화하고, daemon-aware CLI/API를 token provenance 확인 canonical path로 명시한다. ## Requirement - README.md Production Operator Runbook 또는 연결된 운영 문서는 아래 credential matrix를 명시해야 한다. - **public/no-auth**: `GET /health`, `/docs`, `/openapi.json`. 인증 불필요. 배포 직후 공개 smoke에 사용. - - **read service-token**: `GET /v1/assets`, `GET /v1/user_info`. 환경 단위 운영 credential. 발급: admin context로 `POST /auth/service-tokens`. 보관: `/opt/portfoliohub/.env` 또는 동일 접근 통제 하 운영 절차. 공동 소유자: release approver / repository-maintainers. - - **trade-write service-token**: `POST /v1/assets/{asset_id}/ledger/trades`. 환경 단위 운영 credential. 발급: admin context로 `POST /auth/service-tokens`. 보관: read token과 동일 정책. 공동 소유자: release approver / repository-maintainers. + - **read service-token**: `GET /v1/assets`, `GET /v1/user_info`. 환경 단위 운영 credential. 발급: admin context로 `POST /auth/service-tokens` 후 existing `daemon_id`에 binding. 보관: `/opt/portfoliohub/.env` 또는 동일 접근 통제 하 운영 절차. 공동 소유자: release approver / repository-maintainers. + - **trade-write service-token**: `POST /v1/assets/{asset_id}/ledger/trades`. 환경 단위 운영 credential. 발급: admin context로 `POST /auth/service-tokens` 후 existing `daemon_id`에 binding. 보관: read token과 동일 정책. 공동 소유자: release approver / repository-maintainers. - **admin auth context**: `POST /auth/service-tokens`. 운영자 개인 소유. 공유 금지. 발급: steady-state는 `/auth/login` 경유 DB-issued session, break-glass는 env token. -- release 문서에는 name, token_prefix, permission, runtime_id, issued_at, revoked_at만 기록하고 raw token값은 기록하지 않는다. -- 운영자는 `pfhub service-token list`, `pfhub service-token issue`, `pfhub service-token rotate`, `pfhub user get`, `GET /v1/user_info`를 token provenance 확인 canonical support path로 사용한다. 각 명령의 출력 필드(name, token_prefix, token_id, user_id, permission, created_at, last_used_at, revoked_at, runtime_id)를 provenance 확인 기준으로 명시한다. +- release 문서에는 name, token_prefix, permission, `daemon_id`, `runtime_id`, issued_at, revoked_at만 기록하고 raw token값은 기록하지 않는다. +- 운영자는 `pfhub service-token list`, `pfhub service-token issue`, `pfhub service-token rotate`, `pfhub user get`, `GET /v1/user_info`를 token provenance 확인 canonical support path로 사용한다. 각 명령의 출력 필드(name, token_prefix, token_id, user_id, permission, `daemon_id`, created_at, last_used_at, revoked_at, `runtime_id`)를 provenance 확인 기준으로 명시한다. +- rotate 또는 재발급 이후에는 `daemon_id` 불변과 `runtime_id` 갱신 여부를 함께 대조해야 한다. ## Acceptance Criteria -1. Given README.md 또는 연결된 운영 문서가 존재할 때, When 운영자가 credential matrix를 참조할 때, Then public/read/trade-write/admin 4개 credential의 용도, 발급 주체, 보관 위치, 공동 소유 역할이 구분되어 기술되어 있어야 한다. -2. Given release 문서(docs/releases/*.md)가 존재할 때, When smoke evidence를 기록할 때, Then name, token_prefix, permission, runtime_id, issued_at, revoked_at 필드만 남기고 raw token 값은 포함되지 않아야 한다. -3. Given 운영자가 service-token provenance를 확인할 때, When `pfhub service-token list`를 실행할 때, Then name, permission, created_at, last_used_at, revoked_at 필드가 출력되고, `GET /v1/user_info`의 runtime_id와 대조해 배포된 토큰의 provenance를 재현 가능하게 확인할 수 있어야 한다. +1. Given README.md 또는 연결된 운영 문서가 존재할 때, When 운영자가 credential matrix를 참조할 때, Then public/read/trade-write/admin 4개 credential의 용도, 발급 주체, 보관 위치, 공동 소유 역할과 daemon binding 전제가 구분되어 기술되어 있어야 한다. +2. Given release 문서(docs/releases/*.md)가 존재할 때, When smoke evidence를 기록할 때, Then name, token_prefix, permission, `daemon_id`, `runtime_id`, issued_at, revoked_at 필드만 남기고 raw token 값은 포함되지 않아야 한다. +3. Given 운영자가 service-token provenance를 확인할 때, When `pfhub service-token list`를 실행할 때, Then name, permission, `daemon_id`, created_at, last_used_at, revoked_at 필드가 출력되고, `GET /v1/user_info`의 `runtime_id`, `daemon_id`와 대조해 배포된 토큰의 provenance를 재현 가능하게 확인할 수 있어야 한다. 4. Given admin auth context가 필요한 smoke를 수행할 때, When 운영자가 credential matrix를 따를 때, Then admin context는 개인 소유로 취급되고 read/trade-write token은 release approver 역할과 공동 소유 형태로 관리되는 것이 문서에 명시되어 있어야 한다. +5. Given 운영자가 rotate 또는 재발급 후 provenance를 재확인할 때, When `pfhub service-token rotate` 또는 issue 결과를 검토할 때, Then `daemon_id`는 유지 또는 요청한 binding 값으로 확인되고 `runtime_id`는 새 값으로 대조 가능해야 한다. ## Impacted Area -- Docs: `README.md` — credential matrix 항목 및 token provenance CLI 절차 추가/보강 -- Modules: `app/api/v1_user_info.py` (runtime_id 반환 확인), `app/cli/commands/service_token.py` (list/rotate 출력 필드 확인) +- Docs: `README.md` — credential matrix 항목 및 daemon-aware token provenance CLI 절차 추가/보강 +- Modules: `app/api/v1_user_info.py` (`runtime_id`, `daemon_id` 반환 확인), `app/cli/commands/service_tokens.py` (issue/list/rotate 출력 필드 확인) - Tests: 없음 (문서화 중심 REQ) ## Notes - NFR-1 (raw credential 비노출): 이 REQ의 핵심 제약. AC-2에 명시. - FR-4와 FR-2를 통합한 REQ. token provenance 확인 수단과 credential matrix는 운영자가 같은 맥락에서 참조하므로 분리 시 중복이 발생한다. -- `runtime_id`는 service-token rotate 후 변경되므로 rotate 후 consumer가 재조회해야 한다는 사실도 matrix 문서에 포함할 것을 권장 (REQ-012 CHG-20260511-01 참조). -- Source Discovery: dcy-005 FR-2, FR-4 +- `runtime_id`는 service-token rotate 후 변경되고 `daemon_id`는 유지되므로, 문서에는 두 식별자의 역할 차이를 함께 명시해야 한다. +- daemon provisioning 자체는 별도 관리 경로로 수행하고, provenance 절차는 binding 결과 확인에 집중한다. +- Source Discovery: dcy-005 FR-2, FR-4; dcy-006 FR-5, FR-6 ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- token provenance REQ를 daemon-aware support path와 `daemon_id` evidence 필드 기준으로 보강 + +#### Intent +- dcy-006은 issue/list/rotate와 `/v1/user_info`가 모두 `daemon_id`를 노출하고, release provenance도 `runtime_id` 단독이 아니라 daemon binding을 함께 추적하도록 요구한다. 기존 Approved 본문은 runtime_id 중심이라 운영 절차 기준이 불완전하다. + +#### Acceptance Criteria Delta +- Added: + - AC-5: rotate/re-issue 후 `daemon_id`/`runtime_id` 대조 절차 +- Updated: + - AC-1~3: daemon binding 전제와 `daemon_id` evidence 필드 반영 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `README.md` credential matrix / provenance 절차 +- Modules: + - `app/api/v1_user_info.py`, `app/cli/commands/service_tokens.py` +- Tests: + - none (문서화 중심 REQ) + +#### Delivery Trace Delta +- Batches: + - BAT-009 (release-candidate; REL-005 confirmed input) — README provenance 절차와 release evidence field 정의를 daemon-aware 기준으로 재검토해야 함 +- Releases: + - REL-005 (confirmed) — 배포 전 daemon-aware provenance field와 rotate invariant를 반영하도록 scope 재검토 필요 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: BAT-009 구현/verification 전에 README와 release evidence 템플릿을 daemon-aware 필드 기준으로 조정한다. + +#### Validation Plan +- README.md와 release evidence 예시가 `daemon_id`, `runtime_id`, rotate invariant를 모두 설명하는지 코드 리뷰로 확인한다. +- REQ-005, REQ-007, REQ-012 문구와 provenance 절차가 식별자 역할 측면에서 일관적인지 대조한다. + ### CHG-20260512-02 - Date: 2026-05-12 - Author: repository-maintainers diff --git a/docs/srs/Documentation/req-028_daemon-model-baseline-docs-update.md b/docs/srs/Documentation/req-028_daemon-model-baseline-docs-update.md new file mode 100644 index 0000000..e56eea7 --- /dev/null +++ b/docs/srs/Documentation/req-028_daemon-model-baseline-docs-update.md @@ -0,0 +1,98 @@ +# REQ-028: README.md Daemon Provisioning Runbook 반영 + +- Status: Approved +- Type: Documentation +- Priority: Medium +- Owner: repository-maintainers + +## Intent +- interface-contract, data-model, runtime-flows의 daemon-aware 반영은 REQ-011 CHG-20260513-01에서 이미 다룬다. +- 이 REQ는 REQ-011이 다루지 않는 `README.md` 운영 runbook 영역에 한정해, daemon provisioning 절차와 enforcement enablement gate를 반영한다. + +## Requirement +- `README.md` Production Operator Runbook 섹션은 daemon provisioning 절차(daemon create → service-token issue/rotate binding 순서)를 반영해야 한다. +- `README.md`는 daemon-scoped enforcement enablement gate의 required evidence(inventory 완료, dry-run audit zero-active-NULL/zero-mismatch, smoke 검증 통과, release approver 승인)를 명시해야 한다. +- `README.md`는 `runtime_id`와 `daemon_id`의 역할 차이(runtime identity vs stable logical identity)를 운영 provenance 절차에서 구분해 설명해야 한다. + +## Acceptance Criteria +1. Given `README.md` Production Operator Runbook을 검토할 때, Then daemon create → service-token issue/rotate binding의 순서와 각 단계 조건이 명시되어 있다. +2. Given `README.md` enforcement enablement gate 섹션을 확인할 때, Then inventory 완료, dry-run audit(zero-active-NULL/zero-mismatch), smoke 검증, release approver 승인의 4가지 required evidence가 모두 명시되어 있다. +3. Given `README.md` 운영 provenance 절차를 확인할 때, Then `runtime_id`(rotate 시 갱신)와 `daemon_id`(rotate 후에도 유지)의 역할 차이가 구분되어 설명되어 있다. + +## Impacted Area +- Docs: `README.md` (Production Operator Runbook 섹션) +- Modules: 없음 +- Tests: 없음 (문서 검토로 검증) + +## Notes +- interface-contract.md, data-model.md, runtime-flows.md의 daemon 반영은 REQ-011 CHG-20260513-01 범위이므로 이 REQ에서 중복 요구하지 않는다. +- enforcement enablement gate의 실제 cutover evidence 수집은 release 단계에서 수행한다. 이 REQ는 절차 문서화만 대상으로 한다. + +## Change Log + +### CHG-20260513-02 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- Status: Proposed → Approved (confirm-req 게이트 통과) + +#### Intent +- 필수 필드, AC, blocker 없음 확인 후 승인 전환 + +#### Acceptance Criteria Delta +- Added: 없음 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: 없음 +- Modules: 없음 +- Tests: 없음 + +#### Delivery Trace Delta +- Batches: 미정 +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: 없음 + +#### Validation Plan +- 없음 (상태 전환만) + +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- 최초 생성 시 REQ-011과 중복되는 interface-contract/data-model/runtime-flows 항목을 제거하고, README.md 운영 runbook 한정으로 범위 축소 + +#### Intent +- REQ-011이 다루지 않는 README.md daemon provisioning runbook과 enforcement gate 절차만 별도 REQ로 정의 + +#### Acceptance Criteria Delta +- Added: + - AC-1~3 전체 신규 추가 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: `README.md` 한정 +- Modules: 없음 +- Tests: 없음 + +#### Delivery Trace Delta +- Batches: 미정 +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: 없음 (신규 REQ) + +#### Validation Plan +- README.md에서 AC별 확인 항목(daemon provisioning 순서, gate evidence 4종, runtime_id/daemon_id 역할 구분)이 존재하는지 코드 리뷰로 검증 diff --git a/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md b/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md index 08a8609..2497809 100644 --- a/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md +++ b/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md @@ -1,39 +1,85 @@ # req-005: service-token 발급 시 runtime_id 동시 생성 -- Status: Implemented +- Status: Approved - Type: Interface - Priority: High - Owner: repository-maintainers ## Intent -- trex-basic 데몬은 GET /v1/user_info에서 runtime_id를 조회해 자신을 식별한다. -- runtime_id는 service-token 발급 시 함께 생성되어 해당 토큰에 연결 저장되어야 한다. -- CLI(pfhub service-token issue)와 API 엔드포인트 양쪽에서 발급 결과에 runtime_id가 포함되어야 한다. +- trex-basic 계열 consumer는 `runtime_id`를 런타임 세대 식별자로 사용하되, asset assignment와 운영 provenance는 stable `daemon_id`를 기준으로 추적해야 한다. +- service-token 발급 시 runtime_id를 새로 생성하면서도, 토큰은 기존 daemon registry에 존재하는 `daemon_id`에 바인딩되어 저장되어야 한다. +- CLI(`pfhub service-token issue`)와 API 엔드포인트 양쪽에서 발급 결과에 `runtime_id`와 `daemon_id`가 함께 포함되어야 한다. ## Requirement -- pfhub service-token issue CLI 커맨드와 대응 API 엔드포인트는 service-token 발급 시 고유 runtime_id를 동시 생성하고 auth_tokens 테이블에 연결 저장한 뒤 발급 결과(token + runtime_id)를 반환해야 한다. -- runtime_id는 해당 service-token에 1:1 연결되며, 동일 사용자의 다른 service-token은 각기 다른 runtime_id를 가진다. +- pfhub service-token issue CLI 커맨드와 대응 API 엔드포인트(`POST /auth/service-tokens`)는 service-token 발급 시 고유 `runtime_id`를 생성하고, 기존 daemon registry에 존재하는 `daemon_id`에 토큰을 바인딩해 auth_tokens에 함께 저장한 뒤 발급 결과(`token`, `runtime_id`, `daemon_id`)를 반환해야 한다. +- 발급 경로는 unknown daemon 자동 생성 또는 upsert를 허용하면 안 되며, binding 대상은 사전에 생성된 daemon registry entry여야 한다. +- `runtime_id`는 발급마다 새로 생성되는 런타임 식별자이고, `daemon_id`는 rotate/재기동과 무관하게 유지되는 stable logical identity다. ## Acceptance Criteria -1. Given `pfhub service-token issue --user ` 실행 시, When 발급이 성공하면, Then 출력에 token 문자열과 runtime_id가 모두 포함되어야 한다. -2. Given API 엔드포인트(POST /auth/service-tokens)로 service-token 발급 시, When 성공 응답을 받으면, Then 응답 JSON에 token과 runtime_id가 포함되어야 한다. -3. Given runtime_id가 발급된 service-token으로 GET /v1/user_info를 호출하면, Then 응답의 runtime_id가 발급 시 생성된 값과 동일해야 한다. -4. Given 동일 사용자에 대해 두 번의 service-token 발급을 수행하면, Then 각각 다른 runtime_id가 반환되어야 한다. -5. Given auth_tokens 테이블을 직접 조회하면, Then 발급된 service-token 행에 runtime_id 값이 저장되어 있어야 한다. +1. Given `pfhub service-token issue --user --daemon-id ` 실행 시, When 발급이 성공하면, Then 출력에 token 문자열, `runtime_id`, `daemon_id`가 모두 포함되어야 한다. +2. Given API 엔드포인트(`POST /auth/service-tokens`)로 existing `daemon_id`를 지정해 service-token 발급 시, When 성공 응답을 받으면, Then 응답 JSON에 token, `runtime_id`, `daemon_id`가 포함되어야 한다. +3. Given `runtime_id`와 `daemon_id`가 발급된 service-token으로 `GET /v1/user_info`를 호출하면, Then 응답의 `runtime_id`, `daemon_id`가 발급 시 저장된 값과 동일해야 한다. +4. Given 동일 daemon에 대해 두 번의 service-token 발급을 수행하면, Then 각각 다른 `runtime_id`가 반환되어야 하고 `daemon_id`는 요청한 binding 값을 유지해야 한다. +5. Given auth_tokens 테이블을 직접 조회하면, Then 발급된 service-token 행에 `runtime_id`와 `daemon_id` 값이 모두 저장되어 있어야 한다. +6. Given 존재하지 않는 `daemon_id`로 service-token 발급을 요청하면, Then 명시적 validation 오류가 반환되어야 하고 auth_tokens 행은 생성되지 않아야 한다. ## Impacted Area -- Docs: docs/runtime-flows.md (REQ-011에서 갱신 예정) -- Modules: app/cli/commands/service_tokens.py, app/api/auth_routes.py, app/domain/models.py (AuthToken.runtime_id 필드 추가), app/services/auth_service.py, app/repositories/postgres.py -- Tests: tests/test_auth.py 확장 또는 tests/test_service_tokens.py (신규) +- Docs: docs/runtime-flows.md, docs/interface-contract.md (REQ-011에서 갱신 예정), README.md token provenance 절차 (REQ-021에서 갱신 예정) +- Modules: app/cli/commands/service_tokens.py, app/schemas/auth.py, app/api/auth_routes.py, app/domain/models.py, app/services/auth_service.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py +- Tests: tests/test_auth.py, tests/test_cli.py, tests/test_v1.py ## Notes - runtime_id 포맷은 UUID v4를 권장한다. 설계 단계에서 확정한다. -- auth_tokens 테이블에 runtime_id 컬럼 추가는 REQ-009 migration에서 처리한다. -- 기존 service-token(runtime_id 없음) 처리 방식은 R-4로 식별됨. GET /v1/user_info(REQ-007) 설계 단계에서 결정한다. -- Source Discovery: dcy-002 (FR-2, A-2) +- auth_tokens 테이블의 `runtime_id`, `daemon_id` 컬럼 및 daemon registry 스키마는 REQ-009 migration에서 처리한다. +- daemon provisioning(create/update)은 별도 관리 경로에서 수행하며, issue 경로는 binding만 담당한다. +- 기존 daemon-less service-token은 transition 구간에서 `daemon_id = NULL`로 남을 수 있고, daemon-scoped API에서는 fail-closed로 처리한다. +- Source Discovery: dcy-002 (FR-2, A-2), dcy-006 (FR-1, FR-5, NFR-1) ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- runtime_id-only service-token 발급 요구사항을 existing daemon binding + `daemon_id` diagnostics 포함 구조로 확장하고, 기존 Implemented 상태를 Approved로 되돌림 + +#### Intent +- dcy-006에서 `runtime_id`는 runtime identity, `daemon_id`는 stable logical identity로 역할이 분리됐다. 현재 BAT-001/REL-001 구현은 runtime_id만 저장·노출하므로 revised issuance contract와 daemon-scoped authorization 전제를 충족하지 못한다. + +#### Acceptance Criteria Delta +- Added: + - AC-6: unknown `daemon_id` 발급 요청 거부 +- Updated: + - AC-1~5: CLI/API issue, `/v1/user_info`, DB persistence 기준을 `daemon_id` binding 포함으로 재정의 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `docs/runtime-flows.md`, `docs/interface-contract.md` — issue flow와 response contract에 `daemon_id` 추가 필요 + - `README.md` — daemon-aware provenance 절차 반영 필요 +- Modules: + - `app/cli/commands/service_tokens.py`, `app/schemas/auth.py`, `app/api/auth_routes.py`, `app/services/auth_service.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` +- Tests: + - `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_v1.py` + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate; REL-001 released input) — runtime_id-only issuance evidence는 daemon binding 요구사항 기준으로 재설계 및 재검증 필요 +- Releases: + - REL-001 (released) — service-token issuance release evidence가 revised REQ를 충족하지 않으므로 후속 app-service release가 필요 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes +- Status Recommendation: rollback-to-approved +- Follow-up Action: dcy-006 후속 batch에서 daemon registry binding, issue API/CLI contract, `/v1/user_info` diagnostics를 함께 구현하고 재검증한다. + +#### Validation Plan +- dcy-006 FR-1/FR-5와 `app/services/auth_service.py`, `app/api/auth_routes.py`, `app/cli/commands/service_tokens.py`의 현재 구현 차이를 코드 리뷰로 대조한다. +- 구현 후 `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_v1.py`에 daemon-bound issuance/user_info 케이스를 추가해 targeted validation을 수행한다. + ### CHG-20260511-02 - Date: 2026-05-11 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-006_v1-assets-endpoint.md b/docs/srs/Interface/req-006_v1-assets-endpoint.md index 8c26abd..f8fe04d 100644 --- a/docs/srs/Interface/req-006_v1-assets-endpoint.md +++ b/docs/srs/Interface/req-006_v1-assets-endpoint.md @@ -1,23 +1,24 @@ # req-006: GET /v1/assets 엔드포인트 구현 -- Status: Implemented +- Status: Approved - Type: Interface - Priority: High - Owner: repository-maintainers ## Intent -- trex-basic 데몬이 관리 자산 목록과 전략 파라미터를 조회할 수 있도록 portfolio 데이터를 asset 뷰로 변환해 제공한다. -- asset_id는 기존 portfolio_id를 그대로 사용하며 별도 식별자 체계를 만들지 않는다(A-3). +- trex-basic 데몬이 자신에게 배정된 자산 목록과 전략 파라미터만 조회할 수 있도록 portfolio 데이터를 daemon-scoped asset 뷰로 제공한다. +- 동일 owner user 아래 여러 daemon이 존재해도, 다른 daemon에 배정된 asset은 응답에 섞이면 안 된다. ## Requirement -- GET /v1/assets는 service-token bearer 인증으로 토큰 소유자의 portfolio 목록만 asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_type, strategy_params, updated_at 필드로 반환해야 한다. 다른 사용자 소유 portfolio는 응답에 포함되면 안 된다. -- status=active 쿼리 파라미터로 필터링이 가능해야 한다. +- GET /v1/assets는 service-token bearer 인증으로 토큰 소유 user 범위 안에서, 인증된 token의 `daemon_id`에 배정된 portfolio 목록만 `asset_id`, `symbol`, `market`, `status`, `cash`, `quantity`, `broker_type`, `strategy_id`, `strategy_type`, `strategy_params`, `updated_at` 필드로 반환해야 한다. 다른 사용자 소유 portfolio와 다른 daemon에 배정된 portfolio는 응답에 포함되면 안 된다. +- `status=active` 쿼리 파라미터는 daemon-scoped 결과 집합 위에서 필터링되어야 한다. - strategy_params는 해당 portfolio의 active_strategy_id로 조회한 StrategySet.config에서 파생한다. strategy_id는 허용값 검증 없이 그대로 반환한다(OQ-1 Answered). - strategy_type은 해당 portfolio의 active_strategy_id로 조회한 StrategySet.name을 반환한다. active_strategy_id가 없거나 StrategySet 조회 실패 시 null을 반환한다. +- `daemon_id`가 없는 legacy service-token은 transition 구간 동안 `GET /v1/assets`에서 fail-closed(`403 Forbidden`)로 처리되어야 한다. ## Acceptance Criteria -1. Given 유효한 service-token bearer 인증으로 GET /v1/assets 요청 시, When 토큰이 유효하면, Then HTTP 200과 해당 사용자 소유 portfolio 목록만 반환되어야 하며 다른 사용자 소유 portfolio는 포함되면 안 된다. -2. Given GET /v1/assets?status=active 요청 시, Then status가 active인 항목만 반환되어야 한다. +1. Given 유효한 daemon-bound service-token bearer 인증으로 `GET /v1/assets` 요청 시, When 토큰이 유효하면, Then HTTP 200과 해당 daemon에 배정된 portfolio 목록만 반환되어야 하며 다른 사용자 소유 portfolio나 다른 daemon에 배정된 portfolio는 포함되면 안 된다. +2. Given `GET /v1/assets?status=active` 요청 시, Then status가 active인 항목만 daemon-scoped 결과 안에서 반환되어야 한다. 3. Given 응답의 각 항목을 확인하면, Then asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_type, strategy_params, updated_at 필드가 모두 포함되어야 한다. 4. Given portfolio에 연결된 StrategySet이 있을 때, When strategy_params를 확인하면, Then StrategySet.config 값에서 파생된 JSON 객체여야 한다. 5. Given StrategySet config가 없거나 파싱 실패 시, Then strategy_params는 빈 객체({})를 반환하고 에러가 전파되지 않아야 한다. @@ -25,20 +26,64 @@ 7. Given 인증 없이 GET /v1/assets 요청 시, Then HTTP 401이 반환되어야 한다. 8. Given portfolio에 active_strategy_id로 연결된 StrategySet이 있을 때, Then strategy_type은 해당 StrategySet.name 값을 반환해야 한다. 9. Given portfolio에 active_strategy_id가 없거나 StrategySet 조회 실패 시, Then strategy_type은 null을 반환해야 한다. +10. Given `daemon_id`가 없는 legacy service-token으로 `GET /v1/assets` 요청 시, Then HTTP 403이 반환되어야 하고 daemon-scoped asset 목록은 비노출이어야 한다. ## Impacted Area - Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/project-structure.md (신규 라우터 파일 반영 필요) -- Modules: app/api/v1_assets.py (신규), app/services/ (assets 조회 서비스 메서드 추가), app/schemas/ (v1 assets 응답 스키마 추가), app/repositories/postgres.py (portfolio + strategy_set JOIN 쿼리 추가), app/main.py (/v1 라우터 등록) -- Tests: tests/test_v1.py (GET /v1/assets 관련 케이스 및 owner scope 회귀 검증) +- Modules: app/api/v1_assets.py, app/schemas/v1.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py, app/core/auth.py, app/main.py +- Tests: tests/test_v1.py, tests/test_portfolio_ownership.py 또는 신규 daemon assignment 회귀 테스트 ## Notes - broker_type은 portfolios.broker 컬럼을 그대로 매핑한다(m-2 finding 반영). - strategy_id는 portfolios.active_strategy_id를 그대로 반환한다. -- GET /v1/assets의 소유권 스코프는 service-token 소유자(owner_user_id)를 기준으로 한다. -- Source Discovery: dcy-002 (FR-3, A-3, OQ-1) +- `GET /v1/assets`의 최종 authorization 스코프는 service-token 소유자 범위 위에 `managed_by_daemon_id = auth.daemon_id` 조건을 추가한 daemon scope다. +- exact-one asset assignment는 `portfolios.managed_by_daemon_id`를 통해 표현되며 스키마 제약은 REQ-009 change-req에서 다룬다. +- Source Discovery: dcy-002 (FR-3, A-3, OQ-1), dcy-006 (FR-2, FR-3, NFR-2) ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- owner-user scope 기반 `/v1/assets` 요구사항을 daemon-scoped asset visibility와 fail-closed legacy token 규칙으로 갱신하고, Implemented 상태를 Approved로 되돌림 + +#### Intent +- dcy-006에서 각 asset은 정확히 1개 daemon에 배정되고, `GET /v1/assets`는 owner user 전체가 아니라 인증 daemon 범위만 반환해야 한다. 현재 구현은 `owner_user_id=auth.user_id`만 사용하므로 revised authorization model과 맞지 않는다. + +#### Acceptance Criteria Delta +- Added: + - AC-10: daemon-less legacy token fail-closed(403) +- Updated: + - AC-1, AC-2: owner scope를 daemon scope 기준으로 재정의 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `docs/interface-contract.md`, `docs/runtime-flows.md` — daemon-scoped asset list contract 반영 필요 +- Modules: + - `app/api/v1_assets.py`, `app/schemas/v1.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`, `app/core/auth.py` +- Tests: + - `tests/test_v1.py`, daemon assignment 관련 신규 회귀 테스트 + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate; REL-001 released input) — owner-user scope verification evidence는 daemon-scoped visibility 기준으로 전면 재검증 필요 +- Releases: + - REL-001 (released) — released contract는 owner-user scope 기준이라 revised REQ와 불일치 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes +- Status Recommendation: rollback-to-approved +- Follow-up Action: dcy-006 후속 batch에서 daemon assignment query, 403 fail-closed path, daemon-scoped regression test를 구현한다. + +#### Validation Plan +- `app/api/v1_assets.py`가 현재 `owner_user_id=auth.user_id`만 적용하는지 확인하고, daemon-aware query 설계를 REQ-009와 함께 정리한다. +- 구현 후 daemon A/B 분리 시나리오와 daemon-less token 403 시나리오를 targeted pytest로 검증한다. + ### CHG-20260511-02 - Date: 2026-05-11 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-007_v1-user-info-endpoint.md b/docs/srs/Interface/req-007_v1-user-info-endpoint.md index 6e0a1f4..2d0b4f5 100644 --- a/docs/srs/Interface/req-007_v1-user-info-endpoint.md +++ b/docs/srs/Interface/req-007_v1-user-info-endpoint.md @@ -1,43 +1,88 @@ # req-007: GET /v1/user_info 엔드포인트 구현 -- Status: Implemented +- Status: Approved - Type: Interface - Priority: High - Owner: repository-maintainers ## Intent - trex-basic 데몬이 사용자 거래 가능 여부와 브로커 인증 정보를 조회할 수 있도록 한다. -- runtime_id는 인증 service-token에 연결된 값을 반환하며, environment 필드는 포함하지 않는다(NFR-1). +- `runtime_id`는 인증 service-token의 런타임 세대 식별자이고, `daemon_id`는 stable logical identity로 함께 반환되어야 한다. - credential_payload는 DB에서 Fernet 복호화 후 반환한다(NFR-3/REQ-010 의존). ## Requirement -- GET /v1/user_info는 service-token bearer 인증으로 runtime_id, trading_enabled(users.is_active 매핑), disabled_reason, brokers[](broker, account_ref, credential_payload, credential_version, last_rotated_at)를 반환해야 한다. +- GET /v1/user_info는 service-token bearer 인증으로 `runtime_id`, `daemon_id`, `trading_enabled`(users.is_active 매핑), `disabled_reason`, `brokers[]`(broker, account_ref, credential_payload, credential_version, last_rotated_at)를 반환해야 한다. - environment 필드를 응답에 포함하지 않는다. - credential_payload는 Fernet으로 복호화된 JSON을 반환한다. +- transition 구간의 legacy token은 `daemon_id = null`을 진단용으로 반환할 수 있어야 하며, `runtime_id`가 없는 구형 token 처리 규칙은 기존 v1 contract를 유지한다. ## Acceptance Criteria -1. Given runtime_id가 연결된 service-token으로 GET /v1/user_info 호출 시, Then HTTP 200과 응답 JSON에 runtime_id, trading_enabled, brokers[] 필드가 포함되어야 한다. +1. Given `runtime_id`가 연결된 service-token으로 `GET /v1/user_info` 호출 시, Then HTTP 200과 응답 JSON에 `runtime_id`, `daemon_id`, `trading_enabled`, `brokers[]` 필드가 포함되어야 한다. 2. Given GET /v1/user_info 응답을 확인하면, Then environment 필드가 존재하지 않아야 한다. 3. Given users.is_active가 true인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 true여야 한다. 4. Given users.is_active가 false인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 false여야 한다. 5. Given broker_credentials에 KIS credential이 저장된 사용자의 토큰으로 호출 시, When 응답의 brokers[] 항목을 확인하면, Then credential_payload가 복호화된 JSON({app_key, app_secret})으로 반환되어야 한다. 6. Given runtime_id가 없는 구형 service-token으로 호출 시, Then 설계 단계에서 확정된 오류 응답이 반환되어야 한다(R-4, 404 또는 즉시 runtime_id 생성 중 선택). 7. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다. +8. Given `daemon_id = NULL`로 backfill된 legacy service-token으로 호출 시, Then HTTP 200 응답이 유지되고 `daemon_id` 필드는 `null`로 노출되어야 한다. ## Impacted Area - Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정) -- Modules: app/api/v1_user_info.py (신규), app/domain/models.py (BrokerCredential 모델 추가), app/services/ (user_info 조회 서비스 메서드 추가), app/schemas/ (v1 user_info 응답 스키마 추가), app/repositories/postgres.py (broker_credentials 조회 추가), app/main.py (/v1 라우터 등록) -- Tests: tests/test_v1_user_info.py (신규) +- Modules: app/api/v1_user_info.py, app/schemas/v1.py, app/core/auth.py, app/domain/models.py, app/services/, app/repositories/postgres.py, app/main.py +- Tests: tests/test_v1.py, tests/test_auth.py ## Notes - trading_enabled는 users.is_active 단순 매핑이다(A-4 Confirmed). 별도 trading 전용 상태 필드는 이번 반복 Out of Scope. - disabled_reason: trading_enabled가 false일 때 이유 문자열. 이번 반복에서는 is_active가 false인 경우 "account_inactive" 고정값을 사용하는 방안을 설계 단계에서 확정한다. -- R-4(기존 service-token에 runtime_id 없는 경우 처리): 404 또는 즉시 생성 중 설계 단계에서 결정. 이 REQ의 Acceptance Criteria 6번은 설계 결정 후 구체화한다. +- `daemon_id = NULL` legacy token은 transition diagnostics 대상으로 허용되지만, daemon-scoped read/write endpoint에서는 fail-closed가 적용된다. +- R-4(기존 service-token에 runtime_id 없는 경우 처리): 404 또는 즉시 생성 중 설계 단계에서 결정. 이 REQ의 Acceptance Criteria 6번은 runtime_id 부재 처리 규칙에 한해 유지한다. - Fernet 복호화는 REQ-010에서 정의한 암호화 유틸리티를 사용한다. -- Source Discovery: dcy-002 (FR-4, NFR-1, A-4) +- Source Discovery: dcy-002 (FR-4, NFR-1, A-4), dcy-006 (FR-1, FR-5, NFR-1) ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- `/v1/user_info` 응답 요구사항에 stable `daemon_id` diagnostics를 추가하고, runtime_id-only 구현 증거를 무효화하여 Status를 Approved로 되돌림 + +#### Intent +- dcy-006은 `/v1/user_info`를 runtime 세대 식별과 daemon binding diagnostics를 함께 제공하는 endpoint로 재정의한다. 현재 구현/verification은 `runtime_id`만 기준으로 삼아 revised contract를 충족하지 못한다. + +#### Acceptance Criteria Delta +- Added: + - AC-8: legacy token의 `daemon_id = null` diagnostics +- Updated: + - AC-1: 응답 필수 필드에 `daemon_id` 추가 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md` +- Modules: + - `app/api/v1_user_info.py`, `app/schemas/v1.py`, `app/core/auth.py`, `app/repositories/postgres.py` +- Tests: + - `tests/test_v1.py`, `tests/test_auth.py` + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate; REL-001 released input) — `/v1/user_info` evidence를 daemon diagnostics 기준으로 재검증해야 함 +- Releases: + - REL-001 (released) — released smoke는 `runtime_id`만 확인했으므로 revised REQ 충족 증거로 재사용 불가 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes +- Status Recommendation: rollback-to-approved +- Follow-up Action: dcy-006 후속 batch에서 `daemon_id` schema/API contract와 legacy diagnostic behavior를 구현한다. + +#### Validation Plan +- `app/api/v1_user_info.py`, `app/schemas/v1.py` 현재 구현에 `daemon_id`가 없는지 코드 리뷰로 확인한다. +- 구현 후 daemon-bound token, legacy daemon-less token, runtime_id-missing token 시나리오를 분리해 targeted pytest를 추가한다. + ### CHG-20260511-01 - Date: 2026-05-11 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md b/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md index 9194e88..7eba332 100644 --- a/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md +++ b/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md @@ -1,6 +1,6 @@ # req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 -- Status: Implemented +- Status: Approved - Type: Interface - Priority: High - Owner: repository-maintainers @@ -8,34 +8,81 @@ ## Intent - trex-basic이 체결 내역을 portfolio-hub에 idempotent하게 전송할 수 있도록 한다. - 동일 (asset_id, order_id, execution_id) 조합의 중복 수신은 DB unique constraint로 감지하고 status=duplicate를 반환한다(NFR-2). -- 동일 키에 다른 payload가 오면 충돌로 처리한다. +- 동일 키에 다른 payload가 오면 충돌로 처리하며, daemon assignment mismatch 또는 미배정 asset write는 저장 전에 거부한다. ## Requirement -- POST /v1/assets/{asset_id}/ledger/trades는 service-token bearer 인증으로 체결 내역(execution_id, order_id, side, quantity, price, fee_amount, tax_amount, executed_at)을 ledger_trades 테이블에 저장하고 HTTP 201을 반환해야 한다. +- POST /v1/assets/{asset_id}/ledger/trades는 service-token bearer 인증으로, 인증된 token의 `daemon_id`에 배정된 asset에 대해서만 체결 내역(execution_id, order_id, side, quantity, price, fee_amount, tax_amount, executed_at)을 ledger_trades 테이블에 저장하고 HTTP 201을 반환해야 한다. - 동일 (asset_id, order_id, execution_id) 재수신은 신규 행 생성 없이 status=duplicate와 기존 ledger_entry_id를 HTTP 200으로 반환해야 한다. - 동일 키에 payload가 다른 경우 HTTP 409를 반환해야 한다. - idempotency는 DB unique constraint로 보장하며, 애플리케이션 로직만으로 보장하지 않는다. +- daemon assignment mismatch, 미배정 asset, `daemon_id = NULL` legacy token은 모두 HTTP 403으로 fail-closed 처리해야 하며 ledger row가 생성되면 안 된다. ## Acceptance Criteria -1. Given 유효한 service-token과 신규 체결 데이터로 POST /v1/assets/{asset_id}/ledger/trades 요청 시, Then HTTP 201과 생성된 ledger_entry_id가 반환되어야 한다. +1. Given 유효한 daemon-bound service-token과 해당 daemon에 배정된 asset의 신규 체결 데이터로 POST /v1/assets/{asset_id}/ledger/trades 요청 시, Then HTTP 201과 생성된 ledger_entry_id가 반환되어야 한다. 2. Given 동일 (asset_id, order_id, execution_id)와 동일 payload로 두 번째 요청 시, Then HTTP 200과 status="duplicate", 기존 ledger_entry_id가 반환되어야 하며 DB에 단일 행만 존재해야 한다. 3. Given 동일 (asset_id, order_id, execution_id)에 다른 payload로 요청 시, Then HTTP 409가 반환되어야 한다. 4. Given asset_id가 존재하지 않는 portfolio_id인 경우, Then HTTP 404가 반환되어야 한다. 5. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다. +6. Given asset이 다른 daemon에 배정되어 있는 경우, When 요청 daemon이 asset owner daemon과 다르면, Then HTTP 403이 반환되어야 하고 ledger row는 생성되지 않아야 한다. +7. Given asset이 아직 daemon에 배정되지 않았거나 토큰의 `daemon_id`가 NULL인 경우, Then HTTP 403이 반환되어야 하고 ledger row는 생성되지 않아야 한다. ## Impacted Area - Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정) -- Modules: app/api/v1_ledger.py (신규), app/domain/models.py (LedgerTrade 모델 추가), app/services/ (ledger 저장 서비스 메서드 추가), app/schemas/ (v1 ledger 요청/응답 스키마 추가), app/repositories/postgres.py (ledger_trades 저장 및 중복 감지 쿼리 추가), app/main.py (/v1 라우터 등록) -- Tests: tests/test_v1_ledger.py (신규) +- Modules: app/api/v1_ledger.py, app/domain/models.py, app/services/, app/schemas/, app/repositories/portfolio_repository.py, app/repositories/postgres.py, app/main.py +- Tests: tests/test_v1.py 또는 신규 daemon assignment / ledger authorization 회귀 테스트 ## Notes - NFR-2(idempotency DB unique constraint): (asset_id, order_id, execution_id) unique constraint는 REQ-009 migration에서 정의한다. - ledger_trades 테이블과 기존 portfolio_ledger 테이블은 별개다. portfolio_ledger는 내부 원장이고 ledger_trades는 trex-basic이 외부에서 기록하는 체결 내역이다. - 중복 감지 구현: DB unique constraint 위반(psycopg UniqueViolation) 캐치 → 기존 행 조회 → payload 동일 여부 비교 → duplicate 또는 409 응답. -- Source Discovery: dcy-002 (FR-5, NFR-2) +- daemon authorization은 기존 permission(`trade-write`) 위에 추가되는 두 번째 authorization 축이다. +- Source Discovery: dcy-002 (FR-5, NFR-2), dcy-006 (FR-2, FR-4, NFR-2) ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- ledger write REQ에 daemon assignment authorization과 403 fail-closed 규칙을 추가하고, 기존 Implemented 상태를 Approved로 되돌림 + +#### Intent +- dcy-006은 `trade-write` permission만으로는 충분하지 않고, asset이 인증 daemon에 배정됐는지까지 검증하도록 요구한다. 현재 구현은 asset 존재성만 확인하므로 revised authorization contract를 충족하지 못한다. + +#### Acceptance Criteria Delta +- Added: + - AC-6: daemon mismatch 403 + - AC-7: 미배정 asset 또는 daemon-less token 403 +- Updated: + - AC-1: 성공 조건을 daemon-assigned asset으로 제한 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md` +- Modules: + - `app/api/v1_ledger.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` +- Tests: + - `tests/test_v1.py` 또는 신규 daemon assignment ledger authorization 테스트 + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate; REL-001 released input) — ledger verification evidence는 404/409/idempotency 중심이라 daemon authz 케이스를 추가해 재검증해야 함 +- Releases: + - REL-001 (released) — released trade-write contract는 daemon mismatch 403 정책을 포함하지 않음 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes +- Status Recommendation: rollback-to-approved +- Follow-up Action: dcy-006 후속 batch에서 asset assignment lookup과 ledger authorization guard를 구현하고 duplicate/conflict 회귀와 함께 재검증한다. + +#### Validation Plan +- `app/api/v1_ledger.py`가 현재 asset 존재성만 확인하는지 코드 리뷰로 확인한다. +- 구현 후 success/duplicate/conflict/404와 daemon mismatch·unassigned 403 시나리오를 함께 검증한다. + ### CHG-20260511-01 - Date: 2026-05-11 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-012_service-token-rotate-cli.md b/docs/srs/Interface/req-012_service-token-rotate-cli.md index 47ca4ac..475e540 100644 --- a/docs/srs/Interface/req-012_service-token-rotate-cli.md +++ b/docs/srs/Interface/req-012_service-token-rotate-cli.md @@ -1,6 +1,6 @@ # req-012: service-token rotate CLI 명령 구현 -- Status: Implemented +- Status: Approved - Type: Interface - Priority: Medium - Owner: repository-maintainers @@ -9,23 +9,23 @@ - 발급된 service-token의 원문이 노출되었거나 주기적 교체가 필요한 경우, token을 revoke 없이 in-place로 갱신할 수 있는 CLI 명령이 필요하다. - 기존 `revoke → re-issue` 방식은 `token_id`, `user_id`, `permission`, `expires_at`, `created_at` 등 메타데이터가 변경되므로, 운영 시스템에서 동일한 이름으로 참조하는 통합 구성을 다시 설정해야 하는 불편이 있다. -- rotate는 `token_hash`, `token_prefix`, `runtime_id`만 교체하고 나머지 메타데이터를 유지하여, 동일한 token 이름과 메타데이터를 유지한 채 새 token 원문만 배포할 수 있게 한다. 단, `runtime_id`를 소비하는 시스템은 rotate 후 새 토큰 기준으로 runtime_id를 재조회해야 한다. +- rotate는 `token_hash`, `token_prefix`, `runtime_id`만 교체하고 `daemon_id`를 포함한 나머지 메타데이터를 유지하여, 동일한 token 이름과 daemon binding을 유지한 채 새 token 원문만 배포할 수 있게 한다. 단, `runtime_id`를 소비하는 시스템은 rotate 후 새 토큰 기준으로 runtime_id를 재조회해야 한다. ## Requirement - `pfhub service-token rotate [--force]` CLI 명령을 구현한다. -- rotate는 기존 auth_token 행의 `token_hash`, `token_prefix`, `runtime_id`를 새 값으로 UPDATE한다. -- `id`, `user_id`, `name`, `permission`, `token_type`, `expires_at`, `created_at`은 변경하지 않는다. +- rotate는 기존 auth_token 행의 `token_hash`, `token_prefix`, `runtime_id`를 새 값으로 UPDATE하고, `daemon_id`는 유지해야 한다. +- `id`, `user_id`, `name`, `permission`, `token_type`, `expires_at`, `created_at`, `daemon_id`는 변경하지 않는다. - rotate 대상 token이 존재하지 않으면 오류를 반환한다. - rotate 대상 token이 이미 revoked 상태이면 오류를 반환한다. - rotate 대상 token의 `expires_at`이 NULL이 아닌 경우(만료일이 설정된 토큰) 오류를 반환한다. rotate는 `--no-expiry`로 발급된 영구 토큰에만 허용된다. - `--force` 없이 실행하면 확인 프롬프트를 표시한다. -- rotate 성공 시 새 token 원문을 1회 출력하고, 이후 list/조회에서는 token_prefix만 표시된다. +- rotate 성공 시 새 token 원문을 1회 출력하고, 이후 list/조회에서는 token_prefix만 표시된다. rotate 출력 메타데이터에는 `daemon_id`가 포함되어야 한다. ## Acceptance Criteria -1. Given `expires_at IS NULL`인 유효한 service-token 이름, When `pfhub service-token rotate --force`를 실행, Then DB 행의 `token_hash`, `token_prefix`, `runtime_id`가 새 값으로 변경되고 `id`, `permission`, `expires_at`(NULL)은 기존과 동일하다. -2. Given rotate 성공, When CLI 출력, Then 새 token 원문이 화면에 1회 출력되고 "이 토큰은 지금 1회만 표시" 안내 메시지가 함께 표시된다. +1. Given `expires_at IS NULL`인 유효한 service-token 이름, When `pfhub service-token rotate --force`를 실행, Then DB 행의 `token_hash`, `token_prefix`, `runtime_id`가 새 값으로 변경되고 `id`, `permission`, `expires_at`(NULL), `daemon_id`는 기존과 동일하다. +2. Given rotate 성공, When CLI 출력, Then 새 token 원문이 화면에 1회 출력되고 "이 토큰은 지금 1회만 표시" 안내 메시지와 함께 `daemon_id` 메타데이터가 표시되어야 한다. 3. Given 존재하지 않는 token 이름, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. 4. Given revoked 상태의 token, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. 5. Given `expires_at`이 NULL이 아닌(만료일이 설정된) token, When `rotate`, Then "만료일이 설정된 토큰은 rotate할 수 없습니다" 오류를 출력하고 종료 코드 1을 반환한다. @@ -38,22 +38,66 @@ - `app/repositories/portfolio_repository.py` — `rotate_service_token` Protocol stub 추가 - `app/repositories/postgres.py` — `rotate_service_token` UPDATE 구현 추가 - `app/services/auth_service.py` — `rotate_service_token(name)` 서비스 메서드 추가 - - `app/cli/commands/service_tokens.py` — `rotate` 명령 추가 + - `app/cli/commands/service_tokens.py` — `rotate` 명령 및 `daemon_id` 출력 추가 - `tests/test_cli.py` — stub repo에 `rotate_service_token` 추가 및 테스트 케이스 추가 - Tests: - - `tests/test_cli.py`: rotate 성공, 없는 이름, revoked 상태 3개 케이스 + - `tests/test_cli.py`: rotate 성공, 없는 이름, revoked 상태, `daemon_id` 유지/출력 케이스 ## Notes - `expires_at`은 rotate 시 변경하지 않는다. 만료일 변경이 필요하면 revoke + re-issue를 사용한다. - rotate는 `--no-expiry`로 발급된 영구 토큰(`expires_at IS NULL`)에만 허용된다. 만료일이 설정된 토큰은 rotate 대상이 아니다. 해당 토큰은 만료 후 re-issue하는 것이 권장 패턴이다. -- rotate는 `runtime_id`도 새 값으로 갱신한다. trex-basic 등 `runtime_id`를 소비하는 시스템은 rotate 후 새 토큰으로 다시 인증하고 `GET /v1/user_info`를 재호출해 새 `runtime_id`를 재인식해야 한다. +- rotate는 `runtime_id`도 새 값으로 갱신하지만 `daemon_id`는 유지한다. trex-basic 등 `runtime_id`를 소비하는 시스템은 rotate 후 새 토큰으로 다시 인증하고 `GET /v1/user_info`를 재호출해 새 `runtime_id`를 재인식해야 한다. - 기존 `runtime_id`를 캐싱한 상태로 계속 사용하면 consumer 식별 불일치가 발생할 수 있다. rotate는 "토큰 원문 교체 시 token name과 메타데이터를 유지한다"는 의미이지, `runtime_id`까지 불변이라는 뜻은 아니다. +- rotate는 daemon provisioning 경로가 아니며, unknown daemon 자동 생성/업서트를 수행하면 안 된다. - DB 스키마 변경(migration)은 없다. 기존 `auth_tokens` 테이블의 기존 컬럼만 UPDATE한다. - 구조/인터페이스/런타임 흐름 변경 없음 → minor-change fast path 적용. ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- rotate REQ에 `daemon_id` 불변식과 CLI diagnostic 출력 요구를 추가하고, 기존 Implemented 상태를 Approved로 되돌림 + +#### Intent +- dcy-006은 rotate 이후에도 `daemon_id`가 stable identity로 유지되어야 하고, 운영자는 rotate 출력과 provenance 절차에서 `daemon_id`를 확인할 수 있어야 한다. BAT-002/REL-002 구현은 runtime_id lifecycle만 다루므로 revised REQ를 충족하지 못한다. + +#### Acceptance Criteria Delta +- Added: + - none +- Updated: + - AC-1: `daemon_id` 유지 불변식 추가 + - AC-2: CLI output에 `daemon_id` metadata 추가 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `README.md`, token provenance 문서, `docs/runtime-flows.md` +- Modules: + - `app/cli/commands/service_tokens.py`, `app/services/auth_service.py`, `app/repositories/postgres.py` +- Tests: + - `tests/test_cli.py`, 필요한 경우 auth/v1 provenance 회귀 테스트 + +#### Delivery Trace Delta +- Batches: + - BAT-002 (release-candidate; REL-002 released input) — rotate verification evidence는 `daemon_id` 유지/출력 기준으로 재검증 필요 +- Releases: + - REL-002 (released) — rotate 운영 메모는 runtime_id 재조회만 다루며 daemon-aware provenance는 미반영 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes +- Status Recommendation: rollback-to-approved +- Follow-up Action: dcy-006 후속 batch에서 rotate response metadata와 daemon binding invariants를 구현하고, REL-002 후속 tooling release evidence를 갱신한다. + +#### Validation Plan +- current CLI/service/repository 구현이 `daemon_id`를 보존·출력하지 않는지 코드 리뷰로 확인한다. +- 구현 후 rotate 전후 `daemon_id` 불변, `runtime_id` 갱신, CLI output metadata를 targeted CLI 테스트로 검증한다. + ### CHG-20260511-02 - Date: 2026-05-11 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-023_daemon-registry-and-service-token-daemon-binding.md b/docs/srs/Interface/req-023_daemon-registry-and-service-token-daemon-binding.md new file mode 100644 index 0000000..2a29c68 --- /dev/null +++ b/docs/srs/Interface/req-023_daemon-registry-and-service-token-daemon-binding.md @@ -0,0 +1,101 @@ +# REQ-023: Daemon Registry 관리 경로 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- service-token issue/rotate가 기존 daemon에만 binding할 수 있으려면, daemon을 사전에 생성·수정할 수 있는 별도 관리 경로(daemon registry CRUD)가 필요하다. +- daemon provisioning은 service-token issuance와 분리된 경로에서 수행되어야 하며, REQ-005(issuance binding)와 REQ-012(rotate daemon_id 유지)의 전제 조건이 된다. + +## Requirement +- 시스템은 daemon을 생성·수정할 수 있는 별도 관리 경로를 제공해야 한다. +- daemon은 owner user 내에서 unique한 `daemon_slug`(lowercase kebab-case)와 `daemon_display_name`(non-empty human-readable label)을 가진다. +- 동일 owner user 내에서 중복 `daemon_slug`로 생성 시 명시적으로 거부한다. +- daemon list 조회는 owner user 범위로 제한된다. + +## Acceptance Criteria +1. Given admin context에서 유효한 `daemon_slug`와 `daemon_display_name`으로 daemon create를 호출할 때, Then 새 daemon entry가 registry에 생성되고 `daemon_id`가 반환된다. +2. Given 기존 daemon에 대해 `daemon_display_name` 수정을 요청할 때, Then daemon entry가 갱신된다. +3. Given 동일 owner user 내에 이미 존재하는 `daemon_slug`로 daemon create를 요청할 때, Then 명시적 오류로 거부되고 중복 entry가 생성되지 않는다. +4. Given daemon list 조회를 요청할 때, Then 인증된 user 소유의 daemon만 반환된다. + +## Impacted Area +- Docs: `docs/interface-contract.md`, `docs/runtime-flows.md` +- Modules: `app/domain/models.py`, `app/repositories/postgres.py`, `app/api/auth_routes.py` 또는 별도 daemon management route, `app/cli/commands/` (daemon subcommand 신규) +- Tests: 신규 `tests/test_daemon_registry.py` + +## Notes +- service-token issuance의 unknown daemon 거부(REQ-005 AC-6), rotate daemon_id 유지 불변식(REQ-012 CHG-20260513-01)은 이 REQ의 daemon registry가 사전 구축된 상태를 전제한다. +- daemon registry CRUD의 구체적인 transport 형태(HTTP endpoint vs CLI subcommand)는 구현 시 결정한다. +- canonical 운영 표기: `/` + +## Change Log + +### CHG-20260513-02 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- Status: Proposed → Approved (confirm-req 게이트 통과) + +#### Intent +- 필수 필드, AC, blocker 없음 확인 후 승인 전환 + +#### Acceptance Criteria Delta +- Added: 없음 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: 없음 +- Modules: 없음 +- Tests: 없음 + +#### Delivery Trace Delta +- Batches: 미정 +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: 없음 + +#### Validation Plan +- 없음 (상태 전환만) + +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- dcy-006 FR-1의 daemon provisioning 관리 경로 부분만 추출해 신규 생성 (issuance binding·rotate invariant·fail-closed는 REQ-005/012/006/008에 이미 반영) + +#### Intent +- service-token issuance(REQ-005)와 rotate(REQ-012)의 전제가 되는 daemon registry CRUD 경로를 별도 REQ로 정의 + +#### Acceptance Criteria Delta +- Added: + - AC-1~4 전체 신규 추가 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: `docs/interface-contract.md`, `docs/runtime-flows.md` +- Modules: daemon registry route, CLI subcommand +- Tests: `tests/test_daemon_registry.py` + +#### Delivery Trace Delta +- Batches: 미정 +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: 없음 (신규 REQ) + +#### Validation Plan +- pytest로 daemon create/duplicate reject/update/list 시나리오 통과 확인 diff --git a/docs/srs/Migration/req-009_v1-api-db-schema-migration.md b/docs/srs/Migration/req-009_v1-api-db-schema-migration.md index 68447e5..e99dc1e 100644 --- a/docs/srs/Migration/req-009_v1-api-db-schema-migration.md +++ b/docs/srs/Migration/req-009_v1-api-db-schema-migration.md @@ -1,12 +1,13 @@ # req-009: v1 API 지원을 위한 DB 스키마 migration 추가 -- Status: Implemented +- Status: Approved - Type: Migration - Priority: High - Owner: repository-maintainers ## Intent - dcy-002에서 요구하는 4개 DB 변경(portfolios.market 컬럼, auth_tokens.runtime_id 컬럼, broker_credentials 테이블, ledger_trades 테이블)을 Alembic revision으로 구현한다. +- dcy-006의 daemon-aware authorization 모델을 위해 `daemons` registry, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`, transition backfill 및 관련 제약을 같은 migration ownership 규칙 안에서 확장한다. - IF NOT EXISTS 패턴을 유지해 REQ-002 정책을 준수한다(NFR-4). ## Requirement @@ -15,6 +16,9 @@ 2. auth_tokens 테이블에 runtime_id TEXT UNIQUE NULLABLE 컬럼 추가 3. broker_credentials 테이블 신규 생성 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at 포함) 4. ledger_trades 테이블 신규 생성 (ledger_entry_id PK, asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at 포함, (asset_id, order_id, execution_id) unique constraint 포함) + 5. daemons registry 테이블 신규 생성 (`daemon_id`, `owner_user_id`, stable slug/display metadata, 생성/갱신 시각 포함) + 6. auth_tokens 테이블에 `daemon_id` NULLABLE FK 컬럼 추가, portfolios 테이블에 `managed_by_daemon_id` NULLABLE FK 컬럼 추가 + 7. transition migration은 기존 service-token과 portfolio의 daemon 관련 컬럼을 `NULL`로 backfill하고, post-cutover exact-one assignment로 수렴할 수 있도록 same-user consistency 및 invalid reference rejection을 위한 FK/check/unique 제약을 제공해야 한다. ## Acceptance Criteria 1. Given alembic upgrade head 실행 후, When portfolios 테이블을 조회하면, Then market TEXT NULLABLE 컬럼이 존재해야 한다. @@ -24,21 +28,69 @@ 5. Given (asset_id, order_id, execution_id) 동일 조합으로 ledger_trades에 두 번 삽입 시, Then UniqueViolation이 발생해야 한다. 6. Given migration을 이미 실행한 DB에 동일 migration을 재실행하면, Then 에러 없이 완료되어야 한다(IF NOT EXISTS 보장). 7. Given portfolios 테이블의 기존 행, When migration 실행 후 조회하면, Then market 값이 NULL이어야 한다(기존 데이터 보정 없음, DECIDE_MARKET_DEFAULT Answered). +8. Given alembic upgrade head 실행 후, When schema를 조회하면, Then `daemons` 테이블과 `auth_tokens.daemon_id` 컬럼이 존재해야 한다. +9. Given alembic upgrade head 실행 후, When portfolios 테이블을 조회하면, Then `managed_by_daemon_id` 컬럼이 존재하고 기존 행은 `NULL`로 backfill되어 있어야 한다. +10. Given 존재하지 않는 `daemon_id`를 auth_tokens 또는 portfolios에 바인딩하려고 시도하면, Then FK 또는 동등 제약으로 거부되어야 하며, owner user 일관성을 깨는 binding/assignment는 DB 레벨 제약 또는 동등한 무결성 장치로 방지되어야 한다. ## Impacted Area - Docs: docs/data-model.md (REQ-011에서 갱신 예정) -- Modules: migrations/versions/ (신규 migration 파일 추가), app/domain/models.py (Portfolio.market, AuthToken.runtime_id, BrokerCredential, LedgerTrade 모델 필드 추가), app/repositories/postgres.py (신규 테이블 쿼리 추가) -- Tests: tests/test_app.py 또는 migration 검증 테스트 +- Modules: migrations/versions/, app/domain/models.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py +- Tests: tests/test_app.py 또는 migration 검증 테스트, daemon schema integrity 검증 ## Notes - NFR-4(IF NOT EXISTS 패턴) 준수 — REQ-002 Alembic migration 정책 기반. - broker_credentials 테이블의 encrypted_payload 컬럼은 Fernet 암호화된 ciphertext를 TEXT로 저장한다. 암복호화 로직은 REQ-010 참조. - 컬럼 상세 타입(NUMERIC vs FLOAT, TIMESTAMPTZ 등)은 설계 단계(draft-batch-design)에서 확정한다. -- migration revision ID는 구현 시 기존 chain의 마지막 revision(20260423_0013)을 down_revision으로 참조한다. -- Source Discovery: dcy-002 (영향 범위, NFR-4, DECIDE_MARKET_DEFAULT) +- daemon exact-one assignment의 steady-state `NOT NULL`/강제 cutover는 inventory/backfill 완료 이후 단계적으로 적용할 수 있다. transition migration은 `NULL` backfill을 허용하되 fail-open을 허용하면 안 된다. +- migration revision ID는 구현 시 기존 revision chain의 마지막 head를 down_revision으로 참조한다. +- Source Discovery: dcy-002 (영향 범위, NFR-4, DECIDE_MARKET_DEFAULT), dcy-006 (FR-1, FR-2, NFR-2, NFR-3) ## Change Log +### CHG-20260513-01 +- Date: 2026-05-13 +- Author: repository-maintainers + +#### Change Summary +- runtime_id 중심 v1 migration REQ를 daemon registry/binding/assignment 스키마까지 포함하도록 확장하고, 기존 Implemented 상태를 Approved로 되돌림 + +#### Intent +- dcy-006의 daemon-aware model은 기존 REQ-009 구현 범위를 넘어서 `daemons`, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`, transition backfill, same-user consistency 제약을 추가로 요구한다. BAT-001/REL-001 migration evidence는 이 범위를 포함하지 않는다. + +#### Acceptance Criteria Delta +- Added: + - AC-8: `daemons`, `auth_tokens.daemon_id` + - AC-9: `portfolios.managed_by_daemon_id` + NULL backfill + - AC-10: daemon FK / 무결성 제약 +- Updated: + - Requirement 본문에 daemon registry 및 transition backfill 항목 추가 +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - `docs/data-model.md`, `docs/runtime-flows.md` +- Modules: + - `migrations/versions/`, `app/domain/models.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` +- Tests: + - migration integrity / daemon schema constraint 검증 + +#### Delivery Trace Delta +- Batches: + - BAT-001 (release-candidate; REL-001 released input) — 기존 migration verification은 daemon schema, backfill, constraint evidence를 포함하지 않음 +- Releases: + - REL-001 (released) — release facts는 runtime_id/broker_credentials/ledger_trades까지만 다루므로 revised migration REQ를 충족하지 않음 + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes +- Status Recommendation: rollback-to-approved +- Follow-up Action: dcy-006 후속 batch에서 신규 Alembic revision과 migration validation을 설계하고, cutover/backfill evidence를 release 단계에서 수집한다. + +#### Validation Plan +- 현재 migration chain과 `app/repositories/postgres.py` schema ownership을 재검토해 daemon schema 추가 위치를 설계한다. +- 구현 후 alembic upgrade / re-run / invalid daemon reference 검증을 포함한 migration test plan을 수행한다. + ### CHG-20260511-01 - Date: 2026-05-11 - Author: repository-maintainers diff --git a/docs/srs/index.md b/docs/srs/index.md index 710054f..c6dc639 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-023` +- Current: `REQ-029` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -85,14 +85,14 @@ | REQ-002 | Alembic와 runtime schema ownership 경계 정리 | Migration | Implemented | High | repository-maintainers | [REQ-002](./Migration/req-002_alembic-runtime-schema-ownership.md) | | REQ-003 | 로컬 DB 운영 runbook 및 baseline 문서 갱신 | Documentation | Implemented | Medium | repository-maintainers | [REQ-003](./Documentation/req-003_local-db-operations-runbook-and-baseline-updates.md) | | REQ-004 | GET /health 응답 스키마 확장 | Interface | Implemented | High | repository-maintainers | [REQ-004](./Interface/req-004_health-response-schema-extension.md) | -| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Implemented | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) | -| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) | -| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) | -| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) | -| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Implemented | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) | +| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Approved | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) | +| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) | +| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) | +| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) | +| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Approved | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) | | REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Implemented | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) | -| REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Implemented | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) | -| REQ-012 | service-token rotate CLI 명령 구현 | Interface | Implemented | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) | +| REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) | +| REQ-012 | service-token rotate CLI 명령 구현 | Interface | Approved | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) | | REQ-013 | CLI `portfolio list --user` 필수화 및 소유자 기반 조회 | Interface | Implemented | Medium | repository-maintainers | [REQ-013](./Interface/req-013_portfolio-list-user-required.md) | | REQ-014 | CLI `portfolio create --user` 필수화 및 소유자 설정 | Interface | Implemented | Medium | repository-maintainers | [REQ-014](./Interface/req-014_portfolio-create-user-required.md) | | REQ-015 | CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) | Interface | Implemented | Medium | repository-maintainers | [REQ-015](./Interface/req-015_broker-credential-cli.md) | @@ -103,13 +103,13 @@ | REQ-020 | 실배포 admin bootstrap 및 steady-state operator access runbook | Deployment | Approved | High | repository-maintainers | [REQ-020](./Deployment/req-020_production-admin-bootstrap-and-operator-access-runbook.md) | | REQ-021 | release 검증 credential matrix 및 token provenance 운영 절차 | Documentation | Approved | High | repository-maintainers | [REQ-021](./Documentation/req-021_release-credential-matrix-and-token-provenance-procedures.md) | | REQ-022 | 운영 로그 접근 및 release evidence 기록 절차 | Documentation | Approved | Medium | repository-maintainers | [REQ-022](./Documentation/req-022_operator-log-access-and-release-evidence-recording.md) | +| REQ-023 | Daemon Registry 관리 경로 | Interface | Approved | High | repository-maintainers | [REQ-023](./Interface/req-023_daemon-registry-and-service-token-daemon-binding.md) | +| REQ-028 | README.md Daemon Provisioning Runbook 반영 | Documentation | Approved | Medium | repository-maintainers | [REQ-028](./Documentation/req-028_daemon-model-baseline-docs-update.md) | ## Recent Change Log Summary -- 2026-05-12: REQ-020~022 Approved 전환 — confirm-req 게이트 통과 (필수 필드, AC, blocker 없음) -- 2026-05-12: REQ-020~022 Proposed 생성 — dcy-005 (실배포 admin bootstrap 및 operator access) FR-1~3, NFR-1~3 기반 -- 2026-05-11: REQ-011, REQ-012, REQ-015 Implemented 전환 — BAT-008/BAT-002/BAT-005 verification evidence 확인 -- 2026-05-11: REQ-005 CHG-20260511-02 — service-token 발급 API 경로를 POST /auth/service-tokens로 명확화 -- 2026-05-11: REQ-012 CHG-20260511-01 — rotate 후 runtime_id lifecycle 및 consumer 재조회 필요성 명시 -- 2026-05-11: REQ-001~003 Implemented 전환 — BAT-007 verification passed, confirm-req-implemented 게이트 통과 -- 2026-05-11: REQ-006 CHG-20260511-02 — GET /v1/assets owner scope 회귀 수정 및 BAT-001 verification 정합화 \ No newline at end of file +- 2026-05-13: REQ-023, REQ-028 Approved 전환 — confirm-req 게이트 통과 (필수 필드, AC, blocker 없음) +- 2026-05-13: REQ-023, REQ-028 Proposed 생성 — dcy-006 FR-1(daemon registry CRUD)·FR-6(README.md runbook); REQ-024~027은 REQ-005~009/011/012 CHG-20260513-01과 완전 중복으로 미생성 +- 2026-05-13: REQ-005~009, REQ-011~012 CHG-20260513-01 — dcy-006 daemon_id / exact-one asset assignment 반영으로 revised contract가 기존 Implemented evidence를 무효화해 Status를 Approved로 되돌림 +- 2026-05-13: REQ-021 CHG-20260513-01 — token provenance 절차에 `daemon_id` evidence 필드와 rotate invariant 반영, Status 유지 +- 2026-05-12: REQ-020~022 Approved 전환 — confirm-req 게이트 통과 (필수 필드, AC, blocker 없음) \ No newline at end of file diff --git a/migrations/versions/20260513_0016_daemon_model.py b/migrations/versions/20260513_0016_daemon_model.py new file mode 100644 index 0000000..f7e7e24 --- /dev/null +++ b/migrations/versions/20260513_0016_daemon_model.py @@ -0,0 +1,69 @@ +"""Daemon model: daemons table, auth_tokens.daemon_id, portfolios.managed_by_daemon_id + +Revision ID: 20260513_0016 +Revises: 20260511_0015 +Create Date: 2026-05-13 00:00:00 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "20260513_0016" +down_revision = "20260511_0015" +branch_labels = None +depends_on = None + + +def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool: + if not _table_exists(inspector, table_name): + return False + return any(c["name"] == column_name for c in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + # Step 1: Create daemons table + if not _table_exists(inspector, "daemons"): + op.create_table( + "daemons", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("owner_user_id", sa.Text(), nullable=False), + sa.Column("daemon_slug", sa.Text(), nullable=False), + sa.Column("display_name", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("owner_user_id", "daemon_slug", name="uq_daemons_owner_slug"), + ) + + # Step 2: Add auth_tokens.daemon_id (nullable FK to daemons.id, no DB-level FK constraint — soft reference) + if not _column_exists(inspector, "auth_tokens", "daemon_id"): + op.add_column("auth_tokens", sa.Column("daemon_id", sa.Text(), nullable=True)) + + # Step 3: Add portfolios.managed_by_daemon_id (nullable soft reference to daemons.id) + if not _column_exists(inspector, "portfolios", "managed_by_daemon_id"): + op.add_column("portfolios", sa.Column("managed_by_daemon_id", sa.Text(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _column_exists(inspector, "portfolios", "managed_by_daemon_id"): + op.drop_column("portfolios", "managed_by_daemon_id") + + if _column_exists(inspector, "auth_tokens", "daemon_id"): + op.drop_column("auth_tokens", "daemon_id") + + if _table_exists(inspector, "daemons"): + op.drop_table("daemons") diff --git a/tests/test_auth.py b/tests/test_auth.py index 5f7980a..16db392 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -99,6 +99,7 @@ class _AuthRepo: token_type: str, expires_at: datetime | None = None, runtime_id: str | None = None, + daemon_id: str | None = None, ) -> AuthToken: token = AuthToken( id=f"token-{self._seq}", @@ -110,6 +111,7 @@ class _AuthRepo: expires_at=expires_at, created_at=datetime.now(timezone.utc), runtime_id=runtime_id, + daemon_id=daemon_id, ) self._seq += 1 self.tokens[token.id] = token diff --git a/tests/test_cli.py b/tests/test_cli.py index adb69dd..f5e8c30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -123,6 +123,7 @@ class FakeRepo: token_type: str, expires_at: datetime | None = None, runtime_id: str | None = None, + daemon_id: str | None = None, ) -> AuthToken: token = AuthToken( id=f"token-{self._token_seq}", @@ -134,6 +135,7 @@ class FakeRepo: expires_at=expires_at, created_at=datetime.now(timezone.utc), runtime_id=runtime_id, + daemon_id=daemon_id, ) self._token_seq += 1 self._auth_tokens[token.id] = token diff --git a/tests/test_daemon_assignment.py b/tests/test_daemon_assignment.py new file mode 100644 index 0000000..f1a8672 --- /dev/null +++ b/tests/test_daemon_assignment.py @@ -0,0 +1,248 @@ +"""Tests for daemon assignment authorization on v1 endpoints (REQ-006, REQ-007, REQ-008).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.core.auth import AuthContext, Permission +from app.core.config import Settings, get_settings +from app.domain.models import Portfolio, TokenPrincipal, User +from app.main import create_app + + +def _make_settings(**kwargs) -> Settings: + return Settings(allowed_origins_raw="http://localhost:3000", **kwargs) + + +def _svc_header(token: str = "svc-token") -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _app_with_db(monkeypatch) -> TestClient: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + get_settings.cache_clear() + s = _make_settings() + app = create_app(settings_factory=lambda: s) + app.dependency_overrides[get_settings] = lambda: s + return TestClient(app) + + +def _principal(daemon_id: str | None = "d-1") -> TokenPrincipal: + return TokenPrincipal( + token_id="t1", user_id="user-1", permission="trade-write", + token_type="service", runtime_id="rt-1", daemon_id=daemon_id, + ) + + +def _portfolio_with_daemon(daemon_id: str | None = "d-1") -> MagicMock: + p = MagicMock(spec=Portfolio) + p.managed_by_daemon_id = daemon_id + p.portfolio_id = "asset-1" + return p + + +def _ledger_trade_payload() -> dict: + return { + "order_id": "ord-1", + "execution_id": "exec-1", + "side": "buy", + "quantity": "10", + "price": "50000", + "fee_amount": "0", + "tax_amount": "0", + "executed_at": "2026-05-13T10:00:00+09:00", + } + + +# --------------------------------------------------------------------------- +# REQ-006: GET /v1/assets daemon-scoped + NULL fail-closed +# --------------------------------------------------------------------------- + +def test_assets_403_when_token_has_no_daemon_id(monkeypatch) -> None: + client = _app_with_db(monkeypatch) + principal = _principal(daemon_id=None) + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.get("/v1/assets", headers=_svc_header()) + + assert resp.status_code == 403 + assert "daemon_id" in resp.json()["detail"] + + +def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None: + client = _app_with_db(monkeypatch) + principal = _principal(daemon_id="d-1") + portfolio = Portfolio( + portfolio_id="p1", broker="kis", status="enabled", name="T", + symbol="005930", market="KRW", cash_balance=0.0, asset_quantity=0.0, + price=0.0, profit_buffer=0.0, owner_user_id="user-1", managed_by_daemon_id="d-1", + ) + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_portfolios_with_strategies.return_value = [portfolio] + mock_repo.get_strategy_set.return_value = None + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.get("/v1/assets", headers=_svc_header()) + + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + mock_repo.list_portfolios_with_strategies.assert_called_once_with( + owner_user_id="user-1", + include_disabled=False, + managed_by_daemon_id="d-1", + ) + + +# --------------------------------------------------------------------------- +# REQ-007: GET /v1/user_info daemon_id field + NULL diagnostic +# --------------------------------------------------------------------------- + +def test_user_info_returns_daemon_id_in_response(monkeypatch) -> None: + client = _app_with_db(monkeypatch) + principal = _principal(daemon_id="d-1") + user = User(id="user-1", username="alice", permission="read", is_active=True) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.get("/v1/user_info", headers=_svc_header()) + + assert resp.status_code == 200 + assert resp.json()["daemon_id"] == "d-1" + + +def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None: + client = _app_with_db(monkeypatch) + principal = _principal(daemon_id=None) + user = User(id="user-1", username="alice", permission="read", is_active=True) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.get("/v1/user_info", headers=_svc_header()) + + assert resp.status_code == 200 + assert resp.json()["daemon_id"] is None + + +# --------------------------------------------------------------------------- +# REQ-008: POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization +# --------------------------------------------------------------------------- + +def _setup_ledger_mocks(monkeypatch, principal: TokenPrincipal, portfolio: MagicMock, trade: MagicMock) -> tuple[MagicMock, TestClient]: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + get_settings.cache_clear() + s = _make_settings(credential_encryption_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + app = create_app(settings_factory=lambda: s) + app.dependency_overrides[get_settings] = lambda: s + client = TestClient(app) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get.return_value = portfolio + mock_repo.insert_ledger_trade.return_value = (trade, True) + return mock_repo, client + + +def test_ledger_trade_403_when_token_has_no_daemon_id(monkeypatch) -> None: + from app.domain.models import LedgerTrade + principal = _principal(daemon_id=None) + portfolio = _portfolio_with_daemon(daemon_id="d-1") + trade = MagicMock(spec=LedgerTrade) + mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.post( + "/v1/assets/asset-1/ledger/trades", + json=_ledger_trade_payload(), + headers=_svc_header(), + ) + + assert resp.status_code == 403 + assert "daemon_id" in resp.json()["detail"] + + +def test_ledger_trade_403_when_asset_has_no_daemon_assignment(monkeypatch) -> None: + from app.domain.models import LedgerTrade + principal = _principal(daemon_id="d-1") + portfolio = _portfolio_with_daemon(daemon_id=None) + trade = MagicMock(spec=LedgerTrade) + mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.post( + "/v1/assets/asset-1/ledger/trades", + json=_ledger_trade_payload(), + headers=_svc_header(), + ) + + assert resp.status_code == 403 + assert "not assigned" in resp.json()["detail"] + + +def test_ledger_trade_403_when_daemon_mismatch(monkeypatch) -> None: + from app.domain.models import LedgerTrade + principal = _principal(daemon_id="d-1") + portfolio = _portfolio_with_daemon(daemon_id="d-2") + trade = MagicMock(spec=LedgerTrade) + mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.post( + "/v1/assets/asset-1/ledger/trades", + json=_ledger_trade_payload(), + headers=_svc_header(), + ) + + assert resp.status_code == 403 + assert "mismatch" in resp.json()["detail"].lower() or "daemon" in resp.json()["detail"].lower() + + +def test_ledger_trade_201_when_daemon_matches(monkeypatch) -> None: + from app.domain.models import LedgerTrade + principal = _principal(daemon_id="d-1") + portfolio = _portfolio_with_daemon(daemon_id="d-1") + trade = MagicMock() + trade.ledger_entry_id = 1 + trade.asset_id = "asset-1" + trade.order_id = "ord-1" + trade.execution_id = "exec-1" + trade.side = "buy" + trade.quantity = 10.0 + trade.price = 50000.0 + trade.fee_amount = 0.0 + trade.tax_amount = 0.0 + trade.executed_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc) + trade.recorded_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc) + mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + resp = client.post( + "/v1/assets/asset-1/ledger/trades", + json=_ledger_trade_payload(), + headers=_svc_header(), + ) + + assert resp.status_code == 201 + assert resp.json()["status"] == "new" diff --git a/tests/test_daemon_registry.py b/tests/test_daemon_registry.py new file mode 100644 index 0000000..7e3db76 --- /dev/null +++ b/tests/test_daemon_registry.py @@ -0,0 +1,286 @@ +"""Tests for daemon registry CRUD and service-token daemon binding.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +import pytest + +from app.domain.models import AuthToken, Daemon, User +from app.repositories.postgres import PostgresPortfolioRepository +from app.services.auth_service import AuthService +from app.core.config import Settings + + +# --------------------------------------------------------------------------- +# Minimal in-memory repo for daemon registry tests +# --------------------------------------------------------------------------- + +@dataclass +class _DaemonRepo: + def __post_init__(self) -> None: + self._users: dict[str, User] = {} + self._tokens: dict[str, AuthToken] = {} + self._daemons: dict[str, Daemon] = {} + self._daemons_by_slug: dict[tuple[str, str], Daemon] = {} + self._seq = 1 + self._daemon_seq = 1 + + def get_user_by_username(self, username: str) -> User | None: + return next((u for u in self._users.values() if u.username == username), None) + + def get_user(self, user_id: str) -> User: + u = self._users.get(user_id) + if u is None: + raise ValueError("사용자를 찾을 수 없습니다") + return u + + def create_auth_token( + self, + user_id: str, + *, + name: str, + token_hash: str, + token_prefix: str, + permission: str, + token_type: str, + expires_at: datetime | None = None, + runtime_id: str | None = None, + daemon_id: str | None = None, + ) -> AuthToken: + token = AuthToken( + id=f"token-{self._seq}", + user_id=user_id, + name=name, + token_prefix=token_prefix, + permission=permission, + token_type=token_type, + expires_at=expires_at, + created_at=datetime.now(timezone.utc), + runtime_id=runtime_id, + daemon_id=daemon_id, + ) + self._seq += 1 + self._tokens[token.id] = token + return token + + def get_service_token_by_name(self, name: str) -> AuthToken | None: + return next((t for t in self._tokens.values() if t.name == name and t.token_type == "service"), None) + + def revoke_auth_token(self, user_id: str, token_id: str) -> None: + token = self._tokens.get(token_id) + if token is None or token.user_id != user_id: + raise ValueError("토큰을 찾을 수 없습니다") + token.revoked_at = datetime.now(timezone.utc) + + def get_daemon_by_id(self, daemon_id: str) -> Daemon | None: + return self._daemons.get(daemon_id) + + def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None: + return self._daemons_by_slug.get((owner_user_id, daemon_slug)) + + def create_daemon(self, owner_user_id: str, *, daemon_slug: str, display_name: str | None = None) -> Daemon: + key = (owner_user_id, daemon_slug) + if key in self._daemons_by_slug: + raise ValueError(f"이미 존재하는 daemon slug입니다: {daemon_slug}") + daemon = Daemon( + id=f"daemon-{self._daemon_seq}", + owner_user_id=owner_user_id, + daemon_slug=daemon_slug, + display_name=display_name, + is_active=True, + created_at=datetime.now(timezone.utc), + ) + self._daemon_seq += 1 + self._daemons[daemon.id] = daemon + self._daemons_by_slug[key] = daemon + return daemon + + def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]: + return [d for d in self._daemons.values() if d.owner_user_id == owner_user_id] + + def update_daemon(self, daemon_id: str, *, display_name: str | None = None, is_active: bool | None = None) -> Daemon: + daemon = self._daemons.get(daemon_id) + if daemon is None: + raise ValueError("데몬을 찾을 수 없습니다") + updated = Daemon( + id=daemon.id, + owner_user_id=daemon.owner_user_id, + daemon_slug=daemon.daemon_slug, + display_name=display_name if display_name is not None else daemon.display_name, + is_active=is_active if is_active is not None else daemon.is_active, + created_at=daemon.created_at, + updated_at=datetime.now(timezone.utc), + ) + self._daemons[daemon_id] = updated + self._daemons_by_slug[(updated.owner_user_id, updated.daemon_slug)] = updated + return updated + + def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: + existing = self.get_service_token_by_name(name) + if existing is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + existing.revoked_at = datetime.now(timezone.utc) + import uuid as _uuid + from app.core.security import generate_token, hash_token, token_prefix as _tp + raw = generate_token() + new_token = AuthToken( + id=f"token-{self._seq}", + user_id=existing.user_id, + name=name, + token_prefix=_tp(raw), + permission=existing.permission, + token_type="service", + expires_at=existing.expires_at, + created_at=datetime.now(timezone.utc), + runtime_id=str(_uuid.uuid4()), + daemon_id=existing.daemon_id, + ) + self._seq += 1 + self._tokens[new_token.id] = new_token + return raw, new_token + + +def _repo_with_user(username: str = "alice") -> _DaemonRepo: + repo = _DaemonRepo() + repo._users["user-1"] = User(id="user-1", username=username, permission="read") + return repo + + +def _service(repo: _DaemonRepo) -> AuthService: + return AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000")) + + +# --------------------------------------------------------------------------- +# REQ-023: Daemon registry CRUD +# --------------------------------------------------------------------------- + +def test_create_daemon_returns_daemon_id() -> None: + repo = _repo_with_user() + daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot") + + assert daemon.id is not None + assert daemon.daemon_slug == "alpha-bot" + assert daemon.owner_user_id == "user-1" + assert daemon.is_active is True + + +def test_create_daemon_with_display_name() -> None: + repo = _repo_with_user() + daemon = repo.create_daemon("user-1", daemon_slug="beta-bot", display_name="Beta Trading Bot") + + assert daemon.display_name == "Beta Trading Bot" + + +def test_update_daemon_display_name() -> None: + repo = _repo_with_user() + daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot", display_name="Old Name") + + updated = repo.update_daemon(daemon.id, display_name="New Name") + + assert updated.display_name == "New Name" + assert updated.daemon_slug == "alpha-bot" + + +def test_create_daemon_duplicate_slug_raises() -> None: + repo = _repo_with_user() + repo.create_daemon("user-1", daemon_slug="alpha-bot") + + with pytest.raises(ValueError, match="alpha-bot"): + repo.create_daemon("user-1", daemon_slug="alpha-bot") + + +def test_list_daemons_scoped_to_user() -> None: + repo = _repo_with_user() + repo._users["user-2"] = User(id="user-2", username="bob", permission="read") + repo.create_daemon("user-1", daemon_slug="alice-bot") + repo.create_daemon("user-2", daemon_slug="bob-bot") + + alice_daemons = repo.list_daemons_for_user("user-1") + bob_daemons = repo.list_daemons_for_user("user-2") + + assert len(alice_daemons) == 1 + assert alice_daemons[0].daemon_slug == "alice-bot" + assert len(bob_daemons) == 1 + assert bob_daemons[0].daemon_slug == "bob-bot" + + +# --------------------------------------------------------------------------- +# REQ-005: service-token issue daemon binding +# --------------------------------------------------------------------------- + +def test_issue_service_token_with_daemon_id() -> None: + repo = _repo_with_user() + daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot") + service = _service(repo) + + result = service.issue_service_token("my-token", "alice", daemon_id=daemon.id) + + assert result.auth_token.daemon_id == daemon.id + + +def test_issue_service_token_unknown_daemon_raises() -> None: + repo = _repo_with_user() + service = _service(repo) + + with pytest.raises(ValueError, match="존재하지 않거나 비활성"): + service.issue_service_token("my-token", "alice", daemon_id="nonexistent-daemon-id") + + +def test_issue_service_token_inactive_daemon_raises() -> None: + repo = _repo_with_user() + daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot") + repo.update_daemon(daemon.id, is_active=False) + service = _service(repo) + + with pytest.raises(ValueError, match="존재하지 않거나 비활성"): + service.issue_service_token("my-token", "alice", daemon_id=daemon.id) + + +def test_issue_service_token_without_daemon_id_succeeds() -> None: + repo = _repo_with_user() + service = _service(repo) + + result = service.issue_service_token("my-token", "alice") + + assert result.auth_token.daemon_id is None + + +# --------------------------------------------------------------------------- +# REQ-012: service-token rotate daemon_id invariant +# --------------------------------------------------------------------------- + +def test_rotate_service_token_preserves_daemon_id() -> None: + repo = _repo_with_user() + daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot") + service = _service(repo) + + issued = service.issue_service_token("my-token", "alice", daemon_id=daemon.id) + original_daemon_id = issued.auth_token.daemon_id + original_runtime_id = issued.auth_token.runtime_id + + rotated = service.rotate_service_token("my-token") + + assert rotated.auth_token.daemon_id == original_daemon_id + assert rotated.auth_token.runtime_id != original_runtime_id + assert rotated.auth_token.name == "my-token" + + +def test_rotate_service_token_generates_new_raw_token() -> None: + repo = _repo_with_user() + service = _service(repo) + + issued = service.issue_service_token("my-token", "alice") + rotated = service.rotate_service_token("my-token") + + assert rotated.token != issued.token + + +def test_rotate_nonexistent_token_raises() -> None: + repo = _repo_with_user() + service = _service(repo) + + with pytest.raises(ValueError, match="nonexistent"): + service.rotate_service_token("nonexistent") diff --git a/tests/test_v1.py b/tests/test_v1.py index b053dbb..e8670dd 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -87,6 +87,7 @@ class _RuntimeIdRoundTripRepo: token_type: str, expires_at: datetime | None = None, runtime_id: str | None = None, + daemon_id: str | None = None, ) -> AuthToken: token = AuthToken( id=f"token-{self._seq}", @@ -98,6 +99,7 @@ class _RuntimeIdRoundTripRepo: expires_at=expires_at, created_at=datetime.now(timezone.utc), runtime_id=runtime_id, + daemon_id=daemon_id, ) self._seq += 1 self.tokens_by_hash[token_hash] = token @@ -113,6 +115,7 @@ class _RuntimeIdRoundTripRepo: permission=token.permission, token_type=token.token_type, runtime_id=token.runtime_id, + daemon_id=token.daemon_id, ) def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: @@ -226,6 +229,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio: active_strategy_id=None, avabile_strategies=None, owner_user_id="user-1", + managed_by_daemon_id="d-1", ) @@ -253,7 +257,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None: portfolio = _make_portfolio() principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-1" + token_type="service", runtime_id="rt-1", daemon_id="d-1" ) mock_repo = MagicMock() @@ -280,6 +284,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None: mock_repo.list_portfolios_with_strategies.assert_called_once_with( owner_user_id="user-1", include_disabled=False, + managed_by_daemon_id="d-1", ) @@ -294,7 +299,7 @@ def test_v1_assets_status_filter(monkeypatch) -> None: ) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-1" + token_type="service", runtime_id="rt-1", daemon_id="d-1" ) mock_repo = MagicMock() @@ -328,7 +333,7 @@ def test_v1_assets_strategy_params(monkeypatch) -> None: ) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-1" + token_type="service", runtime_id="rt-1", daemon_id="d-1" ) mock_repo = MagicMock() @@ -357,7 +362,7 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None: ) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-1" + token_type="service", runtime_id="rt-1", daemon_id="d-1" ) mock_repo = MagicMock() @@ -379,23 +384,27 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None: # GET /v1/user_info # --------------------------------------------------------------------------- -def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None: +def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None: settings = _make_app_db(monkeypatch) principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id=None, # no runtime_id ) + user = User(id="user-1", username="alice", permission="read", is_active=True) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.get("/v1/user_info", headers=_service_token_header()) - assert resp.status_code == 404 - assert "runtime_id" in resp.json()["detail"] + assert resp.status_code == 200 + assert resp.json()["runtime_id"] is None def test_v1_user_info_returns_user(monkeypatch) -> None: @@ -539,7 +548,7 @@ def _make_ledger_trade(**kwargs) -> LedgerTrade: def _make_ledger_principal() -> TokenPrincipal: return TokenPrincipal( token_id="t1", user_id="user-1", permission="trade-write", - token_type="service", runtime_id="rt-1" + token_type="service", runtime_id="rt-1", daemon_id="d-1" ) @@ -548,7 +557,9 @@ def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerTrade, created: bool, f mock_repo = MagicMock() mock_repo.get_token_principal.return_value = _make_ledger_principal() if found: - mock_repo.get.return_value = MagicMock() # portfolio exists + mock_portfolio = MagicMock() + mock_portfolio.managed_by_daemon_id = "d-1" + mock_repo.get.return_value = mock_portfolio else: mock_repo.get.side_effect = Exception("not found") mock_repo.insert_ledger_trade.return_value = (trade, created) @@ -759,6 +770,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio: active_strategy_id=None, avabile_strategies=None, owner_user_id="user-1", + managed_by_daemon_id="d-1", ) @@ -786,7 +798,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None: portfolio = _make_portfolio() principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", - token_type="service", runtime_id="rt-1" + token_type="service", runtime_id="rt-1", daemon_id="d-1" ) mock_repo = MagicMock() @@ -809,23 +821,27 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None: # GET /v1/user_info # --------------------------------------------------------------------------- -def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None: +def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None: monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id=None, # no runtime_id ) + user = User(id="user-1", username="alice", permission="read", is_active=True) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + mock_repo.list_broker_credentials.return_value = [] - with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app() resp = client.get("/v1/user_info", headers=_service_token_header()) - assert resp.status_code == 404 - assert "runtime_id" in resp.json()["detail"] + assert resp.status_code == 200 + assert resp.json()["runtime_id"] is None def test_v1_user_info_returns_user(monkeypatch) -> None: