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:
@@ -40,6 +40,12 @@ def list_assets(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
) -> AssetsResponse:
|
||||
if auth.daemon_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="daemon_id not assigned to this token",
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
@@ -48,6 +54,7 @@ def list_assets(
|
||||
portfolios = repo.list_portfolios_with_strategies(
|
||||
owner_user_id=auth.user_id,
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id=auth.daemon_id,
|
||||
)
|
||||
|
||||
# Fetch strategy_params per portfolio
|
||||
|
||||
+19
-2
@@ -49,15 +49,32 @@ def record_ledger_trade(
|
||||
settings = get_settings()
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
try:
|
||||
# B-9: Validate asset_id (portfolio) existence
|
||||
# Validate asset_id (portfolio) existence
|
||||
try:
|
||||
repo.get(asset_id)
|
||||
portfolio = repo.get(asset_id)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Asset not found: {asset_id}",
|
||||
)
|
||||
|
||||
# Daemon assignment authorization: NULL token daemon_id, unassigned, or mismatch → 403
|
||||
if auth.daemon_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="daemon_id not assigned to this token",
|
||||
)
|
||||
if portfolio.managed_by_daemon_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Asset is not assigned to any daemon",
|
||||
)
|
||||
if portfolio.managed_by_daemon_id != auth.daemon_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Token daemon does not match asset daemon assignment",
|
||||
)
|
||||
|
||||
trade, created = repo.insert_ledger_trade(
|
||||
asset_id=asset_id,
|
||||
order_id=body.order_id,
|
||||
|
||||
@@ -31,12 +31,6 @@ def get_user_info(
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> UserInfoResponse:
|
||||
if not auth.runtime_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="runtime_id not assigned to this token",
|
||||
)
|
||||
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
try:
|
||||
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
|
||||
@@ -76,6 +70,7 @@ def get_user_info(
|
||||
|
||||
return UserInfoResponse(
|
||||
runtime_id=auth.runtime_id,
|
||||
daemon_id=auth.daemon_id,
|
||||
trading_enabled=user.is_active,
|
||||
disabled_reason=disabled_reason,
|
||||
brokers=brokers,
|
||||
|
||||
@@ -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."),
|
||||
ttl_days: int | None = typer.Option(None, "--ttl-days", help="Token TTL in days. Default: 365."),
|
||||
no_expiry: bool = typer.Option(False, "--no-expiry", help="Issue a token with no expiry."),
|
||||
daemon_id: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon ID to bind to this token."),
|
||||
) -> None:
|
||||
def _issue(service: AuthService, app_context):
|
||||
return service.issue_service_token(
|
||||
@@ -70,6 +71,7 @@ def issue_service_token(
|
||||
permission=permission,
|
||||
expires_in_days=ttl_days,
|
||||
no_expiry=no_expiry,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
issued = _with_service(ctx, _issue)
|
||||
@@ -79,6 +81,32 @@ def issue_service_token(
|
||||
expires_at=issued.auth_token.expires_at,
|
||||
revoked=issued.auth_token.revoked_at is not None,
|
||||
runtime_id=issued.auth_token.runtime_id,
|
||||
daemon_id=issued.auth_token.daemon_id,
|
||||
token=issued.token,
|
||||
)
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
|
||||
if ctx.obj.output_format != "json":
|
||||
ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}")
|
||||
|
||||
|
||||
@token_app.command("rotate")
|
||||
def rotate_service_token(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Service token name to rotate."),
|
||||
) -> None:
|
||||
"""Rotate a service token: revoke old and issue new with same daemon_id binding."""
|
||||
def _rotate(service: AuthService, app_context):
|
||||
return service.rotate_service_token(name)
|
||||
|
||||
issued = _with_service(ctx, _rotate)
|
||||
response = ServiceTokenIssueCliResponse(
|
||||
token_id=issued.auth_token.id,
|
||||
name=name,
|
||||
expires_at=issued.auth_token.expires_at,
|
||||
revoked=issued.auth_token.revoked_at is not None,
|
||||
runtime_id=issued.auth_token.runtime_id,
|
||||
daemon_id=issued.auth_token.daemon_id,
|
||||
token=issued.token,
|
||||
)
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Callable
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from app.cli.commands.daemon import daemon_app
|
||||
from app.cli.commands.health import register_health_commands
|
||||
from app.cli.commands.service_tokens import token_app
|
||||
from app.cli.commands.portfolio import portfolio_app
|
||||
@@ -53,6 +54,7 @@ def create_app(
|
||||
app.add_typer(user_app, name="user")
|
||||
app.add_typer(token_app, name="service-token")
|
||||
app.add_typer(credential_app, name="credential")
|
||||
app.add_typer(daemon_app, name="daemon")
|
||||
register_health_commands(app)
|
||||
return app
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class AuthContext:
|
||||
token_type: str | None = None
|
||||
token_source: str = "env"
|
||||
runtime_id: str | None = None
|
||||
daemon_id: str | None = None
|
||||
|
||||
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
|
||||
repository = get_portfolio_repository_from_settings(settings)
|
||||
@@ -68,6 +69,7 @@ def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioR
|
||||
token_type=principal.token_type,
|
||||
token_source="db",
|
||||
runtime_id=principal.runtime_id,
|
||||
daemon_id=principal.daemon_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ class User:
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Daemon:
|
||||
id: str
|
||||
owner_user_id: str
|
||||
daemon_slug: str
|
||||
display_name: str | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthToken:
|
||||
id: str
|
||||
@@ -42,6 +53,7 @@ class AuthToken:
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
runtime_id: str | None = None
|
||||
daemon_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,6 +63,7 @@ class TokenPrincipal:
|
||||
permission: str
|
||||
token_type: str
|
||||
runtime_id: str | None = None
|
||||
daemon_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -72,6 +85,7 @@ class Portfolio:
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
last_trade_at: datetime | None = None
|
||||
managed_by_daemon_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
@@ -53,6 +53,7 @@ class PortfolioRepository(Protocol):
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
daemon_id: str | None = None,
|
||||
) -> AuthToken: ...
|
||||
|
||||
def list_auth_tokens(self, user_id: str) -> list[AuthToken]: ...
|
||||
@@ -70,6 +71,30 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
|
||||
|
||||
def create_daemon(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
*,
|
||||
daemon_slug: str,
|
||||
display_name: str | None = None,
|
||||
) -> Daemon: ...
|
||||
|
||||
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None: ...
|
||||
|
||||
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None: ...
|
||||
|
||||
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]: ...
|
||||
|
||||
def update_daemon(
|
||||
self,
|
||||
daemon_id: str,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> Daemon: ...
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio: ...
|
||||
|
||||
def get_or_create(
|
||||
@@ -114,6 +139,15 @@ class PortfolioRepository(Protocol):
|
||||
include_tags: list[str] | None = None,
|
||||
exclude_tags: list[str] | None = None,
|
||||
match_mode: str | None = None,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
) -> list[Portfolio]: ...
|
||||
|
||||
def list_portfolios_with_strategies(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
) -> list[Portfolio]: ...
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -39,7 +39,8 @@ _SELECT_AUTH_TOKEN = """
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
created_at,
|
||||
runtime_id
|
||||
runtime_id,
|
||||
daemon_id
|
||||
FROM auth_tokens
|
||||
"""
|
||||
|
||||
@@ -59,7 +60,8 @@ _SELECT_PORTFOLIO = """
|
||||
owner_user_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_trade_at
|
||||
last_trade_at,
|
||||
managed_by_daemon_id
|
||||
FROM portfolios
|
||||
"""
|
||||
|
||||
@@ -143,6 +145,7 @@ class PostgresPortfolioRepository:
|
||||
"""
|
||||
)
|
||||
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS runtime_id TEXT")
|
||||
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS daemon_id TEXT")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
|
||||
@@ -197,6 +200,7 @@ class PostgresPortfolioRepository:
|
||||
)
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS name TEXT")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT")
|
||||
cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
|
||||
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
|
||||
cur.execute(
|
||||
@@ -263,6 +267,26 @@ class PostgresPortfolioRepository:
|
||||
ON portfolio_tags (tag_value, portfolio_id)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS daemons (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
daemon_slug TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (owner_user_id, daemon_slug)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_daemons_owner
|
||||
ON daemons (owner_user_id)
|
||||
"""
|
||||
)
|
||||
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
|
||||
if cur.fetchone()[0]:
|
||||
cur.execute(
|
||||
@@ -304,6 +328,7 @@ class PostgresPortfolioRepository:
|
||||
revoked_at=row[8] if len(row) > 8 else None,
|
||||
created_at=row[9] if len(row) > 9 else None,
|
||||
runtime_id=row[10] if len(row) > 10 else None,
|
||||
daemon_id=row[11] if len(row) > 11 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -336,6 +361,7 @@ class PostgresPortfolioRepository:
|
||||
created_at=row[12] if len(row) > 12 else None,
|
||||
updated_at=row[13] if len(row) > 13 else None,
|
||||
last_trade_at=row[14] if len(row) > 14 else None,
|
||||
managed_by_daemon_id=row[15] if len(row) > 15 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -537,6 +563,7 @@ class PostgresPortfolioRepository:
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
daemon_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -547,14 +574,16 @@ class PostgresPortfolioRepository:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth_tokens (
|
||||
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
|
||||
id, user_id, name, token_prefix, token_hash, permission, token_type,
|
||||
expires_at, runtime_id, daemon_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
id, user_id, name, token_prefix, permission, token_type,
|
||||
expires_at, last_used_at, revoked_at, created_at, runtime_id
|
||||
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
|
||||
""",
|
||||
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
|
||||
(token_id, user_id, name, token_prefix, token_hash, permission, token_type,
|
||||
expires_at, runtime_id, daemon_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_auth_token(row)
|
||||
@@ -617,7 +646,7 @@ class PostgresPortfolioRepository:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
|
||||
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id, t.daemon_id
|
||||
FROM auth_tokens t
|
||||
WHERE token_hash = %s
|
||||
AND t.revoked_at IS NULL
|
||||
@@ -635,6 +664,7 @@ class PostgresPortfolioRepository:
|
||||
permission=row[2],
|
||||
token_type=row[3],
|
||||
runtime_id=row[4] if len(row) > 4 else None,
|
||||
daemon_id=row[5] if len(row) > 5 else None,
|
||||
)
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
@@ -794,6 +824,7 @@ class PostgresPortfolioRepository:
|
||||
include_tags: list[str] | None = None,
|
||||
exclude_tags: list[str] | None = None,
|
||||
match_mode: str | None = None,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
) -> list[Portfolio]:
|
||||
self.ensure_schema()
|
||||
query = _SELECT_PORTFOLIO
|
||||
@@ -808,6 +839,9 @@ class PostgresPortfolioRepository:
|
||||
if owner_user_id is not None:
|
||||
clauses.append("owner_user_id = %s")
|
||||
params.append(owner_user_id)
|
||||
if managed_by_daemon_id is not None:
|
||||
clauses.append("managed_by_daemon_id = %s")
|
||||
params.append(managed_by_daemon_id)
|
||||
normalized_mode = (match_mode or "OR").upper()
|
||||
if include_tags:
|
||||
if normalized_mode == "AND":
|
||||
@@ -1302,6 +1336,198 @@ class PostgresPortfolioRepository:
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
|
||||
def list_portfolios_with_strategies(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
) -> list[Portfolio]:
|
||||
status_filter = None if include_disabled else "enabled"
|
||||
return self.list_portfolios(
|
||||
owner_user_id=owner_user_id,
|
||||
status=status_filter,
|
||||
managed_by_daemon_id=managed_by_daemon_id,
|
||||
)
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
|
||||
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
|
||||
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL",
|
||||
(name,),
|
||||
)
|
||||
existing_row = cur.fetchone()
|
||||
if existing_row is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
existing = self._row_to_auth_token(existing_row)
|
||||
|
||||
new_raw = generate_token()
|
||||
new_runtime_id = str(uuid.uuid4())
|
||||
new_hash = hash_token(new_raw)
|
||||
new_prefix = _token_prefix(new_raw)
|
||||
|
||||
cur.execute("UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s", (existing.id,))
|
||||
|
||||
token_id = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth_tokens (
|
||||
id, user_id, name, token_prefix, token_hash, permission, token_type,
|
||||
expires_at, runtime_id, daemon_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
id, user_id, name, token_prefix, permission, token_type,
|
||||
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
|
||||
""",
|
||||
(
|
||||
token_id,
|
||||
existing.user_id,
|
||||
name,
|
||||
new_prefix,
|
||||
new_hash,
|
||||
existing.permission,
|
||||
"service",
|
||||
existing.expires_at,
|
||||
new_runtime_id,
|
||||
existing.daemon_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return new_raw, self._row_to_auth_token(row)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_daemon(row: Any) -> Daemon:
|
||||
if row is None:
|
||||
raise ValueError("데몬을 찾을 수 없습니다")
|
||||
return Daemon(
|
||||
id=row[0],
|
||||
owner_user_id=row[1],
|
||||
daemon_slug=row[2],
|
||||
display_name=row[3],
|
||||
is_active=bool(row[4]),
|
||||
created_at=row[5] if len(row) > 5 else None,
|
||||
updated_at=row[6] if len(row) > 6 else None,
|
||||
)
|
||||
|
||||
def create_daemon(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
*,
|
||||
daemon_slug: str,
|
||||
display_name: str | None = None,
|
||||
) -> Daemon:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
daemon_id = str(uuid.uuid4())
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO daemons (id, owner_user_id, daemon_slug, display_name)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
|
||||
""",
|
||||
(daemon_id, owner_user_id, daemon_slug, display_name),
|
||||
)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError(f"이미 존재하는 daemon slug입니다: {daemon_slug}") from exc
|
||||
row = cur.fetchone()
|
||||
return self._row_to_daemon(row)
|
||||
|
||||
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
|
||||
FROM daemons WHERE id = %s
|
||||
""",
|
||||
(daemon_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_daemon(row)
|
||||
|
||||
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
|
||||
FROM daemons WHERE owner_user_id = %s AND daemon_slug = %s
|
||||
""",
|
||||
(owner_user_id, daemon_slug),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_daemon(row)
|
||||
|
||||
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
|
||||
FROM daemons WHERE owner_user_id = %s ORDER BY created_at ASC
|
||||
""",
|
||||
(owner_user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_daemon(row) for row in rows]
|
||||
|
||||
def update_daemon(
|
||||
self,
|
||||
daemon_id: str,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> Daemon:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
|
||||
FROM daemons WHERE id = %s
|
||||
""",
|
||||
(daemon_id,),
|
||||
)
|
||||
existing_row = cur.fetchone()
|
||||
if existing_row is None:
|
||||
raise ValueError("데몬을 찾을 수 없습니다")
|
||||
|
||||
assignments: list[str] = []
|
||||
params: list[object] = []
|
||||
if display_name is not None:
|
||||
assignments.append("display_name = %s")
|
||||
params.append(display_name)
|
||||
if is_active is not None:
|
||||
assignments.append("is_active = %s")
|
||||
params.append(is_active)
|
||||
|
||||
if not assignments:
|
||||
return self._row_to_daemon(existing_row)
|
||||
|
||||
assignments.append("updated_at = NOW()")
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE daemons
|
||||
SET {', '.join(assignments)}
|
||||
WHERE id = %s
|
||||
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
|
||||
""",
|
||||
(*params, daemon_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_daemon(row)
|
||||
|
||||
|
||||
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
|
||||
with psycopg.connect(
|
||||
|
||||
@@ -76,12 +76,14 @@ class IssueServiceTokenRequest(BaseModel):
|
||||
permission: Literal["read", "trade-write", "admin"] = "read"
|
||||
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
no_expiry: bool = False
|
||||
daemon_id: str | None = None
|
||||
|
||||
|
||||
class IssueServiceTokenApiResponse(BaseModel):
|
||||
token: str
|
||||
token_type: Literal["bearer"] = "bearer"
|
||||
runtime_id: str | None = None
|
||||
daemon_id: str | None = None
|
||||
auth_token: AuthTokenResponse
|
||||
|
||||
|
||||
@@ -91,6 +93,7 @@ class ServiceTokenIssueCliResponse(BaseModel):
|
||||
expires_at: datetime | None = None
|
||||
revoked: bool
|
||||
runtime_id: str | None = None
|
||||
daemon_id: str | None = None
|
||||
token: str | None = None
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -53,7 +53,8 @@ class BrokerItem(BaseModel):
|
||||
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
runtime_id: str
|
||||
runtime_id: str | None = None
|
||||
daemon_id: str | None = None
|
||||
trading_enabled: bool
|
||||
disabled_reason: str | None = None
|
||||
brokers: list[BrokerItem] = []
|
||||
|
||||
@@ -117,11 +117,19 @@ class AuthService:
|
||||
permission: str = "read",
|
||||
expires_in_days: int | None = None,
|
||||
no_expiry: bool = False,
|
||||
daemon_id: str | None = None,
|
||||
) -> IssuedServiceToken:
|
||||
user = self._repository.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise ValueError(f"사용자를 찾을 수 없습니다: {username}")
|
||||
|
||||
if daemon_id is not None:
|
||||
daemon = self._repository.get_daemon_by_id(daemon_id)
|
||||
if daemon is None or not daemon.is_active:
|
||||
raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}")
|
||||
if daemon.owner_user_id != user.id:
|
||||
raise ValueError("데몬이 해당 사용자에 속하지 않습니다")
|
||||
|
||||
expires_at: datetime | None
|
||||
if no_expiry:
|
||||
if expires_in_days is not None:
|
||||
@@ -143,11 +151,16 @@ class AuthService:
|
||||
token_type="service",
|
||||
expires_at=expires_at,
|
||||
runtime_id=runtime_id,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
|
||||
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
|
||||
|
||||
def rotate_service_token(self, name: str) -> IssuedServiceToken:
|
||||
raw_token, auth_token = self._repository.rotate_service_token(name)
|
||||
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
|
||||
|
||||
def revoke_service_token(self, name: str) -> datetime:
|
||||
existing = self._repository.get_service_token_by_name(name)
|
||||
if existing is None:
|
||||
|
||||
Reference in New Issue
Block a user