feat: extend daemon model with daemon status tracking and list support
- Add daemon status tracking with state and metrics (idle, running, error) - Implement pfhub daemon list CLI command for viewing registered daemons - Extend auth schema and service for daemon lifecycle management - Add postgres repository queries for daemon state persistence - Expand test coverage for daemon registry and status transitions - Update rel-006 release document with daemon model verification results - Support daemon graceful shutdown and error recovery patterns
This commit is contained in:
@@ -8,7 +8,7 @@ import typer
|
|||||||
|
|
||||||
from app.cli.core.output import mask_token_text, render_service_token_result
|
from app.cli.core.output import mask_token_text, render_service_token_result
|
||||||
from app.cli.core.utils import render_output
|
from app.cli.core.utils import render_output
|
||||||
from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRevokeCliResponse
|
from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRebindCliResponse, ServiceTokenRevokeCliResponse
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
|
|
||||||
token_app = typer.Typer(help="Service token lifecycle commands.")
|
token_app = typer.Typer(help="Service token lifecycle commands.")
|
||||||
@@ -42,6 +42,7 @@ def list_service_tokens(
|
|||||||
token_prefix=t.token_prefix,
|
token_prefix=t.token_prefix,
|
||||||
permission=t.permission,
|
permission=t.permission,
|
||||||
token_type=t.token_type,
|
token_type=t.token_type,
|
||||||
|
daemon_id=t.daemon_id,
|
||||||
expires_at=t.expires_at,
|
expires_at=t.expires_at,
|
||||||
last_used_at=t.last_used_at,
|
last_used_at=t.last_used_at,
|
||||||
revoked_at=t.revoked_at,
|
revoked_at=t.revoked_at,
|
||||||
@@ -115,6 +116,30 @@ def rotate_service_token(
|
|||||||
ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}")
|
ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}")
|
||||||
|
|
||||||
|
|
||||||
|
@token_app.command("rebind")
|
||||||
|
def rebind_service_token(
|
||||||
|
ctx: typer.Context,
|
||||||
|
name: str = typer.Argument(..., help="Service token name to rebind."),
|
||||||
|
daemon_id: str = typer.Option(..., "--daemon-id", help="Existing daemon ID to bind to this token."),
|
||||||
|
) -> None:
|
||||||
|
"""Rebind an active service token to an existing daemon."""
|
||||||
|
|
||||||
|
def _rebind(service: AuthService, app_context):
|
||||||
|
return service.rebind_service_token(name, daemon_id)
|
||||||
|
|
||||||
|
previous_daemon_id, rebound = _with_service(ctx, _rebind)
|
||||||
|
response = ServiceTokenRebindCliResponse(
|
||||||
|
token_id=rebound.id,
|
||||||
|
name=name,
|
||||||
|
expires_at=rebound.expires_at,
|
||||||
|
revoked=rebound.revoked_at is not None,
|
||||||
|
runtime_id=rebound.runtime_id,
|
||||||
|
daemon_id=rebound.daemon_id,
|
||||||
|
previous_daemon_id=previous_daemon_id,
|
||||||
|
)
|
||||||
|
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||||
|
|
||||||
|
|
||||||
@token_app.command("revoke")
|
@token_app.command("revoke")
|
||||||
def revoke_service_token(
|
def revoke_service_token(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ def print_service_token_table(console: Console, tokens: list[AuthTokenResponse])
|
|||||||
table.add_column("Name", style="cyan", no_wrap=True)
|
table.add_column("Name", style="cyan", no_wrap=True)
|
||||||
table.add_column("Permission", no_wrap=True)
|
table.add_column("Permission", no_wrap=True)
|
||||||
table.add_column("User ID", no_wrap=True)
|
table.add_column("User ID", no_wrap=True)
|
||||||
|
table.add_column("Daemon ID", no_wrap=True)
|
||||||
table.add_column("Prefix", no_wrap=True)
|
table.add_column("Prefix", no_wrap=True)
|
||||||
table.add_column("Expires At", no_wrap=True)
|
table.add_column("Expires At", no_wrap=True)
|
||||||
table.add_column("Revoked At", no_wrap=True)
|
table.add_column("Revoked At", no_wrap=True)
|
||||||
@@ -177,6 +178,7 @@ def print_service_token_table(console: Console, tokens: list[AuthTokenResponse])
|
|||||||
token.name,
|
token.name,
|
||||||
token.permission,
|
token.permission,
|
||||||
token.user_id,
|
token.user_id,
|
||||||
|
token.daemon_id or "-",
|
||||||
token.token_prefix,
|
token.token_prefix,
|
||||||
token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never",
|
token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never",
|
||||||
token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-",
|
token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ class PortfolioRepository(Protocol):
|
|||||||
|
|
||||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
|
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
|
||||||
|
|
||||||
|
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken: ...
|
||||||
|
|
||||||
def create_daemon(
|
def create_daemon(
|
||||||
self,
|
self,
|
||||||
owner_user_id: str,
|
owner_user_id: str,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
@@ -633,7 +633,7 @@ class PostgresPortfolioRepository:
|
|||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
with self._connect().cursor() as cur:
|
with self._connect().cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service'",
|
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1",
|
||||||
(name,),
|
(name,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -1399,6 +1399,32 @@ class PostgresPortfolioRepository:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return new_raw, self._row_to_auth_token(row)
|
return new_raw, self._row_to_auth_token(row)
|
||||||
|
|
||||||
|
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
|
||||||
|
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 ORDER BY created_at DESC LIMIT 1",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
existing_row = cur.fetchone()
|
||||||
|
if existing_row is None:
|
||||||
|
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||||
|
existing = self._row_to_auth_token(existing_row)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE auth_tokens
|
||||||
|
SET daemon_id = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING
|
||||||
|
id, user_id, name, token_prefix, permission, token_type,
|
||||||
|
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
|
||||||
|
""",
|
||||||
|
(daemon_id, existing.id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return self._row_to_auth_token(row)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _row_to_daemon(row: Any) -> Daemon:
|
def _row_to_daemon(row: Any) -> Daemon:
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class AuthTokenResponse(BaseModel):
|
|||||||
token_prefix: str
|
token_prefix: str
|
||||||
permission: Literal["read", "trade-write", "admin"]
|
permission: Literal["read", "trade-write", "admin"]
|
||||||
token_type: Literal["session", "client", "service"]
|
token_type: Literal["session", "client", "service"]
|
||||||
|
daemon_id: str | None = None
|
||||||
expires_at: datetime | None = None
|
expires_at: datetime | None = None
|
||||||
last_used_at: datetime | None = None
|
last_used_at: datetime | None = None
|
||||||
revoked_at: datetime | None = None
|
revoked_at: datetime | None = None
|
||||||
@@ -97,6 +98,16 @@ class ServiceTokenIssueCliResponse(BaseModel):
|
|||||||
token: str | None = None
|
token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTokenRebindCliResponse(BaseModel):
|
||||||
|
token_id: str
|
||||||
|
name: str
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
revoked: bool
|
||||||
|
runtime_id: str | None = None
|
||||||
|
daemon_id: str | None = None
|
||||||
|
previous_daemon_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ServiceTokenRevokeCliResponse(BaseModel):
|
class ServiceTokenRevokeCliResponse(BaseModel):
|
||||||
token_id: str
|
token_id: str
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -161,6 +161,21 @@ class AuthService:
|
|||||||
raw_token, auth_token = self._repository.rotate_service_token(name)
|
raw_token, auth_token = self._repository.rotate_service_token(name)
|
||||||
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
|
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
|
||||||
|
|
||||||
|
def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]:
|
||||||
|
existing = self._repository.get_service_token_by_name(name)
|
||||||
|
if existing is None:
|
||||||
|
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||||
|
|
||||||
|
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 != existing.user_id:
|
||||||
|
raise ValueError("데몬이 해당 토큰 사용자에 속하지 않습니다")
|
||||||
|
|
||||||
|
previous_daemon_id = existing.daemon_id
|
||||||
|
rebound = self._repository.rebind_service_token(name, daemon_id)
|
||||||
|
return previous_daemon_id, rebound
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -47,22 +47,32 @@
|
|||||||
|
|
||||||
## Verification Checklist
|
## Verification Checklist
|
||||||
|
|
||||||
|
> Local validation snapshot (KST 2026-05-14 08:31): `docker-compose.local-test.yml` 기반 TimescaleDB와 로컬 app process smoke로 확인했다. 아래 체크는 actual deploy evidence가 아니라 local pre-release validation 결과다.
|
||||||
|
>
|
||||||
|
> 참고: migration 20260513_0016은 local test DB에 online apply로 검증했다. offline `alembic --sql` dry-run은 migration 내부의 `inspect(op.get_bind())` 사용 때문에 현재 그대로는 성립하지 않는다.
|
||||||
|
|
||||||
### 배포 전
|
### 배포 전
|
||||||
- [ ] migration 20260513_0016이 target DB에 적용 가능한지 dry-run 확인 (alembic history 정상)
|
- [x] migration 20260513_0016이 local test DB(target DB 대체 검증)에 online 적용 가능한지 확인 (`pfhub migrate-db` → head, `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`)
|
- [x] 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` → `0`)
|
||||||
- [ ] daemon mismatch dry-run 확인 (`SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NOT NULL`)
|
- [x] daemon mismatch dry-run 확인 (`SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NOT NULL` → `0`)
|
||||||
- [ ] `pytest tests/` 128 passed / 7 failed (pre-existing) 확인
|
- [x] `pytest tests/` 결과 확인 (`128 passed / 7 failed`, pre-existing CLI 실패와 일치)
|
||||||
- [ ] release approver 승인 확보
|
- [ ] release approver 승인 확보
|
||||||
|
|
||||||
### 배포 후
|
### 배포 후
|
||||||
- [ ] `GET /health` → HTTP 200, `{"status": "ok", "service": "portfolio-hub"}`
|
- [x] `GET /health` → HTTP 200, `{"status": "ok", "service": "portfolio-hub"}`
|
||||||
- [ ] `pfhub_DB_ENABLED=true pfhub health-check` → exit code 0
|
- local app process smoke 결과: `status=ok`, `service=portfolio-hub`, `version=0.1.0`
|
||||||
- [ ] `pfhub daemon create alpha-bot --user <admin_user>` → daemon_id 반환 확인
|
- [x] `pfhub_DB_ENABLED=true pfhub health-check` → exit code 0
|
||||||
- [ ] `pfhub service-token issue test-daemon-token --user <user> --daemon-id <daemon_id> --no-expiry` → token 발급 확인
|
- [x] `pfhub daemon create alpha-bot --user <admin_user>` → daemon_id 반환 확인
|
||||||
- [ ] `GET /v1/assets` (daemon-bound token) → HTTP 200, daemon 소유 asset만 포함
|
- local CLI plain output에서 `Daemon(id=...)` 확인. 단, `--format json`은 datetime serialization bug로 실패한다.
|
||||||
- [ ] `GET /v1/assets` (NULL daemon_id token) → HTTP 403 확인
|
- [x] `pfhub service-token issue test-daemon-token --user <user> --daemon-id <daemon_id> --no-expiry` → token 발급 확인
|
||||||
- [ ] `GET /v1/user_info` (daemon-bound token) → HTTP 200, `daemon_id` 필드 포함
|
- local CLI JSON output에서 `token_id`, `runtime_id`, `daemon_id` 반환 확인. raw token은 의도적으로 마스킹된다.
|
||||||
|
- [x] `GET /v1/assets` (daemon-bound token) → HTTP 200, daemon 소유 asset만 포함
|
||||||
|
- local smoke fixture에서 매칭 daemon portfolio 1건만 반환되고 다른 daemon portfolio는 비노출 확인
|
||||||
|
- [x] `GET /v1/assets` (NULL daemon_id token) → HTTP 403 확인
|
||||||
|
- 응답 detail: `daemon_id not assigned to this token`
|
||||||
|
- [x] `GET /v1/user_info` (daemon-bound token) → HTTP 200, `daemon_id` 필드 포함
|
||||||
- [ ] `pfhub service-token rotate test-daemon-token --force` → 새 runtime_id, 동일 daemon_id 확인
|
- [ ] `pfhub service-token rotate test-daemon-token --force` → 새 runtime_id, 동일 daemon_id 확인
|
||||||
|
- local CLI 검증 실패: `idx_auth_tokens_service_name_unique` 제약으로 rotate insert가 막힌다.
|
||||||
- [ ] journalctl/Caddy access log에 ERROR 없음 확인
|
- [ ] journalctl/Caddy access log에 ERROR 없음 확인
|
||||||
|
|
||||||
## Release Execution Evidence
|
## Release Execution Evidence
|
||||||
|
|||||||
+147
-2
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
|
|||||||
from app.cli.main import create_app
|
from app.cli.main import create_app
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.core.security import hash_token, token_prefix
|
from app.core.security import hash_token, token_prefix
|
||||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||||
|
|
||||||
|
|
||||||
class FakeRepo:
|
class FakeRepo:
|
||||||
@@ -20,7 +20,9 @@ class FakeRepo:
|
|||||||
self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3")
|
self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3")
|
||||||
self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set}
|
self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set}
|
||||||
self._users: dict[str, User] = {}
|
self._users: dict[str, User] = {}
|
||||||
|
self._daemons: dict[str, Daemon] = {}
|
||||||
self._user_seq = 1
|
self._user_seq = 1
|
||||||
|
self._daemon_seq = 1
|
||||||
self._comments: dict[str, PortfolioComment] = {}
|
self._comments: dict[str, PortfolioComment] = {}
|
||||||
self._tags: dict[str, PortfolioTag] = {}
|
self._tags: dict[str, PortfolioTag] = {}
|
||||||
self._auth_tokens: dict[str, AuthToken] = {}
|
self._auth_tokens: dict[str, AuthToken] = {}
|
||||||
@@ -110,7 +112,60 @@ class FakeRepo:
|
|||||||
return next((u for u in self._users.values() if u.username == username), None)
|
return next((u for u in self._users.values() if u.username == username), None)
|
||||||
|
|
||||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||||
return next((t for t in self._auth_tokens.values() if t.name == name and t.token_type == "service"), None)
|
return next(
|
||||||
|
(
|
||||||
|
t
|
||||||
|
for t in sorted(
|
||||||
|
self._auth_tokens.values(),
|
||||||
|
key=lambda token: token.created_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
if t.name == name and t.token_type == "service" and t.revoked_at is None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_daemon(
|
||||||
|
self,
|
||||||
|
owner_user_id: str,
|
||||||
|
*,
|
||||||
|
daemon_slug: str,
|
||||||
|
display_name: str | None = None,
|
||||||
|
) -> Daemon:
|
||||||
|
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
|
||||||
|
return daemon
|
||||||
|
|
||||||
|
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
|
||||||
|
return self._daemons.get(daemon_id)
|
||||||
|
|
||||||
|
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
|
||||||
|
return [daemon for daemon in self._daemons.values() if daemon.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("데몬을 찾을 수 없습니다")
|
||||||
|
if display_name is not None:
|
||||||
|
daemon.display_name = display_name
|
||||||
|
if is_active is not None:
|
||||||
|
daemon.is_active = is_active
|
||||||
|
daemon.updated_at = datetime.now(timezone.utc)
|
||||||
|
return daemon
|
||||||
|
|
||||||
def create_auth_token(
|
def create_auth_token(
|
||||||
self,
|
self,
|
||||||
@@ -161,6 +216,13 @@ class FakeRepo:
|
|||||||
]
|
]
|
||||||
return sorted(results, key=lambda t: t.created_at or datetime.min, reverse=True)
|
return sorted(results, key=lambda t: t.created_at or datetime.min, reverse=True)
|
||||||
|
|
||||||
|
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
|
||||||
|
token = self.get_service_token_by_name(name)
|
||||||
|
if token is None:
|
||||||
|
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||||
|
token.daemon_id = daemon_id
|
||||||
|
return token
|
||||||
|
|
||||||
def get(self, portfolio_id: str) -> Portfolio:
|
def get(self, portfolio_id: str) -> Portfolio:
|
||||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||||
@@ -837,6 +899,7 @@ def test_cli_help_exposes_bot_token_commands() -> None:
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "issue" in result.stdout
|
assert "issue" in result.stdout
|
||||||
|
assert "rebind" in result.stdout
|
||||||
assert "revoke" in result.stdout
|
assert "revoke" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
@@ -930,6 +993,7 @@ def test_cli_service_token_revoke_force_succeeds() -> None:
|
|||||||
def test_cli_service_token_list_returns_active_tokens() -> None:
|
def test_cli_service_token_list_returns_active_tokens() -> None:
|
||||||
repo = FakeRepo(_portfolio())
|
repo = FakeRepo(_portfolio())
|
||||||
user = repo.create_user("alice", permission="read")
|
user = repo.create_user("alice", permission="read")
|
||||||
|
daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot")
|
||||||
repo.create_auth_token(
|
repo.create_auth_token(
|
||||||
user.id,
|
user.id,
|
||||||
name="active-token",
|
name="active-token",
|
||||||
@@ -937,6 +1001,7 @@ def test_cli_service_token_list_returns_active_tokens() -> None:
|
|||||||
token_prefix=token_prefix("tok-1"),
|
token_prefix=token_prefix("tok-1"),
|
||||||
permission="read",
|
permission="read",
|
||||||
token_type="service",
|
token_type="service",
|
||||||
|
daemon_id=daemon.id,
|
||||||
)
|
)
|
||||||
runner = build_runner(repo)
|
runner = build_runner(repo)
|
||||||
|
|
||||||
@@ -947,6 +1012,29 @@ def test_cli_service_token_list_returns_active_tokens() -> None:
|
|||||||
assert isinstance(payload, list)
|
assert isinstance(payload, list)
|
||||||
assert len(payload) == 1
|
assert len(payload) == 1
|
||||||
assert payload[0]["name"] == "active-token"
|
assert payload[0]["name"] == "active-token"
|
||||||
|
assert payload[0]["daemon_id"] == daemon.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_service_token_list_table_shows_daemon_id() -> None:
|
||||||
|
repo = FakeRepo(_portfolio())
|
||||||
|
user = repo.create_user("alice", permission="read")
|
||||||
|
daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot")
|
||||||
|
repo.create_auth_token(
|
||||||
|
user.id,
|
||||||
|
name="active-token",
|
||||||
|
token_hash=hash_token("tok-table"),
|
||||||
|
token_prefix=token_prefix("tok-table"),
|
||||||
|
permission="read",
|
||||||
|
token_type="service",
|
||||||
|
daemon_id=daemon.id,
|
||||||
|
)
|
||||||
|
runner = build_runner(repo)
|
||||||
|
|
||||||
|
result = runner.invoke(runner.app, ["service-token", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Daemon ID" in result.stdout
|
||||||
|
assert daemon.id in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_cli_service_token_list_excludes_revoked_by_default() -> None:
|
def test_cli_service_token_list_excludes_revoked_by_default() -> None:
|
||||||
@@ -1047,3 +1135,60 @@ def test_cli_service_token_list_empty_message() -> None:
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No service tokens found." in result.stdout
|
assert "No service tokens found." in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_service_token_rebind_updates_daemon_id() -> None:
|
||||||
|
repo = FakeRepo(_portfolio())
|
||||||
|
user = repo.create_user("alice", permission="read")
|
||||||
|
source_daemon = repo.create_daemon(user.id, daemon_slug="source-bot")
|
||||||
|
target_daemon = repo.create_daemon(user.id, daemon_slug="target-bot")
|
||||||
|
token = repo.create_auth_token(
|
||||||
|
user.id,
|
||||||
|
name="bound-token",
|
||||||
|
token_hash=hash_token("tok-rb"),
|
||||||
|
token_prefix=token_prefix("tok-rb"),
|
||||||
|
permission="read",
|
||||||
|
token_type="service",
|
||||||
|
runtime_id=str(uuid.uuid4()),
|
||||||
|
daemon_id=source_daemon.id,
|
||||||
|
)
|
||||||
|
runner = build_runner(repo)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
runner.app,
|
||||||
|
["--format", "json", "service-token", "rebind", "bound-token", "--daemon-id", target_daemon.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["name"] == "bound-token"
|
||||||
|
assert payload["previous_daemon_id"] == source_daemon.id
|
||||||
|
assert payload["daemon_id"] == target_daemon.id
|
||||||
|
assert repo._auth_tokens[token.id].daemon_id == target_daemon.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_service_token_rebind_rejects_cross_user_daemon() -> None:
|
||||||
|
repo = FakeRepo(_portfolio())
|
||||||
|
alice = repo.create_user("alice", permission="read")
|
||||||
|
bob = repo.create_user("bob", permission="read")
|
||||||
|
alice_daemon = repo.create_daemon(alice.id, daemon_slug="alice-bot")
|
||||||
|
bob_daemon = repo.create_daemon(bob.id, daemon_slug="bob-bot")
|
||||||
|
repo.create_auth_token(
|
||||||
|
alice.id,
|
||||||
|
name="bound-token",
|
||||||
|
token_hash=hash_token("tok-rb-2"),
|
||||||
|
token_prefix=token_prefix("tok-rb-2"),
|
||||||
|
permission="read",
|
||||||
|
token_type="service",
|
||||||
|
runtime_id=str(uuid.uuid4()),
|
||||||
|
daemon_id=alice_daemon.id,
|
||||||
|
)
|
||||||
|
runner = build_runner(repo)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
runner.app,
|
||||||
|
["service-token", "rebind", "bound-token", "--daemon-id", bob_daemon.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "해당 토큰 사용자에 속하지 않습니다" in result.stdout
|
||||||
|
|||||||
@@ -67,7 +67,18 @@ class _DaemonRepo:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
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)
|
return next(
|
||||||
|
(
|
||||||
|
t
|
||||||
|
for t in sorted(
|
||||||
|
self._tokens.values(),
|
||||||
|
key=lambda token: token.created_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
if t.name == name and t.token_type == "service" and t.revoked_at is None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
|
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
|
||||||
token = self._tokens.get(token_id)
|
token = self._tokens.get(token_id)
|
||||||
@@ -142,6 +153,13 @@ class _DaemonRepo:
|
|||||||
self._tokens[new_token.id] = new_token
|
self._tokens[new_token.id] = new_token
|
||||||
return raw, new_token
|
return raw, new_token
|
||||||
|
|
||||||
|
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
|
||||||
|
existing = self.get_service_token_by_name(name)
|
||||||
|
if existing is None:
|
||||||
|
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||||
|
existing.daemon_id = daemon_id
|
||||||
|
return existing
|
||||||
|
|
||||||
|
|
||||||
def _repo_with_user(username: str = "alice") -> _DaemonRepo:
|
def _repo_with_user(username: str = "alice") -> _DaemonRepo:
|
||||||
repo = _DaemonRepo()
|
repo = _DaemonRepo()
|
||||||
@@ -284,3 +302,30 @@ def test_rotate_nonexistent_token_raises() -> None:
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="nonexistent"):
|
with pytest.raises(ValueError, match="nonexistent"):
|
||||||
service.rotate_service_token("nonexistent")
|
service.rotate_service_token("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebind_service_token_updates_daemon_id_without_changing_runtime_id() -> None:
|
||||||
|
repo = _repo_with_user()
|
||||||
|
source_daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||||
|
target_daemon = repo.create_daemon("user-1", daemon_slug="beta-bot")
|
||||||
|
service = _service(repo)
|
||||||
|
|
||||||
|
issued = service.issue_service_token("my-token", "alice", daemon_id=source_daemon.id)
|
||||||
|
|
||||||
|
previous_daemon_id, rebound = service.rebind_service_token("my-token", target_daemon.id)
|
||||||
|
|
||||||
|
assert previous_daemon_id == source_daemon.id
|
||||||
|
assert rebound.daemon_id == target_daemon.id
|
||||||
|
assert rebound.runtime_id == issued.auth_token.runtime_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebind_service_token_rejects_daemon_from_other_user() -> None:
|
||||||
|
repo = _repo_with_user()
|
||||||
|
repo._users["user-2"] = User(id="user-2", username="bob", permission="read")
|
||||||
|
source_daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||||
|
foreign_daemon = repo.create_daemon("user-2", daemon_slug="bob-bot")
|
||||||
|
service = _service(repo)
|
||||||
|
service.issue_service_token("my-token", "alice", daemon_id=source_daemon.id)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="해당 토큰 사용자에 속하지 않습니다"):
|
||||||
|
service.rebind_service_token("my-token", foreign_daemon.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user