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 <name> --runtime-id <runtime-id>
- pfhub daemon list
- pfhub daemon get <daemon-id>
- pfhub daemon revoke <daemon-id>

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
This commit is contained in:
2026-05-13 23:07:24 +00:00
parent f0a6caa178
commit e6b6a8715b
45 changed files with 2688 additions and 167 deletions
+83
View File
@@ -108,8 +108,13 @@ pfhub user delete <username> --force
pfhub service-token list --user alice pfhub service-token list --user alice
pfhub service-token issue nightly --user alice --ttl-days 30 pfhub service-token issue nightly --user alice --ttl-days 30
pfhub service-token issue permanent --user alice --no-expiry pfhub service-token issue permanent --user alice --no-expiry
pfhub service-token issue my-daemon-token --user alice --daemon-id <daemon_id>
pfhub service-token revoke nightly --force pfhub service-token revoke nightly --force
pfhub service-token rotate permanent --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 <daemon_id> --display-name "Updated Name"
pfhub daemon update <daemon_id> --deactivate
pfhub portfolio list --user <username> pfhub portfolio list --user <username>
pfhub portfolio status 005930:tv --recent 10 pfhub portfolio status 005930:tv --recent 10
pfhub portfolio create 005930:tv --symbol 005930 --user <username> --cash-balance 10000000 --force pfhub portfolio create 005930:tv --symbol 005930 --user <username> --cash-balance 10000000 --force
@@ -134,6 +139,9 @@ Supported commands:
- `service-token issue` - `service-token issue`
- `service-token revoke` - `service-token revoke`
- `service-token rotate` - `service-token rotate`
- `daemon create`
- `daemon list`
- `daemon update`
- `portfolio list` - `portfolio list`
- `portfolio status` - `portfolio status`
- `portfolio create` - `portfolio create`
@@ -155,6 +163,15 @@ Service token issue policy:
- `--no-expiry`는 관리자 권한에서만 허용된다. - `--no-expiry`는 관리자 권한에서만 허용된다.
- 토큰 원문은 발급 응답 1회 이후 마스킹된 값으로만 출력/기록된다. - 토큰 원문은 발급 응답 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`. 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: 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`. 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 <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 = '<daemon_id>' WHERE name = 'my-portfolio' AND owner_user_id = '<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 "<BASE_URL>/v1/user_info" \
-H "Authorization: Bearer <NEW_TOKEN>"
# 응답: runtime_id=<new_id>, daemon_id=<same_daemon_id>
```
## Local DB Validation Runbook ## Local DB Validation Runbook
이 섹션은 로컬 docker compose 환경에서 DB 초기화, 마이그레이션, health-check, smoke flow를 반복 검증하기 위한 절차를 정의한다. 이 섹션은 로컬 docker compose 환경에서 DB 초기화, 마이그레이션, health-check, smoke flow를 반복 검증하기 위한 절차를 정의한다.
+7
View File
@@ -40,6 +40,12 @@ def list_assets(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),
auth: AuthContext = Depends(_require_service_token), auth: AuthContext = Depends(_require_service_token),
) -> AssetsResponse: ) -> 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 from app.core.config import get_settings
settings = get_settings() settings = get_settings()
@@ -48,6 +54,7 @@ def list_assets(
portfolios = repo.list_portfolios_with_strategies( portfolios = repo.list_portfolios_with_strategies(
owner_user_id=auth.user_id, owner_user_id=auth.user_id,
include_disabled=False, include_disabled=False,
managed_by_daemon_id=auth.daemon_id,
) )
# Fetch strategy_params per portfolio # Fetch strategy_params per portfolio
+19 -2
View File
@@ -49,15 +49,32 @@ def record_ledger_trade(
settings = get_settings() settings = get_settings()
repo = get_portfolio_repository_from_settings(settings) repo = get_portfolio_repository_from_settings(settings)
try: try:
# B-9: Validate asset_id (portfolio) existence # Validate asset_id (portfolio) existence
try: try:
repo.get(asset_id) portfolio = repo.get(asset_id)
except Exception: except Exception:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Asset not found: {asset_id}", 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( trade, created = repo.insert_ledger_trade(
asset_id=asset_id, asset_id=asset_id,
order_id=body.order_id, order_id=body.order_id,
+1 -6
View File
@@ -31,12 +31,6 @@ def get_user_info(
auth: AuthContext = Depends(_require_service_token), auth: AuthContext = Depends(_require_service_token),
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
) -> UserInfoResponse: ) -> 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) repo = get_portfolio_repository_from_settings(settings)
try: try:
user = repo.get_user(auth.user_id) # type: ignore[arg-type] user = repo.get_user(auth.user_id) # type: ignore[arg-type]
@@ -76,6 +70,7 @@ def get_user_info(
return UserInfoResponse( return UserInfoResponse(
runtime_id=auth.runtime_id, runtime_id=auth.runtime_id,
daemon_id=auth.daemon_id,
trading_enabled=user.is_active, trading_enabled=user.is_active,
disabled_reason=disabled_reason, disabled_reason=disabled_reason,
brokers=brokers, brokers=brokers,
+82
View File
@@ -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)
+28
View File
@@ -62,6 +62,7 @@ def issue_service_token(
permission: str = typer.Option("read", "--permission", help="Token permission: read or trade-write."), 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."), 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."), 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: ) -> None:
def _issue(service: AuthService, app_context): def _issue(service: AuthService, app_context):
return service.issue_service_token( return service.issue_service_token(
@@ -70,6 +71,7 @@ def issue_service_token(
permission=permission, permission=permission,
expires_in_days=ttl_days, expires_in_days=ttl_days,
no_expiry=no_expiry, no_expiry=no_expiry,
daemon_id=daemon_id,
) )
issued = _with_service(ctx, _issue) issued = _with_service(ctx, _issue)
@@ -79,6 +81,32 @@ def issue_service_token(
expires_at=issued.auth_token.expires_at, expires_at=issued.auth_token.expires_at,
revoked=issued.auth_token.revoked_at is not None, revoked=issued.auth_token.revoked_at is not None,
runtime_id=issued.auth_token.runtime_id, 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, token=issued.token,
) )
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
+2
View File
@@ -8,6 +8,7 @@ from typing import Callable
import typer import typer
from rich.console import Console 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.health import register_health_commands
from app.cli.commands.service_tokens import token_app from app.cli.commands.service_tokens import token_app
from app.cli.commands.portfolio import portfolio_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(user_app, name="user")
app.add_typer(token_app, name="service-token") app.add_typer(token_app, name="service-token")
app.add_typer(credential_app, name="credential") app.add_typer(credential_app, name="credential")
app.add_typer(daemon_app, name="daemon")
register_health_commands(app) register_health_commands(app)
return app return app
+2
View File
@@ -29,6 +29,7 @@ class AuthContext:
token_type: str | None = None token_type: str | None = None
token_source: str = "env" token_source: str = "env"
runtime_id: str | None = None runtime_id: str | None = None
daemon_id: str | None = None
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]: def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
repository = get_portfolio_repository_from_settings(settings) 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_type=principal.token_type,
token_source="db", token_source="db",
runtime_id=principal.runtime_id, runtime_id=principal.runtime_id,
daemon_id=principal.daemon_id,
) )
+14
View File
@@ -29,6 +29,17 @@ class User:
last_login_at: datetime | None = None 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 @dataclass
class AuthToken: class AuthToken:
id: str id: str
@@ -42,6 +53,7 @@ class AuthToken:
revoked_at: datetime | None = None revoked_at: datetime | None = None
created_at: datetime | None = None created_at: datetime | None = None
runtime_id: str | None = None runtime_id: str | None = None
daemon_id: str | None = None
@dataclass @dataclass
@@ -51,6 +63,7 @@ class TokenPrincipal:
permission: str permission: str
token_type: str token_type: str
runtime_id: str | None = None runtime_id: str | None = None
daemon_id: str | None = None
@dataclass @dataclass
@@ -72,6 +85,7 @@ class Portfolio:
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
last_trade_at: datetime | None = None last_trade_at: datetime | None = None
managed_by_daemon_id: str | None = None
@dataclass @dataclass
+35 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Protocol 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): class PortfolioRepository(Protocol):
@@ -53,6 +53,7 @@ class PortfolioRepository(Protocol):
token_type: str, token_type: str,
expires_at: datetime | None = None, expires_at: datetime | None = None,
runtime_id: str | None = None, runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken: ... ) -> AuthToken: ...
def list_auth_tokens(self, user_id: str) -> list[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 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(self, portfolio_id: str) -> Portfolio: ...
def get_or_create( def get_or_create(
@@ -114,6 +139,15 @@ class PortfolioRepository(Protocol):
include_tags: list[str] | None = None, include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None, exclude_tags: list[str] | None = None,
match_mode: 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]: ... ) -> list[Portfolio]: ...
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ... def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
+234 -8
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg import psycopg
from psycopg import sql 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_USER = """
SELECT SELECT
@@ -39,7 +39,8 @@ _SELECT_AUTH_TOKEN = """
last_used_at, last_used_at,
revoked_at, revoked_at,
created_at, created_at,
runtime_id runtime_id,
daemon_id
FROM auth_tokens FROM auth_tokens
""" """
@@ -59,7 +60,8 @@ _SELECT_PORTFOLIO = """
owner_user_id, owner_user_id,
created_at, created_at,
updated_at, updated_at,
last_trade_at last_trade_at,
managed_by_daemon_id
FROM portfolios 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 runtime_id TEXT")
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS daemon_id TEXT")
cur.execute( cur.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created 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 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 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("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL") cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute( cur.execute(
@@ -263,6 +267,26 @@ class PostgresPortfolioRepository:
ON portfolio_tags (tag_value, portfolio_id) 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')") cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
if cur.fetchone()[0]: if cur.fetchone()[0]:
cur.execute( cur.execute(
@@ -304,6 +328,7 @@ class PostgresPortfolioRepository:
revoked_at=row[8] if len(row) > 8 else None, revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None, created_at=row[9] if len(row) > 9 else None,
runtime_id=row[10] if len(row) > 10 else None, runtime_id=row[10] if len(row) > 10 else None,
daemon_id=row[11] if len(row) > 11 else None,
) )
@staticmethod @staticmethod
@@ -336,6 +361,7 @@ class PostgresPortfolioRepository:
created_at=row[12] if len(row) > 12 else None, created_at=row[12] if len(row) > 12 else None,
updated_at=row[13] if len(row) > 13 else None, updated_at=row[13] if len(row) > 13 else None,
last_trade_at=row[14] if len(row) > 14 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 @staticmethod
@@ -537,6 +563,7 @@ class PostgresPortfolioRepository:
token_type: str, token_type: str,
expires_at: datetime | None = None, expires_at: datetime | None = None,
runtime_id: str | None = None, runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken: ) -> AuthToken:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
@@ -547,14 +574,16 @@ class PostgresPortfolioRepository:
cur.execute( cur.execute(
""" """
INSERT INTO auth_tokens ( 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 RETURNING
id, user_id, name, token_prefix, permission, token_type, 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() row = cur.fetchone()
return self._row_to_auth_token(row) return self._row_to_auth_token(row)
@@ -617,7 +646,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute( 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 FROM auth_tokens t
WHERE token_hash = %s WHERE token_hash = %s
AND t.revoked_at IS NULL AND t.revoked_at IS NULL
@@ -635,6 +664,7 @@ class PostgresPortfolioRepository:
permission=row[2], permission=row[2],
token_type=row[3], token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None, 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: def get(self, portfolio_id: str) -> Portfolio:
@@ -794,6 +824,7 @@ class PostgresPortfolioRepository:
include_tags: list[str] | None = None, include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None, exclude_tags: list[str] | None = None,
match_mode: str | None = None, match_mode: str | None = None,
managed_by_daemon_id: str | None = None,
) -> list[Portfolio]: ) -> list[Portfolio]:
self.ensure_schema() self.ensure_schema()
query = _SELECT_PORTFOLIO query = _SELECT_PORTFOLIO
@@ -808,6 +839,9 @@ class PostgresPortfolioRepository:
if owner_user_id is not None: if owner_user_id is not None:
clauses.append("owner_user_id = %s") clauses.append("owner_user_id = %s")
params.append(owner_user_id) 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() normalized_mode = (match_mode or "OR").upper()
if include_tags: if include_tags:
if normalized_mode == "AND": if normalized_mode == "AND":
@@ -1302,6 +1336,198 @@ class PostgresPortfolioRepository:
if cur.fetchone() is None: if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다") 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: def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect( with psycopg.connect(
+3
View File
@@ -76,12 +76,14 @@ class IssueServiceTokenRequest(BaseModel):
permission: Literal["read", "trade-write", "admin"] = "read" permission: Literal["read", "trade-write", "admin"] = "read"
expires_in_days: int | None = Field(default=None, ge=1, le=3650) expires_in_days: int | None = Field(default=None, ge=1, le=3650)
no_expiry: bool = False no_expiry: bool = False
daemon_id: str | None = None
class IssueServiceTokenApiResponse(BaseModel): class IssueServiceTokenApiResponse(BaseModel):
token: str token: str
token_type: Literal["bearer"] = "bearer" token_type: Literal["bearer"] = "bearer"
runtime_id: str | None = None runtime_id: str | None = None
daemon_id: str | None = None
auth_token: AuthTokenResponse auth_token: AuthTokenResponse
@@ -91,6 +93,7 @@ class ServiceTokenIssueCliResponse(BaseModel):
expires_at: datetime | None = None expires_at: datetime | None = None
revoked: bool revoked: bool
runtime_id: str | None = None runtime_id: str | None = None
daemon_id: str | None = None
token: str | None = None token: str | None = None
+2 -1
View File
@@ -53,7 +53,8 @@ class BrokerItem(BaseModel):
class UserInfoResponse(BaseModel): class UserInfoResponse(BaseModel):
runtime_id: str runtime_id: str | None = None
daemon_id: str | None = None
trading_enabled: bool trading_enabled: bool
disabled_reason: str | None = None disabled_reason: str | None = None
brokers: list[BrokerItem] = [] brokers: list[BrokerItem] = []
+13
View File
@@ -117,11 +117,19 @@ class AuthService:
permission: str = "read", permission: str = "read",
expires_in_days: int | None = None, expires_in_days: int | None = None,
no_expiry: bool = False, no_expiry: bool = False,
daemon_id: str | None = None,
) -> IssuedServiceToken: ) -> IssuedServiceToken:
user = self._repository.get_user_by_username(username) user = self._repository.get_user_by_username(username)
if user is None: if user is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {username}") 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 expires_at: datetime | None
if no_expiry: if no_expiry:
if expires_in_days is not None: if expires_in_days is not None:
@@ -143,11 +151,16 @@ class AuthService:
token_type="service", token_type="service",
expires_at=expires_at, expires_at=expires_at,
runtime_id=runtime_id, runtime_id=runtime_id,
daemon_id=daemon_id,
) )
except psycopg.errors.UniqueViolation as exc: except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
return IssuedServiceToken(token=raw_token, auth_token=auth_token) 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: def revoke_service_token(self, name: str) -> datetime:
existing = self._repository.get_service_token_by_name(name) existing = self._repository.get_service_token_by_name(name)
if existing is None: if existing is None:
@@ -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 범위)
@@ -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).
@@ -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 |
@@ -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에서 확인.
@@ -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 <slug> --user <username>` 가 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 <username>` 가 해당 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 <daemon_id> --display-name <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 <name> --daemon-id <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 <name>` 시 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 적용 검증은 배포 시 수행한다.
+1
View File
@@ -17,3 +17,4 @@
| 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-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-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/) | | 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/) |
+44 -5
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-11 00:00 - Last Updated (KST): 2026-05-13 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010
## Project Summary ## Project Summary
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. - 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 - 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 - 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 - BrokerCredential :: encrypted broker API credentials per user-broker pair
- LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id) - 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 - active_strategy_id :: text|null :: currently selected strategy-set reference
- cash_balance :: double precision :: current cash snapshot after the latest ledger event - cash_balance :: double precision :: current cash snapshot after the latest ledger event
- asset_quantity :: double precision :: current holdings 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 ### Uniqueness Constraints
@@ -57,6 +59,7 @@
### Relationships ### Relationships
- User :: owner_user_id links a portfolio to its owning user when ownership is assigned - 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 - StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions
- LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id (UUID) - 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 - token_source :: text :: "db" for persisted tokens; "env" for environment-injected tokens
- permission :: text :: read, trade-write, or admin - permission :: text :: read, trade-write, or admin
- service_name :: text|null :: logical name for service tokens (unique within token_type="service") - 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 - expires_at :: timestamptz|null :: token expiry; null for non-expiring tokens
- created_at :: timestamptz :: issuance timestamp - created_at :: timestamptz :: issuance timestamp
@@ -119,9 +123,43 @@
### State Rules ### 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. - 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` ## Entity: `BrokerCredential`
- Purpose: store Fernet-encrypted broker API credentials per user-broker pair for the trex-basic integration - Purpose: store Fernet-encrypted broker API credentials per user-broker pair for the trex-basic integration
@@ -204,6 +242,7 @@
## Change Log ## 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-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-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. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
@@ -5,9 +5,9 @@
- 문서 ID: dcy-002_20260502_trex-basic-consumer-api-contract - 문서 ID: dcy-002_20260502_trex-basic-consumer-api-contract
- 이슈명: trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 - 이슈명: trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현
- 작성 시각(KST): 2026-05-02 00:00 - 작성 시각(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 - 생성된 REQ 참조: REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011
# 1. 계획 상태 요약 # 1. 계획 상태 요약
@@ -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 표기는 `<username>/<daemon_slug>`로 통일하고, 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
+1
View File
@@ -11,3 +11,4 @@
| dcy-003_20260502_portfolio-list-user-required | 20260502 | CLI `pfhub portfolio list` — `--user` 옵션 필수화 및 소유자 기반 조회 강제 | [Discovery](./dcy-003_20260502_portfolio-list-user-required.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-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 | | 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 |
+23 -12
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-11 00:00 - Last Updated (KST): 2026-05-13 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## Project Summary
@@ -69,7 +69,7 @@
### Inputs ### 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 - 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 - Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
- Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` and `tag` sub-commands still accept portfolio_id (UUID) directly - Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` 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)` ## Interface: `v1 Consumer API (trex-basic)`
- Type: http-api - Type: http-api
- Consumers / Producers: trex-basic service (Consumer-Driven Contract, dcy-002) - Consumers / Producers: trex-basic service (Consumer-Driven Contract, dcy-002, dcy-006)
- Purpose: provide asset listing, user info, and trade ledger recording for the trex-basic trading daemon - Purpose: provide daemon-scoped asset listing, user info with daemon_id diagnostic, and daemon-assignment-authorized trade ledger recording
- Stability: internal (versioned under /v1) - Stability: internal (versioned under /v1)
### Authentication ### 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. - 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. - 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` ### 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=<value>` (optional, filters by portfolio status) - Query Params: `?status=<value>` (optional, filters by portfolio status)
- Filter: returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id`
- Response: HTTP 200 - 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` ### 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 - Response: HTTP 200
``` ```
{ {
"runtime_id": "<str>", "runtime_id": "<str|null>", // null for tokens without runtime_id (diagnostic)
"daemon_id": "<str|null>", // null for unbound/legacy tokens (diagnostic)
"trading_enabled": <bool>, // mapped from users.is_active "trading_enabled": <bool>, // mapped from users.is_active
"disabled_reason": "<str|null>", // "account_inactive" when trading_enabled=false "disabled_reason": "<str|null>", // "account_inactive" when trading_enabled=false
"brokers": [ "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` ### 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 - 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: - Request Body:
``` ```
@@ -208,7 +218,7 @@
``` ```
- Response HTTP 200 (idempotent duplicate with identical payload): same body with `"status": "duplicate"` - 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 ### Idempotency Contract
@@ -242,6 +252,7 @@
## Change Log ## 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-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-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. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
+1
View File
@@ -13,3 +13,4 @@
| 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-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-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) | | 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) |
@@ -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 <admin_user>` → daemon_id 반환 확인
- [ ] `pfhub service-token issue test-daemon-token --user <user> --daemon-id <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:
- <app, event, command, run_id 확인 결과>
- Request Log Fields:
- <request_id 포함 여부와 확인 결과>
- Smoke or Manual Verification:
- <배포 후 재실행한 검증과 결과>
### Rollback Outcome
- Rollback Required:
- <yes 또는 no>
- Rollback Trigger:
- <발생 시 원인, 없으면 none>
- Rollback Action:
- <수행한 조치, 없으면 none>
### Release Decision Evidence
- Released Decision:
- <released 전환 여부>
- Rationale:
- <released 전환 판단 근거>
- 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)
+51 -18
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-11 00:00 - Last Updated (KST): 2026-05-13 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## Project Summary
@@ -22,7 +22,7 @@
## Covered Entry Points ## Covered Entry Points
- uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks - 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 - alembic upgrade head :: apply ordered database schema migrations for persistent environments
## Shared Components ## Shared Components
@@ -78,29 +78,33 @@
## Flow: `v1 API — GET /v1/assets` ## Flow: `v1 API — GET /v1/assets`
1. Service-token auth required (see above). 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`. 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. Optional `?status=<value>` query param filters portfolios by status field. 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. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`. 4. Optional `?status=<value>` query param filters portfolios by status field; `include_disabled=False` by default.
5. Returns `AssetsResponse` with `total` and `assets[]` using new field names: `cash`, `quantity`, `broker_type`, `strategy_params`. 5. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`.
6. Portfolios owned by other users must not be included in the response. 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` which is a server-generated UUID after BAT-006 migration (dcy-002 A-3 updated: text identity assumption replaced by UUID). 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` ## 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`. 2. `get_user()` fetches user record by `user_id`.
3. `list_broker_credentials()` fetches all broker credential rows for the user. 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`. 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` ## Flow: `v1 API — POST /v1/assets/{asset_id}/ledger/trades`
1. Service-token auth required with `trade-write` permission. 1. Service-token auth required with `trade-write` permission.
2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing. 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)`. 3. Daemon assignment authorization (fail-closed, three checks in order):
4. If new record: returns HTTP 201 with `status="new"`. a. `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token").
5. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`. b. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
6. If duplicate key with different payload: returns HTTP 409 with conflict detail. 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` ## 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`. 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. 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 <daemon_slug> --user <username>` (optionally `--display-name <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 <name> --user <username> --daemon-id <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 <name> --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` ## Flow: `Application Startup Validation`
1. `lifespan` startup hook runs `_bootstrap_initial_user()` if DB enabled and bootstrap credentials configured. 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 사용 불가. 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` 후속 실행. 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. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판). 4. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행.
5. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판).
## Current Gaps / Planned Changes ## Current Gaps / Planned Changes
@@ -167,6 +199,7 @@
## Change Log ## 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-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-007 — Added "Local DB Initialization and Validation" flow (Scenario A/B, repeatable run criteria); added "Alembic vs ensure_schema() Schema Ownership Boundary" flow.
- 2026-05-11 KST: bat-006 — Added CLI portfolio name+user resolution flow; updated GET /v1/assets flow to note asset_id is UUID after BAT-006 migration. - 2026-05-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.
@@ -1,24 +1,25 @@
# req-011: trex-basic 계약 반영을 위한 baseline 문서 갱신 # req-011: trex-basic 계약 반영을 위한 baseline 문서 갱신
- Status: Implemented - Status: Approved
- Type: Documentation - Type: Documentation
- Priority: Medium - Priority: Medium
- Owner: repository-maintainers - Owner: repository-maintainers
## Intent ## Intent
- dcy-002 구현 완료 후 interface-contract.md, data-model.md, runtime-flows.md를 실제 구현 결과와 동기화한다. - dcy-002 구현 완료 후 interface-contract.md, data-model.md, runtime-flows.md를 실제 구현 결과와 동기화한다.
- dcy-006의 daemon-aware 변경으로 baseline 문서는 runtime_id 중심 설명을 daemon binding/assignment 모델로 다시 정렬해야 한다.
- 이 REQ는 구현이 완료된 시점에 수행하는 문서 갱신이며, 구현 전 선행 작업이 아니다. - 이 REQ는 구현이 완료된 시점에 수행하는 문서 갱신이며, 구현 전 선행 작업이 아니다.
## Requirement ## Requirement
- REQ-004~010이 구현 완료(Implemented)된 시점에 다음 3개 baseline 문서를 갱신해야 한다. - REQ-005~009, REQ-012의 daemon-aware 변경이 구현 완료(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 인증 방식 추가 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: broker_credentials 테이블, ledger_trades 테이블, portfolios.market 컬럼, auth_tokens.runtime_id 컬럼 추가 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: /v1/* API 요청 처리 흐름(인증 → 서비스 → DB), service-token 발급 시 runtime_id 생성 흐름 추가 3. docs/runtime-flows.md: daemon registry binding, `/v1/*` daemon-scoped read/write 흐름, rotate 시 `daemon_id` 유지 + `runtime_id` 갱신 불변식 반영
## Acceptance Criteria ## 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의 요청/응답 명세가 포함되어야 한다. 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 broker_credentials, ledger_trades 테이블과 portfolios.market, auth_tokens.runtime_id 컬럼이 문서화되어야 한다. 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 v1 API 처리 흐름과 runtime_id 발급 흐름이 포함되어야 한다. 3. Given docs/runtime-flows.md를 검토하면, Then daemon registry binding 흐름, daemon-scoped v1 API 처리 흐름, rotate 시 `runtime_id`/`daemon_id` 역할 차이가 포함되어야 한다.
## Impacted Area ## Impacted Area
- Docs: docs/interface-contract.md, docs/data-model.md, docs/runtime-flows.md - Docs: docs/interface-contract.md, docs/data-model.md, docs/runtime-flows.md
@@ -28,10 +29,53 @@
## Notes ## Notes
- 이 REQ는 구현 완료 후 수행하므로 batch 설계 단계에서 별도 implementation task로 명시한다. - 이 REQ는 구현 완료 후 수행하므로 batch 설계 단계에서 별도 implementation task로 명시한다.
- 문서 갱신 내용은 draft-batch-verification 단계에서 실제 구현과 비교해 검증한다. - 문서 갱신 내용은 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 ## 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 ### CHG-20260511-01
- Date: 2026-05-11 - Date: 2026-05-11
- Author: repository-maintainers - Author: repository-maintainers
@@ -7,36 +7,81 @@
## Intent ## Intent
- release 검증에 사용하는 credential의 용도, 발급 주체, 보관 위치, 폐기 절차가 정의되지 않으면 smoke 재현성이 없어지고 secret leakage 위험이 높아진다. - 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 ## Requirement
- README.md Production Operator Runbook 또는 연결된 운영 문서는 아래 credential matrix를 명시해야 한다. - README.md Production Operator Runbook 또는 연결된 운영 문서는 아래 credential matrix를 명시해야 한다.
- **public/no-auth**: `GET /health`, `/docs`, `/openapi.json`. 인증 불필요. 배포 직후 공개 smoke에 사용. - **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. - **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`. 보관: read token과 동일 정책. 공동 소유자: 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. - **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값은 기록하지 않는다. - 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, created_at, last_used_at, revoked_at, runtime_id)를 provenance 확인 기준으로 명시한다. - 운영자는 `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 ## Acceptance Criteria
1. Given README.md 또는 연결된 운영 문서가 존재할 때, When 운영자가 credential matrix를 참조할 때, Then public/read/trade-write/admin 4개 credential의 용도, 발급 주체, 보관 위치, 공동 소유 역할이 구분되어 기술되어 있어야 한다. 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, runtime_id, issued_at, revoked_at 필드만 남기고 raw token 값은 포함되지 않아야 한다. 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, created_at, last_used_at, revoked_at 필드가 출력되고, `GET /v1/user_info`의 runtime_id와 대조해 배포된 토큰의 provenance를 재현 가능하게 확인할 수 있어야 한다. 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 역할과 공동 소유 형태로 관리되는 것이 문서에 명시되어 있어야 한다. 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 ## Impacted Area
- Docs: `README.md` — credential matrix 항목 및 token provenance CLI 절차 추가/보강 - Docs: `README.md` — credential matrix 항목 및 daemon-aware token provenance CLI 절차 추가/보강
- Modules: `app/api/v1_user_info.py` (runtime_id 반환 확인), `app/cli/commands/service_token.py` (list/rotate 출력 필드 확인) - Modules: `app/api/v1_user_info.py` (`runtime_id`, `daemon_id` 반환 확인), `app/cli/commands/service_tokens.py` (issue/list/rotate 출력 필드 확인)
- Tests: 없음 (문서화 중심 REQ) - Tests: 없음 (문서화 중심 REQ)
## Notes ## Notes
- NFR-1 (raw credential 비노출): 이 REQ의 핵심 제약. AC-2에 명시. - NFR-1 (raw credential 비노출): 이 REQ의 핵심 제약. AC-2에 명시.
- FR-4와 FR-2를 통합한 REQ. token provenance 확인 수단과 credential matrix는 운영자가 같은 맥락에서 참조하므로 분리 시 중복이 발생한다. - FR-4와 FR-2를 통합한 REQ. token provenance 확인 수단과 credential matrix는 운영자가 같은 맥락에서 참조하므로 분리 시 중복이 발생한다.
- `runtime_id`는 service-token rotate 후 변경되므로 rotate 후 consumer가 재조회해야 한다는 사실도 matrix 문서에 포함할 것을 권장 (REQ-012 CHG-20260511-01 참조). - `runtime_id`는 service-token rotate 후 변경되고 `daemon_id`는 유지되므로, 문서에는 두 식별자의 역할 차이를 함께 명시해야 한다.
- Source Discovery: dcy-005 FR-2, FR-4 - daemon provisioning 자체는 별도 관리 경로로 수행하고, provenance 절차는 binding 결과 확인에 집중한다.
- Source Discovery: dcy-005 FR-2, FR-4; dcy-006 FR-5, FR-6
## Change Log ## 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 ### CHG-20260512-02
- Date: 2026-05-12 - Date: 2026-05-12
- Author: repository-maintainers - Author: repository-maintainers
@@ -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 역할 구분)이 존재하는지 코드 리뷰로 검증
@@ -1,39 +1,85 @@
# req-005: service-token 발급 시 runtime_id 동시 생성 # req-005: service-token 발급 시 runtime_id 동시 생성
- Status: Implemented - Status: Approved
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
## Intent ## Intent
- trex-basic 데몬은 GET /v1/user_info에서 runtime_id를 조회해 자신을 식별한다. - trex-basic 계열 consumer는 `runtime_id`를 런타임 세대 식별자로 사용하되, asset assignment와 운영 provenance는 stable `daemon_id`를 기준으로 추적해야 한다.
- runtime_id는 service-token 발급 시 함께 생성되어 해당 토큰에 연결 저장되어야 한다. - service-token 발급 시 runtime_id를 새로 생성하면서도, 토큰은 기존 daemon registry에 존재하는 `daemon_id`에 바인딩되어 저장되어야 한다.
- CLI(pfhub service-token issue)와 API 엔드포인트 양쪽에서 발급 결과에 runtime_id가 포함되어야 한다. - CLI(`pfhub service-token issue`)와 API 엔드포인트 양쪽에서 발급 결과에 `runtime_id`와 `daemon_id`가 함께 포함되어야 한다.
## Requirement ## Requirement
- pfhub service-token issue CLI 커맨드와 대응 API 엔드포인트는 service-token 발급 시 고유 runtime_id를 동시 생성하고 auth_tokens 테이블에 연결 저장한 뒤 발급 결과(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`)를 반환해야 한다.
- runtime_id는 해당 service-token에 1:1 연결되며, 동일 사용자의 다른 service-token은 각기 다른 runtime_id를 가진다. - 발급 경로는 unknown daemon 자동 생성 또는 upsert를 허용하면 안 되며, binding 대상은 사전에 생성된 daemon registry entry여야 한다.
- `runtime_id`는 발급마다 새로 생성되는 런타임 식별자이고, `daemon_id`는 rotate/재기동과 무관하게 유지되는 stable logical identity다.
## Acceptance Criteria ## Acceptance Criteria
1. Given `pfhub service-token issue <name> --user <username>` 실행 시, When 발급이 성공하면, Then 출력에 token 문자열과 runtime_id가 모두 포함되어야 한다. 1. Given `pfhub service-token issue <name> --user <username> --daemon-id <daemon_id>` 실행 시, When 발급이 성공하면, Then 출력에 token 문자열, `runtime_id`, `daemon_id`가 모두 포함되어야 한다.
2. Given API 엔드포인트(POST /auth/service-tokens)로 service-token 발급 시, When 성공 응답을 받으면, Then 응답 JSON에 token과 runtime_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가 발급된 service-token으로 GET /v1/user_info를 호출하면, Then 응답의 runtime_id가 발급 시 생성된 값과 동일해야 한다. 3. Given `runtime_id`와 `daemon_id`가 발급된 service-token으로 `GET /v1/user_info`를 호출하면, Then 응답의 `runtime_id`, `daemon_id`가 발급 시 저장된 값과 동일해야 한다.
4. Given 동일 사용자에 대해 두 번의 service-token 발급을 수행하면, Then 각각 다른 runtime_id가 반환되어야 한다. 4. Given 동일 daemon에 대해 두 번의 service-token 발급을 수행하면, Then 각각 다른 `runtime_id`가 반환되어야 하고 `daemon_id`는 요청한 binding 값을 유지해야 한다.
5. Given auth_tokens 테이블을 직접 조회하면, Then 발급된 service-token 행에 runtime_id 값이 저장되어 있어야 한다. 5. Given auth_tokens 테이블을 직접 조회하면, Then 발급된 service-token 행에 `runtime_id`와 `daemon_id` 값이 모두 저장되어 있어야 한다.
6. Given 존재하지 않는 `daemon_id`로 service-token 발급을 요청하면, Then 명시적 validation 오류가 반환되어야 하고 auth_tokens 행은 생성되지 않아야 한다.
## Impacted Area ## Impacted Area
- Docs: docs/runtime-flows.md (REQ-011에서 갱신 예정) - 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/api/auth_routes.py, app/domain/models.py (AuthToken.runtime_id 필드 추가), app/services/auth_service.py, app/repositories/postgres.py - 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_service_tokens.py (신규) - Tests: tests/test_auth.py, tests/test_cli.py, tests/test_v1.py
## Notes ## Notes
- runtime_id 포맷은 UUID v4를 권장한다. 설계 단계에서 확정한다. - runtime_id 포맷은 UUID v4를 권장한다. 설계 단계에서 확정한다.
- auth_tokens 테이블에 runtime_id 컬럼 추가는 REQ-009 migration에서 처리한다. - auth_tokens 테이블의 `runtime_id`, `daemon_id` 컬럼 및 daemon registry 스키마는 REQ-009 migration에서 처리한다.
- 기존 service-token(runtime_id 없음) 처리 방식은 R-4로 식별됨. GET /v1/user_info(REQ-007) 설계 단계에서 결정한다. - daemon provisioning(create/update)은 별도 관리 경로에서 수행하며, issue 경로는 binding만 담당한다.
- Source Discovery: dcy-002 (FR-2, A-2) - 기존 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 ## 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 ### CHG-20260511-02
- Date: 2026-05-11 - Date: 2026-05-11
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,23 +1,24 @@
# req-006: GET /v1/assets 엔드포인트 구현 # req-006: GET /v1/assets 엔드포인트 구현
- Status: Implemented - Status: Approved
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
## Intent ## Intent
- trex-basic 데몬이 관리 자산 목록과 전략 파라미터를 조회할 수 있도록 portfolio 데이터를 asset 뷰로 변환해 제공한다. - trex-basic 데몬이 자신에게 배정된 자산 목록과 전략 파라미터만 조회할 수 있도록 portfolio 데이터를 daemon-scoped asset 뷰로 제공한다.
- asset_id는 기존 portfolio_id를 그대로 사용하며 별도 식별자 체계를 만들지 않는다(A-3). - 동일 owner user 아래 여러 daemon이 존재해도, 다른 daemon에 배정된 asset은 응답에 섞이면 안 된다.
## Requirement ## 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는 응답에 포함되면 안 된다. - 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 쿼리 파라미터로 필터링이 가능해야 한다. - `status=active` 쿼리 파라미터는 daemon-scoped 결과 집합 위에서 필터링되어야 한다.
- strategy_params는 해당 portfolio의 active_strategy_id로 조회한 StrategySet.config에서 파생한다. strategy_id는 허용값 검증 없이 그대로 반환한다(OQ-1 Answered). - 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을 반환한다. - 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 ## Acceptance Criteria
1. Given 유효한 service-token bearer 인증으로 GET /v1/assets 요청 시, When 토큰이 유효하면, Then HTTP 200과 해당 사용자 소유 portfolio 목록만 반환되어야 하며 다른 사용자 소유 portfolio는 포함되면 안 된다. 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인 항목만 반환되어야 한다. 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 필드가 모두 포함되어야 한다. 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 객체여야 한다. 4. Given portfolio에 연결된 StrategySet이 있을 때, When strategy_params를 확인하면, Then StrategySet.config 값에서 파생된 JSON 객체여야 한다.
5. Given StrategySet config가 없거나 파싱 실패 시, Then strategy_params는 빈 객체({})를 반환하고 에러가 전파되지 않아야 한다. 5. Given StrategySet config가 없거나 파싱 실패 시, Then strategy_params는 빈 객체({})를 반환하고 에러가 전파되지 않아야 한다.
@@ -25,20 +26,64 @@
7. Given 인증 없이 GET /v1/assets 요청 시, Then HTTP 401이 반환되어야 한다. 7. Given 인증 없이 GET /v1/assets 요청 시, Then HTTP 401이 반환되어야 한다.
8. Given portfolio에 active_strategy_id로 연결된 StrategySet이 있을 때, Then strategy_type은 해당 StrategySet.name 값을 반환해야 한다. 8. Given portfolio에 active_strategy_id로 연결된 StrategySet이 있을 때, Then strategy_type은 해당 StrategySet.name 값을 반환해야 한다.
9. Given portfolio에 active_strategy_id가 없거나 StrategySet 조회 실패 시, Then strategy_type은 null을 반환해야 한다. 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 ## Impacted Area
- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/project-structure.md (신규 라우터 파일 반영 필요) - 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 라우터 등록) - 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 (GET /v1/assets 관련 케이스 및 owner scope 회귀 검증) - Tests: tests/test_v1.py, tests/test_portfolio_ownership.py 또는 신규 daemon assignment 회귀 테스트
## Notes ## Notes
- broker_type은 portfolios.broker 컬럼을 그대로 매핑한다(m-2 finding 반영). - broker_type은 portfolios.broker 컬럼을 그대로 매핑한다(m-2 finding 반영).
- strategy_id는 portfolios.active_strategy_id를 그대로 반환한다. - strategy_id는 portfolios.active_strategy_id를 그대로 반환한다.
- GET /v1/assets의 소유권 스코프는 service-token 소유자(owner_user_id)를 기준으로 한다. - `GET /v1/assets`의 최종 authorization 스코프는 service-token 소유자 범위 위에 `managed_by_daemon_id = auth.daemon_id` 조건을 추가한 daemon scope다.
- Source Discovery: dcy-002 (FR-3, A-3, OQ-1) - 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 ## 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 ### CHG-20260511-02
- Date: 2026-05-11 - Date: 2026-05-11
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,43 +1,88 @@
# req-007: GET /v1/user_info 엔드포인트 구현 # req-007: GET /v1/user_info 엔드포인트 구현
- Status: Implemented - Status: Approved
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
## Intent ## Intent
- trex-basic 데몬이 사용자 거래 가능 여부와 브로커 인증 정보를 조회할 수 있도록 한다. - 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 의존). - credential_payload는 DB에서 Fernet 복호화 후 반환한다(NFR-3/REQ-010 의존).
## Requirement ## 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 필드를 응답에 포함하지 않는다. - environment 필드를 응답에 포함하지 않는다.
- credential_payload는 Fernet으로 복호화된 JSON을 반환한다. - credential_payload는 Fernet으로 복호화된 JSON을 반환한다.
- transition 구간의 legacy token은 `daemon_id = null`을 진단용으로 반환할 수 있어야 하며, `runtime_id`가 없는 구형 token 처리 규칙은 기존 v1 contract를 유지한다.
## Acceptance Criteria ## 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 필드가 존재하지 않아야 한다. 2. Given GET /v1/user_info 응답을 확인하면, Then environment 필드가 존재하지 않아야 한다.
3. Given users.is_active가 true인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 true여야 한다. 3. Given users.is_active가 true인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 true여야 한다.
4. Given users.is_active가 false인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 false여야 한다. 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})으로 반환되어야 한다. 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 생성 중 선택). 6. Given runtime_id가 없는 구형 service-token으로 호출 시, Then 설계 단계에서 확정된 오류 응답이 반환되어야 한다(R-4, 404 또는 즉시 runtime_id 생성 중 선택).
7. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다. 7. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다.
8. Given `daemon_id = NULL`로 backfill된 legacy service-token으로 호출 시, Then HTTP 200 응답이 유지되고 `daemon_id` 필드는 `null`로 노출되어야 한다.
## Impacted Area ## Impacted Area
- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정) - 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 라우터 등록) - 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_user_info.py (신규) - Tests: tests/test_v1.py, tests/test_auth.py
## Notes ## Notes
- trading_enabled는 users.is_active 단순 매핑이다(A-4 Confirmed). 별도 trading 전용 상태 필드는 이번 반복 Out of Scope. - trading_enabled는 users.is_active 단순 매핑이다(A-4 Confirmed). 별도 trading 전용 상태 필드는 이번 반복 Out of Scope.
- disabled_reason: trading_enabled가 false일 때 이유 문자열. 이번 반복에서는 is_active가 false인 경우 "account_inactive" 고정값을 사용하는 방안을 설계 단계에서 확정한다. - 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에서 정의한 암호화 유틸리티를 사용한다. - 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 ## 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 ### CHG-20260511-01
- Date: 2026-05-11 - Date: 2026-05-11
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,6 +1,6 @@
# req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 # req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현
- Status: Implemented - Status: Approved
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -8,34 +8,81 @@
## Intent ## Intent
- trex-basic이 체결 내역을 portfolio-hub에 idempotent하게 전송할 수 있도록 한다. - trex-basic이 체결 내역을 portfolio-hub에 idempotent하게 전송할 수 있도록 한다.
- 동일 (asset_id, order_id, execution_id) 조합의 중복 수신은 DB unique constraint로 감지하고 status=duplicate를 반환한다(NFR-2). - 동일 (asset_id, order_id, execution_id) 조합의 중복 수신은 DB unique constraint로 감지하고 status=duplicate를 반환한다(NFR-2).
- 동일 키에 다른 payload가 오면 충돌로 처리한다. - 동일 키에 다른 payload가 오면 충돌로 처리하며, daemon assignment mismatch 또는 미배정 asset write는 저장 전에 거부한다.
## Requirement ## 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으로 반환해야 한다. - 동일 (asset_id, order_id, execution_id) 재수신은 신규 행 생성 없이 status=duplicate와 기존 ledger_entry_id를 HTTP 200으로 반환해야 한다.
- 동일 키에 payload가 다른 경우 HTTP 409를 반환해야 한다. - 동일 키에 payload가 다른 경우 HTTP 409를 반환해야 한다.
- idempotency는 DB unique constraint로 보장하며, 애플리케이션 로직만으로 보장하지 않는다. - idempotency는 DB unique constraint로 보장하며, 애플리케이션 로직만으로 보장하지 않는다.
- daemon assignment mismatch, 미배정 asset, `daemon_id = NULL` legacy token은 모두 HTTP 403으로 fail-closed 처리해야 하며 ledger row가 생성되면 안 된다.
## Acceptance Criteria ## 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에 단일 행만 존재해야 한다. 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가 반환되어야 한다. 3. Given 동일 (asset_id, order_id, execution_id)에 다른 payload로 요청 시, Then HTTP 409가 반환되어야 한다.
4. Given asset_id가 존재하지 않는 portfolio_id인 경우, Then HTTP 404가 반환되어야 한다. 4. Given asset_id가 존재하지 않는 portfolio_id인 경우, Then HTTP 404가 반환되어야 한다.
5. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다. 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 ## Impacted Area
- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정) - 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 라우터 등록) - 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_ledger.py (신규) - Tests: tests/test_v1.py 또는 신규 daemon assignment / ledger authorization 회귀 테스트
## Notes ## Notes
- NFR-2(idempotency DB unique constraint): (asset_id, order_id, execution_id) unique constraint는 REQ-009 migration에서 정의한다. - 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이 외부에서 기록하는 체결 내역이다. - ledger_trades 테이블과 기존 portfolio_ledger 테이블은 별개다. portfolio_ledger는 내부 원장이고 ledger_trades는 trex-basic이 외부에서 기록하는 체결 내역이다.
- 중복 감지 구현: DB unique constraint 위반(psycopg UniqueViolation) 캐치 → 기존 행 조회 → payload 동일 여부 비교 → duplicate 또는 409 응답. - 중복 감지 구현: 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 ## 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 ### CHG-20260511-01
- Date: 2026-05-11 - Date: 2026-05-11
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,6 +1,6 @@
# req-012: service-token rotate CLI 명령 구현 # req-012: service-token rotate CLI 명령 구현
- Status: Implemented - Status: Approved
- Type: Interface - Type: Interface
- Priority: Medium - Priority: Medium
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -9,23 +9,23 @@
- 발급된 service-token의 원문이 노출되었거나 주기적 교체가 필요한 경우, token을 revoke 없이 in-place로 갱신할 수 있는 CLI 명령이 필요하다. - 발급된 service-token의 원문이 노출되었거나 주기적 교체가 필요한 경우, token을 revoke 없이 in-place로 갱신할 수 있는 CLI 명령이 필요하다.
- 기존 `revoke → re-issue` 방식은 `token_id`, `user_id`, `permission`, `expires_at`, `created_at` 등 메타데이터가 변경되므로, 운영 시스템에서 동일한 이름으로 참조하는 통합 구성을 다시 설정해야 하는 불편이 있다. - 기존 `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 ## Requirement
- `pfhub service-token rotate <name> [--force]` CLI 명령을 구현한다. - `pfhub service-token rotate <name> [--force]` CLI 명령을 구현한다.
- rotate는 기존 auth_token 행의 `token_hash`, `token_prefix`, `runtime_id`를 새 값으로 UPDATE한다. - rotate는 기존 auth_token 행의 `token_hash`, `token_prefix`, `runtime_id`를 새 값으로 UPDATE하고, `daemon_id`는 유지해야 한다.
- `id`, `user_id`, `name`, `permission`, `token_type`, `expires_at`, `created_at`은 변경하지 않는다. - `id`, `user_id`, `name`, `permission`, `token_type`, `expires_at`, `created_at`, `daemon_id`는 변경하지 않는다.
- rotate 대상 token이 존재하지 않으면 오류를 반환한다. - rotate 대상 token이 존재하지 않으면 오류를 반환한다.
- rotate 대상 token이 이미 revoked 상태이면 오류를 반환한다. - rotate 대상 token이 이미 revoked 상태이면 오류를 반환한다.
- rotate 대상 token의 `expires_at`이 NULL이 아닌 경우(만료일이 설정된 토큰) 오류를 반환한다. rotate는 `--no-expiry`로 발급된 영구 토큰에만 허용된다. - rotate 대상 token의 `expires_at`이 NULL이 아닌 경우(만료일이 설정된 토큰) 오류를 반환한다. rotate는 `--no-expiry`로 발급된 영구 토큰에만 허용된다.
- `--force` 없이 실행하면 확인 프롬프트를 표시한다. - `--force` 없이 실행하면 확인 프롬프트를 표시한다.
- rotate 성공 시 새 token 원문을 1회 출력하고, 이후 list/조회에서는 token_prefix만 표시된다. - rotate 성공 시 새 token 원문을 1회 출력하고, 이후 list/조회에서는 token_prefix만 표시된다. rotate 출력 메타데이터에는 `daemon_id`가 포함되어야 한다.
## Acceptance Criteria ## Acceptance Criteria
1. Given `expires_at IS NULL`인 유효한 service-token 이름, When `pfhub service-token rotate <name> --force`를 실행, Then DB 행의 `token_hash`, `token_prefix`, `runtime_id`가 새 값으로 변경되고 `id`, `permission`, `expires_at`(NULL)은 기존과 동일하다. 1. Given `expires_at IS NULL`인 유효한 service-token 이름, When `pfhub service-token rotate <name> --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회만 표시" 안내 메시지가 함께 표시된다. 2. Given rotate 성공, When CLI 출력, Then 새 token 원문이 화면에 1회 출력되고 "이 토큰은 지금 1회만 표시" 안내 메시지와 함께 `daemon_id` 메타데이터가 표시되어야 한다.
3. Given 존재하지 않는 token 이름, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. 3. Given 존재하지 않는 token 이름, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다.
4. Given revoked 상태의 token, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. 4. Given revoked 상태의 token, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다.
5. Given `expires_at`이 NULL이 아닌(만료일이 설정된) token, When `rotate`, Then "만료일이 설정된 토큰은 rotate할 수 없습니다" 오류를 출력하고 종료 코드 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/portfolio_repository.py` — `rotate_service_token` Protocol stub 추가
- `app/repositories/postgres.py` — `rotate_service_token` UPDATE 구현 추가 - `app/repositories/postgres.py` — `rotate_service_token` UPDATE 구현 추가
- `app/services/auth_service.py` — `rotate_service_token(name)` 서비스 메서드 추가 - `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/test_cli.py` — stub repo에 `rotate_service_token` 추가 및 테스트 케이스 추가
- Tests: - Tests:
- `tests/test_cli.py`: rotate 성공, 없는 이름, revoked 상태 3개 케이스 - `tests/test_cli.py`: rotate 성공, 없는 이름, revoked 상태, `daemon_id` 유지/출력 케이스
## Notes ## Notes
- `expires_at`은 rotate 시 변경하지 않는다. 만료일 변경이 필요하면 revoke + re-issue를 사용한다. - `expires_at`은 rotate 시 변경하지 않는다. 만료일 변경이 필요하면 revoke + re-issue를 사용한다.
- rotate는 `--no-expiry`로 발급된 영구 토큰(`expires_at IS NULL`)에만 허용된다. 만료일이 설정된 토큰은 rotate 대상이 아니다. 해당 토큰은 만료 후 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`까지 불변이라는 뜻은 아니다. - 기존 `runtime_id`를 캐싱한 상태로 계속 사용하면 consumer 식별 불일치가 발생할 수 있다. rotate는 "토큰 원문 교체 시 token name과 메타데이터를 유지한다"는 의미이지, `runtime_id`까지 불변이라는 뜻은 아니다.
- rotate는 daemon provisioning 경로가 아니며, unknown daemon 자동 생성/업서트를 수행하면 안 된다.
- DB 스키마 변경(migration)은 없다. 기존 `auth_tokens` 테이블의 기존 컬럼만 UPDATE한다. - DB 스키마 변경(migration)은 없다. 기존 `auth_tokens` 테이블의 기존 컬럼만 UPDATE한다.
- 구조/인터페이스/런타임 흐름 변경 없음 → minor-change fast path 적용. - 구조/인터페이스/런타임 흐름 변경 없음 → minor-change fast path 적용.
## Change Log ## 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 ### CHG-20260511-02
- Date: 2026-05-11 - Date: 2026-05-11
- Author: repository-maintainers - Author: repository-maintainers
@@ -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 운영 표기: `<username>/<daemon_slug>`
## 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 시나리오 통과 확인
@@ -1,12 +1,13 @@
# req-009: v1 API 지원을 위한 DB 스키마 migration 추가 # req-009: v1 API 지원을 위한 DB 스키마 migration 추가
- Status: Implemented - Status: Approved
- Type: Migration - Type: Migration
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
## Intent ## Intent
- dcy-002에서 요구하는 4개 DB 변경(portfolios.market 컬럼, auth_tokens.runtime_id 컬럼, broker_credentials 테이블, ledger_trades 테이블)을 Alembic revision으로 구현한다. - 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). - IF NOT EXISTS 패턴을 유지해 REQ-002 정책을 준수한다(NFR-4).
## Requirement ## Requirement
@@ -15,6 +16,9 @@
2. auth_tokens 테이블에 runtime_id TEXT UNIQUE NULLABLE 컬럼 추가 2. auth_tokens 테이블에 runtime_id TEXT UNIQUE NULLABLE 컬럼 추가
3. broker_credentials 테이블 신규 생성 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at 포함) 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 포함) 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 ## Acceptance Criteria
1. Given alembic upgrade head 실행 후, When portfolios 테이블을 조회하면, Then market TEXT NULLABLE 컬럼이 존재해야 한다. 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이 발생해야 한다. 5. Given (asset_id, order_id, execution_id) 동일 조합으로 ledger_trades에 두 번 삽입 시, Then UniqueViolation이 발생해야 한다.
6. Given migration을 이미 실행한 DB에 동일 migration을 재실행하면, Then 에러 없이 완료되어야 한다(IF NOT EXISTS 보장). 6. Given migration을 이미 실행한 DB에 동일 migration을 재실행하면, Then 에러 없이 완료되어야 한다(IF NOT EXISTS 보장).
7. Given portfolios 테이블의 기존 행, When migration 실행 후 조회하면, Then market 값이 NULL이어야 한다(기존 데이터 보정 없음, DECIDE_MARKET_DEFAULT Answered). 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 ## Impacted Area
- Docs: docs/data-model.md (REQ-011에서 갱신 예정) - 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 (신규 테이블 쿼리 추가) - Modules: migrations/versions/, app/domain/models.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py
- Tests: tests/test_app.py 또는 migration 검증 테스트 - Tests: tests/test_app.py 또는 migration 검증 테스트, daemon schema integrity 검증
## Notes ## Notes
- NFR-4(IF NOT EXISTS 패턴) 준수 — REQ-002 Alembic migration 정책 기반. - NFR-4(IF NOT EXISTS 패턴) 준수 — REQ-002 Alembic migration 정책 기반.
- broker_credentials 테이블의 encrypted_payload 컬럼은 Fernet 암호화된 ciphertext를 TEXT로 저장한다. 암복호화 로직은 REQ-010 참조. - broker_credentials 테이블의 encrypted_payload 컬럼은 Fernet 암호화된 ciphertext를 TEXT로 저장한다. 암복호화 로직은 REQ-010 참조.
- 컬럼 상세 타입(NUMERIC vs FLOAT, TIMESTAMPTZ 등)은 설계 단계(draft-batch-design)에서 확정한다. - 컬럼 상세 타입(NUMERIC vs FLOAT, TIMESTAMPTZ 등)은 설계 단계(draft-batch-design)에서 확정한다.
- migration revision ID는 구현 시 기존 chain의 마지막 revision(20260423_0013)을 down_revision으로 참조한다. - daemon exact-one assignment의 steady-state `NOT NULL`/강제 cutover는 inventory/backfill 완료 이후 단계적으로 적용할 수 있다. transition migration은 `NULL` backfill을 허용하되 fail-open을 허용하면 안 된다.
- Source Discovery: dcy-002 (영향 범위, NFR-4, DECIDE_MARKET_DEFAULT) - 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 ## 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 ### CHG-20260511-01
- Date: 2026-05-11 - Date: 2026-05-11
- Author: repository-maintainers - Author: repository-maintainers
+14 -14
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-023` - Current: `REQ-029`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## 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-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-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-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-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 | Implemented | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.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 | Implemented | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-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 | Implemented | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-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 | Implemented | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.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-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-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 | Implemented | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.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-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-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) | | 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-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-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-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 ## Recent Change Log Summary
- 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 없음) - 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 정합화
@@ -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")
+2
View File
@@ -99,6 +99,7 @@ class _AuthRepo:
token_type: str, token_type: str,
expires_at: datetime | None = None, expires_at: datetime | None = None,
runtime_id: str | None = None, runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken: ) -> AuthToken:
token = AuthToken( token = AuthToken(
id=f"token-{self._seq}", id=f"token-{self._seq}",
@@ -110,6 +111,7 @@ class _AuthRepo:
expires_at=expires_at, expires_at=expires_at,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
runtime_id=runtime_id, runtime_id=runtime_id,
daemon_id=daemon_id,
) )
self._seq += 1 self._seq += 1
self.tokens[token.id] = token self.tokens[token.id] = token
+2
View File
@@ -123,6 +123,7 @@ class FakeRepo:
token_type: str, token_type: str,
expires_at: datetime | None = None, expires_at: datetime | None = None,
runtime_id: str | None = None, runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken: ) -> AuthToken:
token = AuthToken( token = AuthToken(
id=f"token-{self._token_seq}", id=f"token-{self._token_seq}",
@@ -134,6 +135,7 @@ class FakeRepo:
expires_at=expires_at, expires_at=expires_at,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
runtime_id=runtime_id, runtime_id=runtime_id,
daemon_id=daemon_id,
) )
self._token_seq += 1 self._token_seq += 1
self._auth_tokens[token.id] = token self._auth_tokens[token.id] = token
+248
View File
@@ -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"
+286
View File
@@ -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")
+31 -15
View File
@@ -87,6 +87,7 @@ class _RuntimeIdRoundTripRepo:
token_type: str, token_type: str,
expires_at: datetime | None = None, expires_at: datetime | None = None,
runtime_id: str | None = None, runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken: ) -> AuthToken:
token = AuthToken( token = AuthToken(
id=f"token-{self._seq}", id=f"token-{self._seq}",
@@ -98,6 +99,7 @@ class _RuntimeIdRoundTripRepo:
expires_at=expires_at, expires_at=expires_at,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
runtime_id=runtime_id, runtime_id=runtime_id,
daemon_id=daemon_id,
) )
self._seq += 1 self._seq += 1
self.tokens_by_hash[token_hash] = token self.tokens_by_hash[token_hash] = token
@@ -113,6 +115,7 @@ class _RuntimeIdRoundTripRepo:
permission=token.permission, permission=token.permission,
token_type=token.token_type, token_type=token.token_type,
runtime_id=token.runtime_id, runtime_id=token.runtime_id,
daemon_id=token.daemon_id,
) )
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: 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, active_strategy_id=None,
avabile_strategies=None, avabile_strategies=None,
owner_user_id="user-1", 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() portfolio = _make_portfolio()
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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() 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( mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1", owner_user_id="user-1",
include_disabled=False, include_disabled=False,
managed_by_daemon_id="d-1",
) )
@@ -294,7 +299,7 @@ def test_v1_assets_status_filter(monkeypatch) -> None:
) )
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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() mock_repo = MagicMock()
@@ -328,7 +333,7 @@ def test_v1_assets_strategy_params(monkeypatch) -> None:
) )
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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() mock_repo = MagicMock()
@@ -357,7 +362,7 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
) )
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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() mock_repo = MagicMock()
@@ -379,23 +384,27 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
# GET /v1/user_info # 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) settings = _make_app_db(monkeypatch)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id 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 = MagicMock()
mock_repo.get_token_principal.return_value = principal 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)) client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/user_info", headers=_service_token_header()) resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 404 assert resp.status_code == 200
assert "runtime_id" in resp.json()["detail"] assert resp.json()["runtime_id"] is None
def test_v1_user_info_returns_user(monkeypatch) -> 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: def _make_ledger_principal() -> TokenPrincipal:
return TokenPrincipal( return TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write", 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 = MagicMock()
mock_repo.get_token_principal.return_value = _make_ledger_principal() mock_repo.get_token_principal.return_value = _make_ledger_principal()
if found: 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: else:
mock_repo.get.side_effect = Exception("not found") mock_repo.get.side_effect = Exception("not found")
mock_repo.insert_ledger_trade.return_value = (trade, created) 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, active_strategy_id=None,
avabile_strategies=None, avabile_strategies=None,
owner_user_id="user-1", 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() portfolio = _make_portfolio()
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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() mock_repo = MagicMock()
@@ -809,23 +821,27 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
# GET /v1/user_info # 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") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id 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 = MagicMock()
mock_repo.get_token_principal.return_value = principal 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() client = _make_app()
resp = client.get("/v1/user_info", headers=_service_token_header()) resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 404 assert resp.status_code == 200
assert "runtime_id" in resp.json()["detail"] assert resp.json()["runtime_id"] is None
def test_v1_user_info_returns_user(monkeypatch) -> None: def test_v1_user_info_returns_user(monkeypatch) -> None: