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:
@@ -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를 반복 검증하기 위한 절차를 정의한다.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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] = []
|
||||||
|
|||||||
@@ -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 적용 검증은 배포 시 수행한다.
|
||||||
@@ -16,4 +16,5 @@
|
|||||||
| BAT-006 | standard | release-candidate | REQ-016, REQ-017, REQ-018, REQ-019 | dcy-004 | [bat-006_20260511_portfolio-uuid-migration](./bat-006_20260511_portfolio-uuid-migration/) |
|
| BAT-006 | standard | release-candidate | REQ-016, REQ-017, REQ-018, REQ-019 | dcy-004 | [bat-006_20260511_portfolio-uuid-migration](./bat-006_20260511_portfolio-uuid-migration/) |
|
||||||
| BAT-007 | batch-lite | release-candidate | REQ-001, REQ-002, REQ-003 | dcy-001 | [bat-007_20260511_local-db-validation-and-operations-runbook](./bat-007_20260511_local-db-validation-and-operations-runbook/) |
|
| BAT-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
@@ -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
|
||||||
@@ -10,4 +10,5 @@
|
|||||||
| dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed |
|
| dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed |
|
||||||
| dcy-003_20260502_portfolio-list-user-required | 20260502 | CLI `pfhub portfolio list` — `--user` 옵션 필수화 및 소유자 기반 조회 강제 | [Discovery](./dcy-003_20260502_portfolio-list-user-required.md) | confirmed |
|
| dcy-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
@@ -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.
|
||||||
@@ -12,4 +12,5 @@
|
|||||||
| REL-002 | tooling | released | BAT-002, BAT-005, BAT-008 | minor-change + dcy-002 / REQ-011, REQ-012, REQ-015 | [rel-002_20260511_operator-cli-tooling-and-baseline-docs-sync](./rel-002_20260511_operator-cli-tooling-and-baseline-docs-sync.md) |
|
| REL-002 | tooling | released | BAT-002, BAT-005, BAT-008 | minor-change + dcy-002 / REQ-011, REQ-012, REQ-015 | [rel-002_20260511_operator-cli-tooling-and-baseline-docs-sync](./rel-002_20260511_operator-cli-tooling-and-baseline-docs-sync.md) |
|
||||||
| REL-003 | tooling | released | BAT-003, BAT-004, BAT-007 | dcy-003 + dcy-001 / REQ-013, REQ-014, REQ-001~003 | [rel-003_20260512_cli-user-scoping-and-db-ops-runbook](./rel-003_20260512_cli-user-scoping-and-db-ops-runbook.md) |
|
| REL-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
@@ -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
|
||||||
|
|||||||
+57
-12
@@ -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
|
||||||
|
|||||||
+15
-15
@@ -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-12: REQ-020~022 Approved 전환 — confirm-req 게이트 통과 (필수 필드, AC, blocker 없음)
|
- 2026-05-13: REQ-023, REQ-028 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-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-11: REQ-011, REQ-012, REQ-015 Implemented 전환 — BAT-008/BAT-002/BAT-005 verification evidence 확인
|
- 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-11: REQ-005 CHG-20260511-02 — service-token 발급 API 경로를 POST /auth/service-tokens로 명확화
|
- 2026-05-13: REQ-021 CHG-20260513-01 — token provenance 절차에 `daemon_id` evidence 필드와 rotate invariant 반영, Status 유지
|
||||||
- 2026-05-11: REQ-012 CHG-20260511-01 — rotate 후 runtime_id lifecycle 및 consumer 재조회 필요성 명시
|
- 2026-05-12: REQ-020~022 Approved 전환 — confirm-req 게이트 통과 (필수 필드, AC, blocker 없음)
|
||||||
- 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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user