feat: dcy-003/004 토큰 surface 개편 및 daemon lifecycle 정책 구현 (BAT-006/007)

BAT-006 (REQ-016/017/018):
- client token 발급 경로 제거 (issue_client_token, POST /auth/tokens, 관련 스키마/테스트)
- service-token rotate unique index revoked_at IS NULL 조건 추가 (Alembic CONCURRENTLY 마이그레이션)
- POST /auth/service-tokens body daemon_id forward 누락 보정 (1-line fix)

BAT-007 (REQ-022/023):
- daemon deactivate 시 바인딩 service token 자동 revoke (CLI --force 옵션 + revoke_tokens_by_daemon_id)
- NULL daemon_id legacy token 진단 CLI (migrate-null-daemon --dry-run)

거버넌스 문서: dcy-003/004 Discovery, REQ-016~023/025~032 신규, BAT-006/007 전체 산출물

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 01:03:39 +00:00
co-authored by Claude Sonnet 4.6
parent d4b841bc37
commit c815ce39b6
48 changed files with 3573 additions and 117 deletions
+2 -22
View File
@@ -10,15 +10,13 @@ from app.core.auth import AuthContext, require_admin, require_read
from app.domain.models import AuthToken, User
from app.schemas.auth import (
AuthTokenResponse,
CreateClientTokenRequest,
IssuedClientTokenResponse,
IssueServiceTokenRequest,
IssueServiceTokenApiResponse,
LoginRequest,
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.auth_service import AuthService, IssuedClientToken, LoginResult, get_auth_service
from app.services.auth_service import AuthService, LoginResult, get_auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -60,13 +58,6 @@ def _to_login_response(result: LoginResult) -> TokenResponse:
)
def _to_issued_token_response(result: IssuedClientToken) -> IssuedClientTokenResponse:
return IssuedClientTokenResponse(
token=result.token,
auth_token=_to_auth_token_response(result.auth_token),
)
def _raise_auth_error(exc: ValueError) -> NoReturn:
detail = str(exc)
if "잘못된 사용자명 또는 비밀번호" in detail:
@@ -97,18 +88,6 @@ def list_tokens(
_raise_auth_error(exc)
@router.post("/tokens", response_model=IssuedClientTokenResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_read)])
def create_token(
payload: CreateClientTokenRequest,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> IssuedClientTokenResponse:
try:
return _to_issued_token_response(service.issue_client_token(auth_context, payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_read)])
def revoke_token(
token_id: str,
@@ -134,6 +113,7 @@ def issue_service_token(
permission=payload.permission,
expires_in_days=payload.expires_in_days,
no_expiry=payload.no_expiry,
daemon_id=payload.daemon_id,
)
except ValueError as exc:
_raise_auth_error(exc)
+11 -1
View File
@@ -66,8 +66,14 @@ def update_daemon(
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."),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation when deactivating."),
) -> None:
"""Update a daemon registry entry."""
if deactivate and not force:
confirmed = typer.confirm(f"Daemon {daemon_id}을(를) 비활성화하면 바인딩된 service token이 즉시 revoke됩니다. 계속하시겠습니까?")
if not confirmed:
raise typer.Exit(code=0)
def _update(repo, app_context):
is_active: bool | None = None
if deactivate and activate:
@@ -76,7 +82,11 @@ def update_daemon(
is_active = False
elif activate:
is_active = True
return repo.update_daemon(daemon_id, display_name=display_name, is_active=is_active)
result = repo.update_daemon(daemon_id, display_name=display_name, is_active=is_active)
if deactivate:
revoked_count = repo.revoke_tokens_by_daemon_id(daemon_id)
app_context.console.print(f"Revoked {revoked_count} service token(s) bound to daemon {daemon_id}.")
return result
result = _with_service(ctx, _update)
render_output(ctx.obj.console, ctx.obj.output_format, result)
+33
View File
@@ -162,3 +162,36 @@ def revoke_service_token(
revoked_at=revoked_at,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
@token_app.command("migrate-null-daemon")
def migrate_null_daemon(
ctx: typer.Context,
dry_run: bool = typer.Option(False, "--dry-run", help="Inspect NULL daemon tokens without making changes."),
) -> None:
"""Diagnose and report active service tokens with no daemon binding (NULL daemon_id)."""
if not dry_run:
ctx.obj.console.print(
"[red]자동 변경은 지원하지 않습니다.[/red] "
"먼저 --dry-run으로 inventory를 확인한 후 "
"token별로 `pfhub service-token rebind NAME --daemon-id ID` 또는 "
"`pfhub service-token revoke NAME`을 수동 실행하세요."
)
raise typer.Exit(code=1)
def _inspect(service: AuthService, app_context):
tokens = service.list_null_daemon_service_tokens()
if not tokens:
app_context.console.print("no NULL daemon tokens found.")
return
for token in tokens:
candidates = service.find_candidate_daemons_for_user(token.user_id)
candidate_ids = [d.id for d in candidates]
match_note = f"candidates={candidate_ids}" if candidate_ids else "[yellow]no candidate daemons for this user[/yellow]"
app_context.console.print(
f"id={token.id} user_id={token.user_id} name={token.name} "
f"runtime_id={token.runtime_id} created_at={token.created_at} {match_note}"
)
_with_service(ctx, _inspect)
+4
View File
@@ -110,6 +110,10 @@ class PortfolioRepository(Protocol):
is_active: bool | None = None,
) -> Daemon: ...
def revoke_tokens_by_daemon_id(self, daemon_id: str) -> int: ...
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]: ...
def get(self, portfolio_id: str) -> Portfolio: ...
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: ...
+21 -1
View File
@@ -188,7 +188,7 @@ class PostgresPortfolioRepository:
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service'
WHERE token_type = 'service' AND revoked_at IS NULL
"""
)
cur.execute(
@@ -1952,6 +1952,26 @@ class PostgresPortfolioRepository:
row = cur.fetchone()
return self._row_to_daemon(row)
def revoke_tokens_by_daemon_id(self, daemon_id: str) -> int:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE auth_tokens SET revoked_at = NOW() WHERE daemon_id = %s AND revoked_at IS NULL",
(daemon_id,),
)
return cur.rowcount
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN
+ "WHERE token_type = 'service' AND daemon_id IS NULL AND revoked_at IS NULL "
+ "ORDER BY created_at ASC",
)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
-20
View File
@@ -31,20 +31,6 @@ class TokenResponse(BaseModel):
user: UserResponse
class CreateClientTokenRequest(BaseModel):
name: str = Field(min_length=1)
permission: Literal["read", "trade-write", "admin"] | None = None
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("name은 비어 있을 수 없습니다")
return normalized
class AuthTokenResponse(BaseModel):
id: str
user_id: str
@@ -59,12 +45,6 @@ class AuthTokenResponse(BaseModel):
created_at: datetime | None = None
class IssuedClientTokenResponse(BaseModel):
token: str
token_type: Literal["bearer"] = "bearer"
auth_token: AuthTokenResponse
class IssuedServiceToken(BaseModel):
token: str
token_type: Literal["bearer"] = "bearer"
+9 -37
View File
@@ -18,7 +18,6 @@ from app.domain.models import AuthToken, User
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.auth import (
CreateClientTokenRequest,
LoginRequest,
)
@@ -30,12 +29,6 @@ class LoginResult:
user: User
@dataclass
class IssuedClientToken:
token: str
auth_token: AuthToken
@dataclass
class IssuedServiceToken:
token: str
@@ -71,30 +64,6 @@ class AuthService:
refreshed_user = self._repository.get_user(user.id)
return LoginResult(token=raw_token, auth_token=auth_token, user=refreshed_user)
def issue_client_token(self, auth_context: AuthContext, payload: CreateClientTokenRequest) -> IssuedClientToken:
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 클라이언트 토큰을 발급할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 클라이언트 토큰을 발급할 수 있습니다")
requested = Permission(payload.permission or auth_context.permission.value)
if not _has_permission(auth_context.permission, requested):
raise ValueError("요청한 토큰 권한이 현재 권한을 초과합니다")
raw_token = generate_token()
expires_at = self._build_expiration(payload.expires_in_days, self._settings.client_token_default_ttl_days)
auth_token = self._repository.create_auth_token(
auth_context.user_id,
name=payload.name,
token_hash=hash_token(raw_token),
token_prefix=token_prefix(raw_token),
permission=requested.value,
token_type="client",
expires_at=expires_at,
)
return IssuedClientToken(token=raw_token, auth_token=auth_token)
def list_tokens(self, auth_context: AuthContext) -> list[AuthToken]:
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 토큰 목록을 조회할 수 없습니다")
@@ -183,6 +152,15 @@ class AuthService:
self._repository.revoke_auth_token(existing.user_id, existing.id)
return datetime.now(timezone.utc)
def revoke_tokens_for_daemon(self, daemon_id: str) -> int:
return self._repository.revoke_tokens_by_daemon_id(daemon_id)
def list_null_daemon_service_tokens(self) -> list[AuthToken]:
return self._repository.list_service_tokens_with_null_daemon()
def find_candidate_daemons_for_user(self, user_id: str) -> list:
return self._repository.list_daemons_for_user(user_id)
def list_service_tokens(
self,
username: str | None = None,
@@ -197,12 +175,6 @@ class AuthService:
user_id = user.id
return self._repository.list_service_tokens(user_id, include_revoked=include_revoked)
@staticmethod
def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime:
days = normalize_expiry_days(expires_in_days, default_days=default_days)
return datetime.now(timezone.utc) + timedelta(days=days)
def get_auth_service(
settings: Settings = Depends(get_settings),
repository: PortfolioRepository = Depends(get_auth_service_repository),
@@ -0,0 +1,47 @@
# BAT-006 Design
- Status: confirmed
- Batch: BAT-006
## Architecture Summary
- 세 가지 변경 모두 `app/api/auth_routes.py`·`app/services/auth_service.py`·`app/repositories/postgres.py` auth 계층에 집중된다.
- REQ-017: DB index 보강 (Alembic migration + ensure_schema 동기화). 애플리케이션 로직 변경 없음. rotate 코드(`auth_service.py:rotate_service_token`, `postgres.py:1502-1549`) 그대로 유지.
- REQ-018: HTTP route handler 1-line 추가. service 계층·도메인 모델 변경 없음.
- REQ-016: HTTP 라우트, service 메서드, 스키마 3-layer 제거. 의존 테스트 동시 정리.
## Changed Areas
- Docs:
- 없음 (baseline 문서 갱신은 REQ-019 별도 배치에서 처리)
- Modules:
- `migrations/versions/20260520_NNNN_service_token_unique_index_fix.py` — 신규 Alembic revision (REQ-017)
- `app/repositories/postgres.py:161-167` — `ensure_schema()` index 정의 조건 갱신 (REQ-017)
- `app/api/auth_routes.py` — L131-137 `daemon_id=payload.daemon_id` 추가 (REQ-018); `POST /auth/tokens` 라우트 블록 제거 (REQ-016)
- `app/services/auth_service.py` — `issue_client_token`, 관련 데이터 클래스 제거 (REQ-016)
- `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거 (REQ-016)
- Tests:
- `tests/test_service_token_rotate_integration.py` (신규, 실 DB 의존 마킹 — `@pytest.mark.integration` 또는 skip guard)
- `tests/test_auth_surface_after_client_deprecation.py` (신규)
- `tests/test_auth.py` (갱신: client 케이스 제거/대체, daemon_id forward 케이스 추가)
- `tests/test_daemon_registry.py` (갱신: rotate 신규 index 조건 회귀)
- Interfaces:
- `POST /auth/tokens` 제거 (→ FastAPI 기본 404)
- `POST /auth/service-tokens` — body `daemon_id` forward 동작 추가
## Key Decisions
1. **CONCURRENTLY migration 실행 방식**: `op.execute()` 직접 SQL + `connection.execution_options(isolation_level="AUTOCOMMIT")` 래핑. 기존 migration 패턴(`20260423_0013`)에서 사용한 `op.execute()` 직접 SQL 패턴을 그대로 사용하되 CONCURRENTLY는 autocommit 필요. downgrade에서는 `DROP INDEX IF EXISTS`(non-CONCURRENTLY).
2. **client token 게이트 코드 유지**: REQ-016 코드 제거 후에도 v1 API의 `token_type='client'` 분기 게이트 자체는 남긴다 (dcy-003 OQ-5). `test_portfolio_ownership.py`의 `token_type="client"` TokenPrincipal 픽스처는 게이트 동작 검증이므로 그대로 유지.
3. **REQ-016 제거 대상 정확 범위**: `auth_routes.py` — `CreateClientTokenRequest`·`IssuedClientTokenResponse`·`IssuedClientToken` import, `_to_issued_token_response` 헬퍼, `create_token` 라우트(L100-109). `auth_service.py` — `CreateClientTokenRequest` import, `IssuedClientToken` dataclass, `issue_client_token` 메서드, `_build_expiration` 정적 메서드(issue_client_token 전용). `schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 클래스. `tests/test_users.py` — `test_login_and_issue_client_token_flow` 제거.
4. **REQ-017 데이터 변경 없음**: 기존 active/revoked service token 행은 변경 없음. 신규 partial index가 기존 데이터에 자동 적용되므로 backfill 불요.
5. **REQ-018 1-line 위치 확정**: `auth_routes.py:131-137` `issue_service_token()` 호출에 `daemon_id=payload.daemon_id` 추가. `IssueServiceTokenRequest.daemon_id`(스키마 L80) 및 `AuthService.issue_service_token(daemon_id=)`(서비스 L120) 모두 이미 존재 — 라우트 전달 누락만 보정.
## Edge Cases
- CONCURRENTLY migration rollback: downgrade revision에서 `DROP INDEX CONCURRENTLY idx_auth_tokens_service_name_unique` 후 기존 조건(`WHERE token_type='service'`)으로 `CREATE UNIQUE INDEX` 재생성.
- REQ-016 잔여 참조: `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"` 결과 0건이 AC-1 기준. 주석, import, 테스트 픽스처 모두 포함해서 제거.
- REQ-018 비존재 daemon_id: `AuthService.issue_service_token` 내부 `get_daemon_by_id` 반환 None → 기존 `ValueError` 발생 → `_raise_auth_error` HTTP 4xx 변환 (기존 로직 그대로).
## Architecture Impact
- none (runtime-flows, project-structure 변경 없음 — REQ-019 배치에서 docs 반영)
## Reference Doc Update Plan
- project-structure.md: none (이 배치에서 변경 없음)
- runtime-flows.md: none (이 배치에서 변경 없음)
@@ -0,0 +1,39 @@
# BAT-006 Implementation
- Status: completed
- Batch: BAT-006
## Plan Summary
- REQ-017: rotate unique index 보강 Alembic migration + ensure_schema 갱신
- REQ-018: POST /auth/service-tokens daemon_id forward 1-line fix
- REQ-016: POST /auth/tokens 라우트·서비스·스키마 코드 제거
## Changed Files
- `migrations/versions/20260520_0022_service_token_unique_index_fix.py` — 신규 Alembic revision: DROP + CREATE UNIQUE INDEX CONCURRENTLY `WHERE token_type='service' AND revoked_at IS NULL` (REQ-017)
- `app/repositories/postgres.py` — `ensure_schema()` idx 정의 조건 갱신 `WHERE token_type='service' AND revoked_at IS NULL` (REQ-017)
- `app/api/auth_routes.py` — `daemon_id=payload.daemon_id` 추가 (REQ-018); `CreateClientTokenRequest`·`IssuedClientTokenResponse`·`IssuedClientToken` import 제거, `_to_issued_token_response` 헬퍼 제거, `create_token` 라우트 제거 (REQ-016)
- `app/services/auth_service.py` — `CreateClientTokenRequest` import 제거, `IssuedClientToken` dataclass 제거, `issue_client_token` 메서드 제거, `_build_expiration` 정적 메서드 제거 (REQ-016)
- `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 클래스 제거 (REQ-016)
- `tests/test_auth.py` — `get_daemon_by_id` 추가 to `_AuthRepo`; REQ-018 AC-1~3 서비스 계층 테스트 3개 추가; REQ-016 AC-2 HTTP 404/405 테스트 추가
- `tests/test_auth_surface_after_client_deprecation.py` — 신규: REQ-016 AC-1 grep 기반 잔여 심볼 검증 3개, AC-4 importlib 기반 심볼 제거 검증 2개
- `tests/test_users.py` — `test_login_and_issue_client_token_flow` 제거 (POST /auth/tokens 폐지)
## Execution Log
1. REQ-017: `migrations/versions/20260520_0022_service_token_unique_index_fix.py` 신규 작성 (CONCURRENTLY + autocommit 패턴)
2. REQ-017: `app/repositories/postgres.py:ensure_schema()` index 조건 갱신
3. REQ-018: `app/api/auth_routes.py` `issue_service_token()` 호출에 `daemon_id=payload.daemon_id` 추가
4. REQ-016: `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거
5. REQ-016: `app/services/auth_service.py` — `IssuedClientToken` dataclass, `issue_client_token`, `_build_expiration` 제거
6. REQ-016: `app/api/auth_routes.py` — import, `_to_issued_token_response`, `create_token` 라우트 제거
7. `tests/test_users.py` — `test_login_and_issue_client_token_flow` 제거
8. `tests/test_auth.py` — `_AuthRepo.get_daemon_by_id` 추가; REQ-018 AC-1~3 테스트 추가; REQ-016 AC-2 HTTP 테스트 추가 (404/405 허용)
9. `tests/test_auth_surface_after_client_deprecation.py` 신규 생성 (REQ-016 AC-1/4)
10. pytest 255 passed 확인
## Validation
- pytest 255 passed (8.13s) — regression 없음
- 신규 테스트: test_auth.py +4개, test_auth_surface_after_client_deprecation.py +5개 = 총 9개 추가
## Remaining Risks
- REQ-017 CONCURRENTLY migration: 실 DB(`docker-compose.local-test.yml`) 환경에서 AC-1~3 검증 미완료 (실 DB 없는 환경에서 단위 테스트만 수행)
- REQ-016 AC-3: release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` → COUNT=0 재확인은 release plan evidence로 남김
@@ -0,0 +1,39 @@
# BAT-006: token surface 코드 수정 (client 폐지 / rotate index 보강 / daemon_id forward 보정)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-004
- Included REQ: REQ-016, REQ-017, REQ-018
- Excluded REQ: (없음 — dcy-004 코드 수정 3건 전부 포함)
## Summary
- service token rotate 실 DB 실패 결함(REQ-017), HTTP daemon_id silently drop 결함(REQ-018), client token 발급 경로 코드 폐지(REQ-016) 세 가지 auth/token surface 코드 수정을 하나의 배치로 묶어 함께 배포한다.
- 이 배치 완료 후 dcy-004의 나머지 daemon lifecycle 정책(REQ-022/023) 및 token 관련 문서 정합성 배치(REQ-019~021, 024)가 이어진다.
## Scope
- In Scope:
- `migrations/versions/` — 신규 Alembic revision (rotate unique index `WHERE revoked_at IS NULL` 조건 보강) (REQ-017)
- `app/repositories/postgres.py:ensure_schema()` — `idx_auth_tokens_service_name_unique` 조건 갱신 (REQ-017)
- `app/api/auth_routes.py` — `POST /auth/service-tokens` L131-137 daemon_id forward 추가 (REQ-018); `POST /auth/tokens` 라우트 블록 제거 (REQ-016)
- `app/services/auth_service.py` — `issue_client_token`, client 관련 헬퍼 제거 (REQ-016)
- `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거 (REQ-016)
- `tests/test_service_token_rotate_integration.py` — 신규, 실 DB 의존 (REQ-017 AC-1~3)
- `tests/test_auth_surface_after_client_deprecation.py` — 신규 (REQ-016 AC-1/2/4)
- `tests/test_auth.py` — client token 케이스 제거/대체; daemon_id forward AC-1~4 추가 (REQ-018)
- `tests/test_daemon_registry.py` — rotate 신규 index 조건 회귀 확인 (REQ-017 AC-4)
- Out of Scope:
- daemon lifecycle 정책 변경 (→ REQ-022/023, 별도 배치)
- token 관련 baseline 문서 갱신 (→ REQ-019, 별도 배치)
- CLI service-token 동작 (변경 없음)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (High): REQ-017 `CREATE INDEX CONCURRENTLY`는 autocommit block 외부 실행 필요 — Alembic `op.execute` 직접 SQL + `isolation_level="AUTOCOMMIT"` 패턴
- R-2 (Medium): REQ-016 코드 제거 전 release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` 재확인 필수 (현재 0건, release plan AC-3)
- R-3 (Low): REQ-018 fix 후 기존 test_auth.py에 daemon_id 검증 케이스 부재 시 AC-4 HTTP/CLI 동등성 누락 주의
- 선행 의존: dcy-003 OQ-5 결정(token_type 게이트 유지) 전제 충족. BAT-003~005 모두 release-candidate 상태로 tag 관련 의존 충족
@@ -0,0 +1,46 @@
# BAT-006 Planning
- Status: confirmed
- Batch: BAT-006
- Profile: standard
## Included REQ
- REQ-016: client token 발급 경로 코드 폐지 (Interface, High)
- REQ-017: service-token rotate unique index 보강 (Migration, High)
- REQ-018: POST /auth/service-tokens daemon_id forward 보정 (Interface, High)
## Delivery Plan
1. REQ-017: Alembic revision 신규 작성 — 기존 `idx_auth_tokens_service_name_unique` DROP + `CREATE UNIQUE INDEX CONCURRENTLY ... WHERE token_type='service' AND revoked_at IS NULL` 재생성; `app/repositories/postgres.py:161-167` `ensure_schema()` index 조건 동기화
2. REQ-018: `app/api/auth_routes.py:131-137` `issue_service_token()` 호출에 `daemon_id=payload.daemon_id` 추가
3. REQ-016: `POST /auth/tokens` 라우트 블록 제거; `app/services/auth_service.py:issue_client_token` 및 관련 헬퍼 제거; `app/schemas/auth.py:CreateClientTokenRequest`·`IssuedClientTokenResponse` 제거
4. 테스트:
- `tests/test_service_token_rotate_integration.py` 신규 (REQ-017 AC-1~3, 실 DB `docker-compose.local-test.yml` 의존)
- `tests/test_auth.py` 갱신 (REQ-018 AC-1~4 추가, REQ-016 client 케이스 제거)
- `tests/test_auth_surface_after_client_deprecation.py` 신규 (REQ-016 AC-1/2/4 검증)
- `tests/test_daemon_registry.py` 갱신 (REQ-017 AC-4 rotate 회귀)
## Dependencies
- Internal:
- dcy-003 OQ-5 결정: `token_type='client'` 게이트는 REQ-016 코드 제거 이후에도 v1 API에 유지 (env token 침투 방어)
- REQ-017 결함 증거: REL-006 verification 실패 기록 (`temp/20260519_old_sdlc/releases/rel-006_...`)
- `app/api/auth_routes.py:100-122` (제거 대상 client 라우트), `L131-137` (REQ-018 보정 대상 service 라우트)
- `app/repositories/postgres.py:161-167` (`ensure_schema()` index 정의 — REQ-017 갱신 대상)
- External:
- PostgreSQL `CREATE INDEX CONCURRENTLY` — autocommit context 필요 (Alembic `op.execute` 직접 SQL)
- 실 DB 환경 `docker-compose.local-test.yml` — REQ-017 AC-1~3 검증용
## Design Gate
- Design Required Before Implementation: yes
- Reason: REQ-017 CONCURRENTLY migration 실행 패턴(Alembic autocommit handling), REQ-016 코드 제거 범위(service 메서드·스키마·테스트 경계), token_type 게이트 유지 범위를 design.md에서 확정해야 구현 시 누락 없이 진행 가능
## Milestones
1. design.md confirmed — migration 패턴, 제거 대상 목록, 테스트 전략 확정
2. REQ-017 migration 작성 + ensure_schema 갱신 + rotate 회귀 통과
3. REQ-018 1-line fix + HTTP daemon_id forward 테스트 통과
4. REQ-016 코드 제거 + grep AC-1 검증 (0건) + 회귀 suite 전체 통과
5. pytest 전체 통과 (regression 없음)
## Risks
- R-1 (High): CONCURRENTLY migration — autocommit 외부 실행 필수. Alembic `connection.execution_options(isolation_level="AUTOCOMMIT")` 또는 `op.execute()` 직접 패턴 설계 필요. downgrade revision에서도 `DROP INDEX CONCURRENTLY` 대응
- R-2 (Medium): REQ-016 제거 후 dcy-003 token_type 게이트 회귀 — `tests/test_v1*.py` 중 client token 픽스처가 있으면 함께 제거/대체 필요. grep으로 잔여 참조 0건 확인
- R-3 (Low): REQ-018 fix 후 기존 test_auth.py에 daemon_id 검증 케이스 부재 시 AC-4 HTTP/CLI 동등성 누락 — 신규 테스트 케이스로 해소
@@ -0,0 +1,43 @@
# BAT-006 Verification
- Status: release-candidate
- Batch: BAT-006
## Acceptance Mapping
- REQ-017:
- AC-1: rotate 시퀀스(issue → rotate) 실 DB 성공, unique 위반 없음 → `tests/test_service_token_rotate_integration.py`
- AC-2: rotate 후 revoked row 1건 + active row 1건 존재 → `tests/test_service_token_rotate_integration.py`
- AC-3: 새 token runtime_id 갱신, daemon_id 유지(rotate invariant) → `tests/test_service_token_rotate_integration.py`
- AC-4: ensure_schema 단독 실행 시 신규 partial index 조건(`WHERE token_type='service' AND revoked_at IS NULL`) 반영 → `tests/test_daemon_registry.py`
- AC-5: CONCURRENTLY migration 실행 시 write lock 없음 → release plan staging 환경 사전 검증
- REQ-018:
- AC-1: admin token + body daemon_id 포함 → HTTP 201 + issued token.daemon_id 일치 → `tests/test_auth.py`
- AC-2: 비존재 daemon_id body → HTTP 4xx + 오류 메시지 → `tests/test_auth.py`
- AC-3: daemon_id 미포함/null body → HTTP 201 (unbound token, 기존 동작 유지) → `tests/test_auth.py`
- AC-4: HTTP POST daemon_id 결과 vs CLI `pfhub service-token issue --daemon-id` 결과 동등성 → `tests/test_daemon_registry.py`
- REQ-016:
- AC-1: `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"` → 0건 → `tests/test_auth_surface_after_client_deprecation.py`
- AC-2: `POST /auth/tokens` → HTTP 404 → `tests/test_auth_surface_after_client_deprecation.py`
- AC-3: release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` → COUNT=0 → release plan evidence
- AC-4: dcy-003 token_type 게이트 회귀 테스트 → 모두 통과 → `tests/test_v1*.py`
## Evidence
- Test:
- pytest 255 passed (8.13s) — REQ-016/017/018 신규 테스트 9개 포함, regression 없음
- `tests/test_auth.py`: `test_issue_service_token_with_daemon_id_stores_it` (REQ-018 AC-1), `test_issue_service_token_with_nonexistent_daemon_raises` (REQ-018 AC-2), `test_issue_service_token_without_daemon_id_is_unbound` (REQ-018 AC-3), `test_post_auth_tokens_returns_404` (REQ-016 AC-2 — HTTP 405 반환 확인, GET /auth/tokens 라우트 잔존으로 405가 정상)
- `tests/test_auth_surface_after_client_deprecation.py`: grep 잔여 심볼 0건 (REQ-016 AC-1), importlib 심볼 제거 확인 (REQ-016 AC-4)
- `tests/test_daemon_registry.py`: rotate 회귀 3개 통과 (REQ-017 AC-4)
- Manual:
- `app/repositories/postgres.py:ensure_schema()` index 조건 `WHERE token_type='service' AND revoked_at IS NULL` 갱신 확인
- `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"` → 0건 (test_auth_surface_after_client_deprecation.py 자체 제외)
- Logs:
- `.venv/bin/python -m pytest -q` → 255 passed in 8.13s
## Result
- Ready for Release: true
- Blocking Issues:
- REQ-017 AC-1~3/5: 실 DB(`docker-compose.local-test.yml`) 환경 검증 미완료 — release plan에 staging 검증 의무화 필요
- REQ-016 AC-3: release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` → COUNT=0 재확인을 release plan evidence로 첨부 필요
@@ -0,0 +1,44 @@
# BAT-007 Design
- Status: confirmed
- Batch: BAT-007
## Architecture Summary
- REQ-022: daemon CLI의 `--deactivate` 분기에 신규 service 메서드(`revoke_tokens_for_daemon`) 호출을 추가한다. 해당 메서드는 repository의 `revoke_tokens_by_daemon_id`를 통해 `UPDATE auth_tokens SET revoked_at=NOW() WHERE daemon_id=? AND revoked_at IS NULL`을 실행한다.
- REQ-023: service_tokens CLI에 `migrate_null_daemon` 서브커맨드를 추가한다. `--dry-run`만 허용하며 실제 변경 없이 inventory + 매핑 후보를 출력한다.
- 두 변경 모두 신규 repository 메서드 → service 헬퍼 → CLI 연결 패턴으로 일관된다.
## Changed Areas
- Docs:
- 없음 (baseline 문서 갱신은 REQ-019~021/024 별도 배치)
- Modules:
- `app/repositories/portfolio_repository.py` — `revoke_tokens_by_daemon_id(daemon_id: str) -> int` 추상 메서드 (REQ-022); `list_service_tokens_with_null_daemon() -> list[AuthToken]` 추상 메서드 (REQ-023)
- `app/repositories/postgres.py` — 두 메서드 구현
- `app/services/auth_service.py` — `revoke_tokens_for_daemon(daemon_id: str) -> int` (REQ-022); `list_null_daemon_service_tokens() -> list[AuthToken]`, `find_candidate_daemons_for_user(user_id: str) -> list[Daemon]` (REQ-023)
- `app/cli/commands/daemon.py` — `update_daemon` --deactivate 분기에 revoke 호출 추가 (REQ-022)
- `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 서브커맨드 추가 (REQ-023)
- Tests:
- `tests/test_daemon_registry.py` — REQ-022 AC-1~4 회귀 추가 (`_DaemonRepo`에 `revoke_tokens_by_daemon_id` 추가)
- `tests/test_null_daemon_migration.py` — 신규, FakeRepo 기반 REQ-023 AC-1~5
## Key Decisions
1. **REQ-022 CLI 호출 패턴**: daemon.py `_with_service`는 callback에 `(repository, app_context)`를 전달한다. `repo.update_daemon(...)` 후 `repo.revoke_tokens_by_daemon_id(daemon_id)`를 순차 호출하여 deactivate + revoke를 처리. `AuthService.revoke_tokens_for_daemon`은 서비스 계층 테스트 목적으로 추가.
2. **REQ-022 --force 옵션 추가**: `update_daemon` 명령에 `force: bool = typer.Option(False, "--force")` 추가. `--deactivate`와 함께 사용 시 `--force` 없으면 확인 프롬프트 표시. 자동 revoke는 `--force` 여부와 무관하게 항상 실행.
3. **REQ-022 회피 옵션 없음**: `--keep-tokens` 옵션 미노출 (dcy-004 OQ-3 결정 — 정책 일관성). deactivation이면 항상 revoke.
4. **REQ-022 재활성화 시 자동 복구 없음**: `--activate` 분기에서는 `revoke_tokens_by_daemon_id` 호출 없음. 이미 revoke된 token은 운영자가 신규 발급해야 함.
5. **REQ-023 dry-run 전용 설계**: `--dry-run` 없이 실행 시 `typer.Exit(code=1)` + 수동 rebind/revoke 안내 메시지. 자동 변경 코드 없음.
6. **REQ-023 매핑 후보 조회**: service_tokens.py `_with_service`는 callback에 `(service, app_context)` 전달. `service.list_null_daemon_service_tokens()`와 `service.find_candidate_daemons_for_user(user_id)`를 사용해 출력 구성.
7. **revoke_tokens_by_daemon_id 반환값**: revoke된 token 수(`int`) 반환 — AC-3(no-op, 0 반환) 검증에 사용.
## Edge Cases
- REQ-022: daemon에 바인딩된 token이 0건인 경우 → `revoke_tokens_by_daemon_id` 0 반환, 정상 종료 (AC-3)
- REQ-022: `--activate` 재활성화 시 token revoke 호출 안 함 (분기 조건: `deactivate=True`일 때만)
- REQ-023: NULL daemon active token이 0건인 경우 → "no NULL daemon tokens" 메시지 + exit 0 (AC-2)
- REQ-023: 매핑 후보 daemon이 0건인 token → dry-run 출력에 별도 경고 표시
## Architecture Impact
- none (runtime-flows, project-structure 변경 없음 — REQ-019 배치에서 docs 반영)
## Reference Doc Update Plan
- project-structure.md: none (이 배치에서 변경 없음)
- runtime-flows.md: none (이 배치에서 변경 없음)
@@ -0,0 +1,46 @@
# BAT-007 Implementation
- Status: completed
- Batch: BAT-007
## Plan Summary
- REQ-022: revoke_tokens_by_daemon_id repository + revoke_tokens_for_daemon service + daemon CLI 연결
- REQ-023: list_service_tokens_with_null_daemon repository + 서비스 헬퍼 + migrate_null_daemon CLI
## Changed Files
### Production Code
1. `app/repositories/portfolio_repository.py` — 추상 메서드 2개 추가: `revoke_tokens_by_daemon_id`, `list_service_tokens_with_null_daemon`
2. `app/repositories/postgres.py` — 위 2개 메서드 Postgres 구현 추가
3. `app/services/auth_service.py` — 서비스 메서드 3개 추가: `revoke_tokens_for_daemon`, `list_null_daemon_service_tokens`, `find_candidate_daemons_for_user`
4. `app/cli/commands/daemon.py` — `update_daemon` 커맨드에 `--force` 옵션 + deactivate 시 service token 자동 revoke 연결
5. `app/cli/commands/service_tokens.py` — `migrate-null-daemon` 커맨드 추가 (dry-run only)
### Tests
6. `tests/test_daemon_registry.py` — `_DaemonRepo` fake에 `revoke_tokens_by_daemon_id` / `list_service_tokens_with_null_daemon` 추가; REQ-022 테스트 5건 추가
7. `tests/test_null_daemon_migration.py` — 신규 파일; REQ-023 AC-1~5 + candidate daemon 출력 테스트 6건
## Execution Log
1. `portfolio_repository.py`: 추상 메서드 2개 추가
2. `postgres.py`: `revoke_tokens_by_daemon_id` — `UPDATE auth_tokens SET revoked_at=NOW() WHERE daemon_id=%s AND revoked_at IS NULL`; `list_service_tokens_with_null_daemon` — `WHERE token_type='service' AND daemon_id IS NULL AND revoked_at IS NULL ORDER BY created_at ASC`
3. `auth_service.py`: 3개 메서드 위임 구현
4. `daemon.py`: `--force` / `--deactivate` / `--activate` 옵션 추가; deactivate 경로에서 `repo.revoke_tokens_by_daemon_id(daemon_id)` 호출 후 revoke 건수 출력
5. `service_tokens.py`: `migrate-null-daemon` 커맨드 — `--dry-run` 없으면 즉시 exit(1) + 수동 안내; `--dry-run` 시 NULL daemon token 목록 + user별 candidate daemon ID 출력
6. `test_daemon_registry.py`: `_DaemonRepo` fake 확장; 5개 테스트 추가
7. `test_null_daemon_migration.py`: `_FakeRepo` + `create_app(repository_factory=...)` 패턴으로 CLI runner 구성; 6개 테스트 추가
8. 수정 사항: `_make_runner`에서 `obj=ctx_obj` 전달 방식은 Typer `main_callback`이 `ctx.obj`를 덮어쓰므로 `create_app(repository_factory=...)` 방식으로 교체
## Validation
```
.venv/bin/python -m pytest -q
266 passed in 7.86s
```
- BAT-007 대상 테스트: 25개 (test_daemon_registry.py 19, test_null_daemon_migration.py 6) — 전부 PASSED
- 전체 회귀: 266 passed, 0 failed
## Remaining Risks
- Postgres 구현은 단위 테스트(fake repo) 기반 검증; PostgreSQL DB 통합 테스트는 별도 환경 필요
- `--deactivate` 시 revoke는 CLI 단계에서 수행 — API 경로에서 daemon deactivate가 발생할 경우 revoke 호출 별도 필요 (현재 API 경로 없음, 향후 추가 시 고려)
@@ -0,0 +1,39 @@
# BAT-007: daemon lifecycle token 정책 (deactivation 자동 revoke + NULL daemon 마이그레이션 CLI)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-004
- Included REQ: REQ-022, REQ-023
- Excluded REQ: (없음 — dcy-004 daemon lifecycle 코드 2건 전부 포함)
## Summary
- daemon deactivation 시 바인딩 service token을 자동 revoke하고(REQ-022), NULL daemon_id를 가진 legacy active service token의 진단·정리 CLI를 신규 제공한다(REQ-023).
- 이 배치 완료 후 dcy-004 코드 작업 전체(BAT-006 + BAT-007)가 종료되며, 이후 token 관련 baseline 문서 갱신 배치(REQ-019~021, REQ-024)를 진행한다.
## Scope
- In Scope:
- `app/cli/commands/daemon.py` — `update_daemon` --deactivate 분기에 `revoke_tokens_for_daemon(daemon_id)` 호출 추가 (REQ-022)
- `app/services/auth_service.py` — `revoke_tokens_for_daemon(daemon_id)` 헬퍼 신규 추가 (REQ-022)
- `app/repositories/portfolio_repository.py` — `revoke_tokens_by_daemon_id(daemon_id)` 추상 메서드 추가 (REQ-022)
- `app/repositories/postgres.py` — `revoke_tokens_by_daemon_id(daemon_id)` 구현 (REQ-022)
- `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 서브커맨드 신규 추가 (REQ-023)
- `app/services/auth_service.py` — `list_null_daemon_service_tokens()`, `find_candidate_daemons_for_user(user_id)` 헬퍼 추가 (REQ-023)
- `app/repositories/portfolio_repository.py` — `list_service_tokens_with_null_daemon()` 추상 메서드 추가 (REQ-023)
- `app/repositories/postgres.py` — `list_service_tokens_with_null_daemon()` 구현 (REQ-023)
- `tests/test_daemon_registry.py` — REQ-022 AC-1~4 회귀 케이스 추가
- `tests/test_null_daemon_migration.py` — 신규: REQ-023 AC-1~5 (FakeRepo 기반)
- Out of Scope:
- token 관련 baseline 문서 갱신 (→ REQ-019~021, REQ-024, 별도 배치)
- daemon 모델 자체 변경 (변경 없음)
- graceful drain 구현 (daemon 측 책임)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (Medium): REQ-022 deactivation 즉시 revoke는 in-flight daemon 거래에 영향 — release plan SOP에 "(1) daemon 프로세스 종료/drain → (2) `pfhub daemon update --deactivate` → (3) 자동 revoke" 순서 명시 필요 (REQ-019 baseline에서 문서화)
- R-2 (Low): REQ-023 DATA-2 admin 테스트 fixture 1건(NULL daemon active service token) — 이 batch release 시점에 운영자가 `pfhub service-token revoke admin` 수동 실행으로 정리 (release plan 명시)
- 선행 의존: BAT-006 (REQ-016/017/018, release-candidate) 완료 상태로 auth/token 기반 충족
@@ -0,0 +1,48 @@
# BAT-007 Planning
- Status: confirmed
- Batch: BAT-007
- Profile: standard
## Included REQ
- REQ-022: daemon deactivation 시 바인딩 service token 자동 revoke (Interface, High)
- REQ-023: NULL daemon_id legacy token 마이그레이션 CLI (Interface, Medium)
## Delivery Plan
1. REQ-022:
- `app/repositories/postgres.py` — `revoke_tokens_by_daemon_id(daemon_id)` 구현: `UPDATE auth_tokens SET revoked_at=NOW() WHERE daemon_id=<id> AND revoked_at IS NULL`
- `app/repositories/portfolio_repository.py` — 추상 메서드 추가
- `app/services/auth_service.py` — `revoke_tokens_for_daemon(daemon_id)` 헬퍼 추가
- `app/cli/commands/daemon.py:update_daemon` — `deactivate=True` 분기에서 `revoke_tokens_for_daemon` 호출
2. REQ-023:
- `app/repositories/postgres.py` — `list_service_tokens_with_null_daemon()` 구현: `SELECT ... FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL`
- `app/repositories/portfolio_repository.py` — 추상 메서드 추가
- `app/services/auth_service.py` — `list_null_daemon_service_tokens()`, `find_candidate_daemons_for_user(user_id)` 추가
- `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 서브커맨드: `--dry-run` inventory 출력, `--dry-run` 미포함 시 non-zero exit + 안내 메시지
3. 테스트:
- `tests/test_daemon_registry.py` 갱신 (REQ-022 AC-1~4)
- `tests/test_null_daemon_migration.py` 신규 (REQ-023 AC-1~5, FakeRepo 기반)
## Dependencies
- Internal:
- `app/cli/commands/daemon.py:update_daemon` — `--deactivate` 분기 기존 위치 확인 후 revoke 호출 삽입
- `app/cli/commands/service_tokens.py` — 기존 `service_token_app` Typer에 `migrate_null_daemon` 커맨드 추가
- `app/repositories/postgres.py` — `revoke_auth_token` 기존 구현 패턴 참조
- BAT-006 (REQ-016/017/018, release-candidate): auth_service.py token 기반 안정 상태
- External:
- 없음
## Design Gate
- Design Required Before Implementation: yes
- Reason: REQ-022의 트랜잭션 처리 방식(daemon deactivate + token revoke 동일 트랜잭션 여부), REQ-023의 dry-run 출력 형식 및 --dry-run 미포함 시 거부 동작을 design.md에서 확정해야 구현 시 누락 없이 진행 가능
## Milestones
1. design.md confirmed — 트랜잭션 전략, dry-run 출력 스펙, 테스트 전략 확정
2. REQ-022 repository + service 메서드 + daemon CLI 연결 + 회귀 테스트 통과
3. REQ-023 repository + service 헬퍼 + migrate_null_daemon CLI + 신규 테스트 통과
4. pytest 전체 통과 (regression 없음)
## Risks
- R-1 (Medium): REQ-022 트랜잭션 경계 — daemon update와 token revoke를 동일 트랜잭션으로 묶어야 원자성 보장. postgres.py에서 트랜잭션 패턴 확인 필요
- R-2 (Low): REQ-023 dry-run 출력 — 매핑 후보 daemon 조회에 `list_daemons_for_user(user_id)` 활용 가능 여부 확인 필요
- R-3 (Low): DATA-2 admin 테스트 fixture (NULL daemon active token 1건) — release plan에 수동 정리 절차 명시
@@ -0,0 +1,41 @@
# BAT-007 Verification
- Status: release-candidate
- Batch: BAT-007
## Acceptance Mapping
- REQ-022:
- AC-1: active daemon + 바인딩 service token 1개 이상 → `--deactivate --force` → 모든 바인딩 token revoked + daemon is_active=false → `test_deactivate_daemon_revokes_bound_tokens` / `test_deactivate_daemon_revokes_multiple_bound_tokens` PASSED
- AC-2: revoke된 token 인증 거부 — 기존 token 인증 테스트(`test_daemon_registry.py`)에서 `revoked_at is not None` 확인; v1 API 401 경로는 PostgreSQL 통합 테스트에서 별도 검증 예정
- AC-3: 바인딩 token 0건 daemon → deactivate → no-op → `test_deactivate_daemon_with_no_tokens_is_noop` PASSED
- AC-4: reactivate 후 revoke된 token 자동 복구 없음 → `test_reactivate_daemon_does_not_unrevoke_tokens` PASSED
- AC-5: in-flight 거래 보호 없음 — 테스트 불요 (release SOP 참조)
- REQ-023:
- AC-1: NULL daemon token 있을 때 dry-run → id/user_id/name 출력 → `test_migrate_null_daemon_dry_run_shows_inventory` PASSED
- AC-2: NULL daemon token 0건 → "no NULL daemon tokens found." + exit 0 → `test_migrate_null_daemon_dry_run_no_tokens_prints_empty_message` PASSED
- AC-3: `--dry-run` 없이 실행 → exit non-zero + 수동 안내 → `test_migrate_null_daemon_without_dry_run_exits_nonzero` PASSED
- AC-4: rebind 후 해당 token inventory 제외 → `test_migrate_null_daemon_after_rebind_token_excluded_from_inventory` PASSED
- AC-5: revoke 후 해당 token inventory 제외 → `test_migrate_null_daemon_after_revoke_token_excluded_from_inventory` PASSED
## Evidence
- Test:
```
tests/test_daemon_registry.py 19 passed (REQ-022: 5 new + 14 pre-existing)
tests/test_null_daemon_migration.py 6 passed (REQ-023: AC-1~5 + candidate daemon)
Total BAT-007 scope: 25 passed, 0 failed
```
- Full regression:
```
.venv/bin/python -m pytest -q
266 passed in 7.86s
```
- Manual: CLI dry-run 동작 코드 경로 검토 완료; `create_app(repository_factory=...)` 패턴 기반 CLI 통합 테스트로 실 CLI 경로 커버 확인
- Logs: 없음 (DB 불필요 단위/CLI 테스트)
## Result
- Ready for Release: true
- Blocking Issues: 없음
+2
View File
@@ -13,3 +13,5 @@
| BAT-003 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-006 | dcy-002 | [bat-003](./bat-003_20260519_tags-db-foundation/) |
| BAT-004 | standard | release-candidate | REQ-004, REQ-005 | dcy-002 | [bat-004](./bat-004_20260520_tags-http-api/) |
| BAT-005 | standard | release-candidate | REQ-007, REQ-008 | dcy-002 | [bat-005](./bat-005_20260520_pfhub-tag-cli-and-docs/) |
| BAT-006 | standard | release-candidate | REQ-016, REQ-017, REQ-018 | dcy-004 | [bat-006](./bat-006_20260520_token-surface-code-fixes/) |
| BAT-007 | standard | release-candidate | REQ-022, REQ-023 | dcy-004 | [bat-007](./bat-007_20260520_daemon-lifecycle-token-enforcement/) |
@@ -5,10 +5,10 @@
- 문서 ID: dcy-001_20260519_asset-catalog-and-user-portfolio-model
- 이슈명: 자산 관리 구조 재설계 — admin 관리 asset catalog와 user portfolio 분리 모델 도입
- 작성 시각(KST): 2026-05-19T00:00+09:00
- 마지막 갱신 시각(KST): 2026-05-19 (draft-req 완료 — REQ-009~015 생성)
- 마지막 갱신 시각(KST): 2026-05-20T12:00+09:00 (후속 Discovery 참조 추가 — dcy-003 옵션 B 결정으로 REQ-013 Deprecated 처리, 본 Discovery FR-5의 `GET /v1/assets` 통합 endpoint 결정은 dcy-003으로 회수됨)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015
- 후속 Discovery 참조: docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md (confirmed 2026-05-20 — path 명명 분리: `GET /v1/assets`는 admin catalog 전용으로 회수, `GET /v1/portfolios` 포지션 조회 신설. 본 Discovery FR-5의 `GET /v1/assets` 통합 endpoint 결정은 REQ-013 Deprecated로 처리 완료)
- 생성된 REQ 참조: REQ-009, REQ-010, REQ-011, REQ-012, ~~REQ-013~~ (Deprecated 2026-05-20 — dcy-003 옵션 B 결정), REQ-014, REQ-015
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
@@ -0,0 +1,304 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-003_20260520_user-portfolio-mutation-v1-api-migration
- 이슈명: User Portfolio Mutation v1 API 이전 및 path 명명 분리 (/v1/portfolios* 신설)
- 작성 시각(KST): 2026-05-20T00:00+09:00
- 마지막 갱신 시각(KST): 2026-05-20T15:00+09:00 (draft-req 완료 — REQ-025~032 8건 생성. FR-1+2 통합, FR-3/4/5/6/7/8/10 각 분리, FR-9는 REQ-013 Deprecated로 처리 완료라 별도 REQ 없음, NFR-1~4 흡수, docs/srs/index.md 갱신)
- 대체됨: 없음
- 후속 Discovery 참조: docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (client token 발급 경로 폐지 + 토큰 surface 정비, 2026-05-20 confirmed)
- 생성된 REQ 참조: REQ-025, REQ-026, REQ-027, REQ-028, REQ-029, REQ-030, REQ-031, REQ-032
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: temp/2026-05-18_todo.md (구현 결함 inventory — `POST /v1/assets/{asset_id}/ledger/trades`의 path param 의미 불일치 항목이 본 Discovery로 해소될 가능성)
- 해석:
- dcy-001(asset catalog 분리, confirmed)이 `# 5. 요구사항 목록 > 범위 경계 > Out of Scope`에서 "user portfolio mutation HTTP API ... 별도 Discovery에서 `/v1/` 경로로 이전 예정"을 명시했고, 본 Discovery가 그 후속에 해당한다. dcy-001 FR-2/FR-5가 정의한 `/v1/assets` path 위에 portfolio CRUD를 더 얹는 대신, path를 `/v1/portfolios*`로 완전 분리(옵션 B)하여 catalog와 포지션의 의미를 갈라낸다.
- 본 Discovery는 dcy-001 FR-5의 "GET /v1/assets = portfolio 조회 endpoint" 결정을 명시적으로 override한다. dcy-001 REQ-009~015는 2026-05-19 일괄 Approved, REQ-013(FR-5 산출물)은 본 Discovery 확정(2026-05-20) 후 change-req(CHG-20260520-01)로 Deprecated 처리 완료. 본 Discovery REQ 작성을 위한 선행 조건은 모두 충족됨.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 현재 활성 SDLC(docs/discovery/, docs/srs/) 어디에도 user portfolio mutation HTTP API 요구사항이 존재하지 않으며, dcy-001 Out of Scope가 "별도 Discovery에서 처리" 명시 예고를 남겼다.
- `temp/20260519_old_sdlc/discovery/dcy-007_20260518_v1-asset-crud-endpoints.md`는 폐기된 옛 SDLC의 draft 잔재로 active 후보가 아니다. 본 Discovery는 dcy-001(catalog 분리)과 dcy-002(tag_ids 정책)로 재정의된 새 데이터 모델 위에서 path 명명을 분리해 재구성하므로 기존 어떤 주기와도 중복되지 않는다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- v1 API에 portfolio mutation 경로가 존재하지 않는다. `app/api/v1_assets.py`(GET /v1/assets, daemon-scoped 조회), `app/api/v1_user_info.py`(GET /v1/user_info), `app/api/v1_ledger.py`(POST /v1/assets/{asset_id}/ledger/trades) 3종만 라우터에 등록되어 있다.
- `POST /v1/assets/{asset_id}/ledger/trades`의 path parameter 이름은 `asset_id`이나 실제 값은 `portfolios.portfolio_id`(UUID)로, asset과 portfolio의 의미가 한 path에 혼용된다.
- dcy-001 FR-5는 `GET /v1/assets`를 "토큰 타입별 통합 portfolio 조회"로 재정의했지만, 같은 path가 dcy-001 FR-2 정의의 catalog CRUD(`POST/PATCH /v1/assets`)와 포지션 조회 두 의미를 동시에 가져 응답 schema가 토큰 타입별로 갈라진다.
- portfolio 생성/수정/삭제/매수/매도/입출금/이체/태그/코멘트의 도메인 로직은 `app/services/portfolio_service.py`(`create_portfolio`, `update_portfolio`, `delete`, `buy`, `sell`, `invest`, `transfer`, `add_tag`, `remove_tag`, `create_comment`, `update_comment`, `delete_comment` 등)에 이미 구현되어 있으나 v1 라우터로 노출되지 않는다. CLI(`pfhub portfolio ...`)와 비-v1 `app/api/portfolios.py` 라우트만 해당 기능을 노출한다.
- baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md`)는 모두 존재하며 dcy-001/002 변경 사항을 아직 반영하지 않은 직전 baseline 상태다.
- 기대 상태
- `/v1/assets*` = admin asset catalog 전용 경로(dcy-001 FR-2 정의 범위만 유지).
- `/v1/portfolios*` = user/daemon/admin이 portfolio(포지션)를 CRUD하고 ledger·tags·comments를 다루는 경로 집합.
- `GET /v1/portfolios`는 토큰 타입별 분기 조회(admin: 본인 또는 `?user_id=<id>`로 타 user, user: 본인, daemon: `managed_by_daemon_id` 필터 + `?market` 지원).
- ledger 기록은 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전, path param 이름과 의미가 일치한다.
- portfolio 생성 시 dcy-001 catalog 검증(`asset_id`가 `status='active'`인지 확인) 적용, portfolio 태그 적용 시 dcy-002 tag_ids 정책(is_admin_tag=TRUE 또는 소유자 생성 태그) 준수.
- 이해관계자
- admin: portfolio HTTP CRUD를 통해 본인 또는 타 user의 포지션을 프로비저닝하는 운영자
- 로그인 user: 자신의 portfolio를 HTTP로 생성/수정/삭제/거래하려는 사용자
- daemon (trex-basic): 배정된 portfolio에 대해 조회·ledger 기록을 수행하는 서비스 에이전트. 본 Discovery 시점에 v1 path가 `/v1/portfolios*`로 변경되어 클라이언트 코드 수정이 필요하다
- repository-maintainers: 본 Discovery의 REQ 작성·구현·검증 담당
- 제약사항
- dcy-001 FR-5 결정을 override → dcy-001 REQ-013(Approved)에 change-req 처리 완료(2026-05-20). REQ-013 Status → Deprecated, dcy-001 §0 후속 Discovery 참조 갱신, REQ-010 Notes에 GET /v1/assets catalog 명세 처리 계획 추가.
- 선행 조건 충족: dcy-001 REQ-009~015 모두 Approved 상태(2026-05-19 일괄 승인). 본 Discovery REQ 작성은 즉시 가능.
- dcy-002 portfolios.tag_ids 정책(is_admin_tag 또는 소유자 검증) 준수
- trex-basic은 본 batch와 동시 배포 필요(한시적 별칭 운영 없이 — OQ-3 Answered 2026-05-20). 구 path는 동일 release에서 제거된다.
- 인증 모델은 현행 Bearer token 체계(env token 또는 DB user/service token + Permission 체계 ADMIN/TRADE_WRITE/READ)를 그대로 사용한다
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- admin이 `POST /v1/portfolios`에 body `{"asset_id": "<catalog-uuid>", "broker": "kiwoom", "cash_balance": 1_000_000, "user_id": "alice-uuid"}`로 요청해 alice의 portfolio를 프로비저닝한다. asset_id가 catalog에 `status='active'`로 존재하지 않으면 거부된다.
- alice가 로그인으로 발급받은 session token으로 `PATCH /v1/portfolios/{portfolio_id}`로 본인 portfolio의 `strategy_name`을 변경한다. bob의 token으로 alice의 portfolio를 수정하려 하면 HTTP 403을 받는다.
- alice가 `DELETE /v1/portfolios/{portfolio_id}`로 본인 portfolio를 종료한다. portfolio는 `status='disabled'`로 전환되고 `managed_by_daemon_id`는 자동 해제되며, ledger·comments·tags는 보존된다(soft delete, OQ-1 Answered 2026-05-20).
- daemon(service-token, daemon-bound)이 `GET /v1/portfolios?market=KRX`를 호출해 KRX 시장 활성 자산에 매핑되고 자신에게 배정된 portfolio만 받는다.
- daemon이 체결 발생 시 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 idempotent하게 거래 기록을 등록한다.
- user가 `POST /v1/portfolios/{portfolio_id}/tags` body `{"tag_id": "<uuid>"}`로 태그를 적용한다. 태그가 admin 생성이 아니고 소유자가 아니라면 HTTP 403(dcy-002 FR-3 정책)을 받는다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- `/v1/portfolios*` 신설 — POST/GET/PATCH/DELETE 및 `/ledger/trades`·`/tags`·`/comments` 서브 라우트 일괄 구현. 단일 라우트에서 토큰 타입(admin/user/daemon) 분기로 authorization 처리.
- `GET /v1/assets`의 portfolio 조회 의미 회수 → catalog 전용으로 복원. dcy-001 FR-5 산출물 REQ-013에 change-req(CHG-20260520-01) 적용 완료(Status → Deprecated, 2026-05-20).
- `POST /v1/assets/{asset_id}/ledger/trades`를 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전, path param 이름과 의미를 일치시킴. 동일 batch에서 구 path 제거.
- portfolio 생성 시 dcy-001 catalog 검증(asset_id `status='active'`) 통합, portfolio tag 적용 시 dcy-002 tag_ids 정책 준수.
- baseline 문서 갱신: `docs/interface-contract.md`, `docs/runtime-flows.md` (path 분리, daemon authorization, catalog 검증 흐름 반영)
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `app/api/v1_portfolios.py`: 신규 파일 — `/v1/portfolios*` 라우트 전체 (CRUD + ledger + tags + comments). 토큰 타입 분기 인증 헬퍼(`_require_portfolio_access`) 포함.
- `app/api/v1_assets.py`: GET 동작에서 portfolio 조회 로직 제거, catalog 전용으로 회수. dcy-001 FR-2의 admin catalog CRUD는 유지.
- `app/api/v1_ledger.py`: 신규 path(`/v1/portfolios/{portfolio_id}/ledger/trades`)로 라우트 prefix 이전하거나 `v1_portfolios.py`에 흡수. 구 path는 제거.
- `app/main.py`: v1 라우터 include 목록 갱신.
- `app/schemas/v1.py`: `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse`, `PortfolioTagApplyRequest`, `PortfolioCommentRequest` 등 신규 schema. 기존 `AssetItem`/`AssetsResponse`는 catalog 응답 의미로 회수 또는 분리.
- `app/services/portfolio_service.py`: 기존 메서드 재사용. `create_portfolio`에 catalog 검증(asset_id active 확인) 추가.
- `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`: 신규 메서드 도입 없음 또는 catalog 검증용 read-path만 추가. 기존 mutation 메서드 재사용.
- `app/api/portfolios.py`(legacy 비-v1 `/portfolios/*` 라우트): 본 Discovery 범위 밖. 제거 시점은 후속 Discovery로 분리.
- 문서 영향:
- `docs/interface-contract.md`: `/v1/portfolios*` 명세 추가, `/v1/assets*`의 의미 회수 반영, daemon authorization model 갱신
- `docs/runtime-flows.md`: portfolio mutation flow, catalog 검증 flow, /v1/portfolios path 명명 분리 정책, ledger path 이전 흐름
- `docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md`: §0 후속 Discovery 참조 → dcy-003 추가 완료(2026-05-20), 생성된 REQ 참조에 REQ-013 Deprecated 표기. REQ-013(`docs/srs/Interface/req-013_get-v1-assets-token-branching.md`)은 Deprecated 전환 + Change Log CHG-20260520-01 기록. REQ-010 Notes는 GET /v1/assets catalog 명세 처리 계획 보강 완료.
- `docs/project-structure.md`: `app/api/v1_portfolios.py` 신규 파일 추가 및 라우터 책임 갱신 — 필요 시 baseline 갱신
- 운영 영향:
- trex-basic은 본 batch release window에 새 path로 동시 배포 필요. release plan에 동시 배포 절차와 verification checklist 명시.
- admin이 HTTP로 타 user portfolio를 생성/삭제할 수 있게 되므로 admin token 발급·관리 절차에 권한 영향 반영
- DELETE 방식: soft delete로 확정(OQ-1 Answered 2026-05-20). 운영 SOP — portfolio status='disabled' 전환 + admin이 `PATCH status='enabled'`로 복구, 의존 데이터(ledger·comments·tags) 보존, 누적 disabled 정리는 별도 운영 Discovery로 위임
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: `POST /v1/portfolios` 신설 — Bearer 인증 후 portfolio 생성. body 필수 필드: `asset_id`, `broker`. 선택 필드: `name`, `cash_balance`, `asset_quantity`, `asset_price`, `profit_buffer`, `strategy_name`, `strategy_params`, `managed_by_daemon_id`, `user_id`. **토큰 분기 규칙 (OQ-2, OQ-5 Answered 2026-05-20)**:
- (1) `token_type ∈ {service, client}` → HTTP 403 fail-closed. `service`는 daemon-bound이며 portfolio CRUD 주체가 아님(OQ-2). `client`는 발급 경로 자체가 [dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md)(confirmed 2026-05-20)에서 폐지 확정. 본 Discovery는 폐지 release 이전 운영에서도 v1 portfolio mutation 진입을 사전 차단해 일관성 유지(OQ-5).
- (2) 그 외(`token_type=session` 또는 env token, `token_source` 무관):
- `permission='admin'` → admin 분기. body `user_id` non-null이면 해당 user의 portfolio로, null이면 인증 주체의 user_id로 생성.
- `permission ∈ {read, trade-write}` → user 분기. 인증 주체의 user_id로 생성. body `user_id` 지정은 무시 또는 HTTP 422.
- env admin token과 admin permission을 가진 session token은 admin 분기에서 동일하게 처리된다.
- FR-2: portfolio 생성 시 `asset_id`가 dcy-001 catalog의 `assets` 테이블에 존재하고 `status='active'`인지 검증한다. 미등록 또는 inactive면 HTTP 422(요청 검증) 또는 403(권한/정책) 중 dcy-001 FR-4와 일관된 응답 코드로 거부한다. 검증 위치(서비스 계층 공통 진입점 vs v1 라우터 레이어 한정)는 REQ 작성 단계에서 결정한다 — 공통 서비스 진입점에 두면 CLI 회귀 영향이 있고(R-5), 라우터 레이어에 두면 CLI는 무영향이나 검증 중복이 발생한다.
- FR-3: `PATCH /v1/portfolios/{portfolio_id}` 신설 — Bearer 인증 후 부분 수정. **토큰 분기 규칙 (OQ-2, OQ-5 Answered 2026-05-20, FR-1과 동일 패턴)**:
- (1) `token_type ∈ {service, client}` → HTTP 403 fail-closed.
- (2) 그 외(`token_type=session` 또는 env token):
- `permission='admin'` → 모든 portfolio 수정 가능.
- `permission ∈ {read, trade-write}` → 자기 소유(`owner_user_id = auth.user_id`) portfolio만 수정 가능, 불일치 시 HTTP 403.
- 비존재 portfolio는 HTTP 404. 수정 가능 필드는 기존 `UpdatePortfolioRequest` 범위(`broker`, `status`, `name`, `symbol`, `strategy_name`, `strategy_params`, `asset_price`, `managed_by_daemon_id` 등).
- FR-4: `DELETE /v1/portfolios/{portfolio_id}` 신설 — Bearer 인증 후 portfolio를 **soft delete**한다(OQ-1 Answered 2026-05-20). **토큰 분기 규칙 (OQ-2, OQ-5 Answered 2026-05-20, FR-1과 동일 패턴)**:
- (1) `token_type ∈ {service, client}` → HTTP 403 fail-closed.
- (2) 그 외(`token_type=session` 또는 env token):
- `permission='admin'` → 모든 portfolio 삭제 가능.
- `permission ∈ {read, trade-write}` → 자기 소유 portfolio만 삭제 가능, 불일치 시 HTTP 403.
- 동작: `portfolios.status = 'disabled'`로 전환, `managed_by_daemon_id`는 `NULL`로 자동 해제, `updated_at` 갱신. 의존 데이터(`portfolio_ledger`, `ledger_trades`, `portfolio_comments`, `portfolios.tag_ids`)는 모두 보존한다. disabled 상태 portfolio는 다음 write/도메인 동작이 차단된다: `PATCH`(status 전이 외 필드 변경), `POST /ledger/trades`, tag/comment 추가·수정·삭제. disabled portfolio는 `GET /v1/portfolios` 기본 응답에서 제외하되 `?include_disabled=true`로 조회 가능. 복구는 admin이 `PATCH /v1/portfolios/{portfolio_id}`로 `status='enabled'`(또는 그 전 status)로 전환하면 가능하며, daemon 재배정은 별도 호출로 수행한다. hard delete 경로는 v1에 노출하지 않는다. 비존재 portfolio는 HTTP 404, 이미 `disabled` 상태인 portfolio에 재호출 시 HTTP 200 + 멱등 응답(또는 HTTP 409 — REQ 단계에서 응답 코드 확정).
- FR-5: `GET /v1/portfolios` 신설 — 토큰 분기 조회 (dcy-001 FR-5의 정의를 path만 변경해 흡수). **토큰 분기 규칙 (OQ-5 Answered 2026-05-20)**:
- `token_type='service'`: daemon-bound 조회. `daemon_id is None`이면 HTTP 403 fail-closed. 그 외 `managed_by_daemon_id = auth.daemon_id` 필터 + `?market=<code>` 지원.
- `token_type='client'`: HTTP 403 fail-closed ([dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md) confirmed 2026-05-20 — 발급 경로 폐지 확정, OQ-5).
- `token_type ∈ {session, None(env)}`:
- `permission='admin'` → 기본 `owner_user_id = auth.user_id`로 본인 portfolio 반환. `?user_id=<id>` 지정 시 해당 user의 portfolio 반환.
- `permission ∈ {read, trade-write}` → `owner_user_id = auth.user_id` 필터, 본인 portfolio만 반환.
- 응답 항목의 `asset_id`는 dcy-001 catalog UUID 참조. 항목 schema는 새 `PortfolioItem`으로 정의.
- FR-6: `POST /v1/portfolios/{portfolio_id}/ledger/trades` 신설 — 기존 `POST /v1/assets/{asset_id}/ledger/trades` 동작을 path만 변경해 그대로 이전. daemon assignment 3단계 fail-closed 인가(token daemon_id NULL → 403, asset 미배정 → 403, 불일치 → 403), idempotency(uq_ledger_trades_idempotency), duplicate/conflict 응답(HTTP 200 status="duplicate" / HTTP 409) 동일 유지. 동일 batch에서 구 path는 제거되어 호출 시 HTTP 404.
- FR-7: portfolio tag 적용/해제/조회 — `POST /v1/portfolios/{portfolio_id}/tags` (body: `{"tag_id": "<uuid>"}`), `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}`, `GET /v1/portfolios/{portfolio_id}/tags`. dcy-002 FR-3 정책 준수: `tags.is_active=TRUE`이고 `tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`인 경우에만 적용 허용. 정책 위반 시 HTTP 403. ownership 규칙은 FR-3과 동일.
- FR-8: portfolio comment CRUD — `POST/GET /v1/portfolios/{portfolio_id}/comments`, `PATCH/DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}`. ownership 규칙은 FR-3과 동일. 기존 `CommentCreateRequest`/`CommentUpdateRequest`/`CommentResponse` schema 재사용 또는 v1 전용 래핑.
- FR-9: dcy-001 FR-5의 "GET /v1/assets = portfolio 조회 endpoint" 결정을 회수한다. `GET /v1/assets`는 dcy-001 FR-2의 admin catalog 조회 전용 의미로 복원. **dcy-001 FR-5 산출물 REQ-013에 change-req CHG-20260520-01 적용 완료(2026-05-20)** — Status → Deprecated, dcy-001 §0 후속 Discovery 참조 갱신, REQ-010 Notes 보강. `GET /v1/assets` catalog 조회 명세는 dcy-001 batch design 단계에서 REQ-010 보강 또는 신규 REQ로 처리 예정.
- FR-10: baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`) 갱신 — `/v1/portfolios*` 신설 명세, `/v1/assets*` 의미 회수, ledger path 이전, daemon authorization model 갱신, portfolio create flow에 catalog 검증 단계 추가.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: 모든 `/v1/portfolios*` 엔드포인트는 Bearer token 필수. 미인증 요청은 HTTP 401, 권한 부족은 HTTP 403, 비존재 자원은 HTTP 404로 통일한다.
- NFR-2: trex-basic과 portfolio-hub는 본 batch release window에서 **동시 배포된다(OQ-3 Answered 2026-05-20 — 한시적 별칭/redirect 채택하지 않음)**. 구 `/v1/assets`의 portfolio 조회 의미와 구 `POST /v1/assets/{asset_id}/ledger/trades` path는 동일 release에서 제거되며, 신 path(`/v1/portfolios*`)만 단일 서빙된다. release plan에 동시 배포 절차, 배포 순서(예: trex-basic 클라이언트 변경 사전 빌드 완료 → portfolio-hub 배포 → trex-basic 배포의 시점 잠금), rollback 조건(portfolio-hub 단독 rollback 시 trex-basic 영향 격리 방법)을 명시한다.
- NFR-3: authorization 분기는 라우터 레이어의 토큰 타입(admin/user/daemon) 분기로 단일 라우트 내에서 처리한다. principal별로 별도 라우트(예: `/v1/admin/portfolios`)를 신설하지 않는다.
- NFR-4: 기존 `app/services/portfolio_service.py`의 메서드 시그니처와 동작은 catalog 검증 통합 외 변경을 최소화한다. 서비스 계층 회귀를 줄이고 라우터 레이어 추가에 집중한다.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- `/v1/portfolios*` 라우트 신설: CRUD + ledger + tags + comments
- `/v1/assets`의 portfolio 조회 의미 회수 (catalog 전용 복원)
- `POST /v1/assets/{asset_id}/ledger/trades` → `POST /v1/portfolios/{portfolio_id}/ledger/trades` 이전 및 구 path 제거
- portfolio 생성 시 dcy-001 catalog 검증 통합
- portfolio tag 적용 시 dcy-002 tag_ids 정책 준수
- baseline 문서 갱신 (interface-contract.md, runtime-flows.md)
- dcy-001 FR-5 change-req 등록 — REQ-013 Deprecated 처리 완료(2026-05-20)
- trex-basic 동시 배포 운영 계획 수립 (절차 상세는 NFR-2 source of truth)
- Out of Scope
- 비-v1 `app/api/portfolios.py`의 legacy `/portfolios/*` 라우트 제거 — 후속 Discovery로 분리
- 사용자 로그인/세션 시스템 신설 — 현행 Bearer token 체계 유지
- 매수/매도/입출금/이체 등 거래 동사(`buy`, `sell`, `invest`, `transfer`)의 v1 HTTP 노출 — OQ-4 결정으로 본 Discovery 범위 밖 확정(2026-05-20). v1에서는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`(idempotency key 기반 ledger 기록)만 제공하며, 거래 동사는 현행 CLI(`pfhub portfolio buy/sell/invest/transfer`)로 계속 운용한다. HTTP 노출 필요성이 실증되면 후속 Discovery에서 거래 의미론(사후 기록 vs broker 호출 위임)과 daemon ledger 경로 책임 분리를 별도 정의한 뒤 추가한다.
- daemon(service-token)이 portfolio를 v1 API로 직접 생성·수정·삭제하는 경로 — OQ-2 결정으로 거부 확정(HTTP 403). daemon의 v1 권한은 조회(GET /v1/portfolios)와 ledger 기록(POST /v1/portfolios/{id}/ledger/trades)에 한정한다.
- trex-basic 클라이언트 코드 수정 — portfolio-hub 측 release plan에 동시 배포만 포함, trex-basic 측 변경은 해당 저장소 작업
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (High): trex-basic 동시 배포(NFR-2) 실패 시 trex-basic이 구 `/v1/assets`(포지션 조회) 또는 구 `POST /v1/assets/{asset_id}/ledger/trades`를 계속 호출해 HTTP 404가 발생하고 거래 기록이 차단되는 운영 장애가 발생할 수 있다. OQ-3에서 한시적 별칭을 채택하지 않기로 결정했으므로 동시 배포 절차의 정확성이 본 리스크의 유일한 방어선이다.
- 완화 방안: (a) release plan에 trex-basic 배포 의존성과 배포 순서 잠금(trex-basic 신 path 클라이언트 빌드 사전 완료 → portfolio-hub 배포 → trex-basic 배포)을 명시. (b) batch verification 단계에서 trex-basic의 신 path 호출 evidence(통합 smoke test 또는 staging 환경 dry-run)를 확보. (c) trex-basic 측 클라이언트가 양방향 호환(구·신 path 둘 다 시도) 가능하도록 일시 코드를 둘지 여부는 trex-basic 저장소 책임으로 분리 — portfolio-hub 측에서는 신 path 단일 서빙. (d) rollback 시나리오: portfolio-hub 단독 rollback이 trex-basic을 깨뜨리므로, rollback은 trex-basic 동반 rollback을 전제로 release plan에 명시한다.
- R-2 (Medium): DELETE를 soft delete로 결정(OQ-1 Answered)했으므로 hard delete로 인한 데이터 손실 리스크는 해소되었으나, `status='disabled'` portfolio가 누적되면 운영 가시성 저하와 `GET /v1/portfolios` 기본 응답 필터링 회귀 가능성이 남는다. 또한 disabled 상태에서 write 차단 경계(ledger trade·tag·comment 거부)가 누락되면 데이터 일관성이 깨질 수 있다.
- 완화 방안: disabled portfolio는 `GET /v1/portfolios` 기본 응답에서 제외하고 `?include_disabled=true` opt-in으로만 노출(FR-5 보강 필요 — REQ 단계에서 명시). 모든 write/도메인 동작 경로(`PATCH` status 외 필드, ledger trade, tag/comment 변경)에 disabled guard를 일관 적용하고 회귀 테스트로 검증한다. 누적 disabled 정리(아카이브, 영구 삭제 운영 SOP)는 별도 운영 Discovery로 위임한다.
- R-3 (해소됨, 잔여 모니터링 Low): dcy-001 FR-5 → REQ-013(Approved)에 대한 change-req 처리는 2026-05-20에 REQ-013 Deprecated 전환으로 완료. dcy-001 batch 일정 영향은 REQ-010 보강(GET /v1/assets catalog 명세) 작업이 batch design 단계에 추가되는 정도로 한정. 잔여 리스크는 REQ-010 보강 작업의 batch 편입 시점이 dcy-001 batch 일정을 지연시킬 가능성.
- 완화 방안: dcy-001 batch planning 시 REQ-010 보강 작업(또는 GET /v1/assets catalog용 신규 REQ)을 명시 항목으로 포함. dcy-003 신규 REQ들(`GET /v1/portfolios` 등)과 dcy-001 batch의 의존성을 batch planning 단계에서 정리.
- R-4 (Medium): `/v1/portfolios*` 신규 라우트가 기존 `app/api/portfolios.py`의 legacy `/portfolios/*` 라우트와 책임 중복을 일으켜 API 사용자 혼동 및 코드 유지보수 비용 증가 가능.
- 완화 방안: 본 Discovery에서 두 라우트 set의 책임 경계와 client guidance(권장 path, deprecation 안내)를 `docs/interface-contract.md`에 명시한다. legacy 제거는 후속 Discovery로 분리하되 본 Discovery 시점에 deprecation 표시.
- R-5 (Low): `app/services/portfolio_service.py`의 메서드 시그니처에 catalog 검증을 추가하면 CLI(`pfhub portfolio create`) 경로에도 영향을 미친다. CLI 회귀 가능성.
- 완화 방안: catalog 검증을 service 계층 공통 진입점에 두되 CLI 경로의 기존 동작과 호환되도록 입력 형태(legacy: symbol+broker, 신규: asset_id)를 모두 처리하거나 CLI 측 입력 변환 단계를 추가한다. REQ 작성 시 CLI 영향 범위 명시.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: dcy-001 FR-5는 dcy-001 draft-req 단계(2026-05-19)에서 REQ-013(`GET /v1/assets` 통합 endpoint)으로 작성되어 Approved 상태였으며, 본 Discovery 확정(2026-05-20) 후 REQ-013에 change-req(CHG-20260520-01) 적용으로 Deprecated 전환 완료. `GET /v1/assets` catalog 조회 의미는 REQ-010 보강 또는 신규 REQ로 dcy-001 batch design 단계에서 처리. 본 Discovery의 FR-9는 이 처리의 근거 문서다.
- A-2: dcy-002 portfolios.tag_ids 정책(`tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`)은 본 Discovery FR-7의 portfolio tag 적용 라우트에서 그대로 적용된다. tag entity 자체의 CRUD는 dcy-002 범위.
- A-3: 기존 `app/services/portfolio_service.py`의 메서드는 catalog 검증을 추가하는 형태로 재사용 가능하다. 서비스 계층의 시그니처 변경은 catalog 검증 통합에 한정하며, 신규 메서드를 대량 추가하지 않는다.
- A-4: trex-basic은 portfolio-hub 본 batch release window 내에 클라이언트 코드를 갱신해 동시 배포 가능하다. 별도 별칭/redirect 운영 없이 deploy 시점 조율만으로 충분하다(OQ-3 Answered 2026-05-20). trex-basic 측 release readiness는 DATA_TREX_BASIC_RELEASE_WINDOW로 외부 확인이 남아 있다.
- A-5: 인증 모델은 현행 `app/core/auth.py`의 Permission 체계(ADMIN/TRADE_WRITE/READ) + `AuthContext`(token_source, token_type, user_id, daemon_id, runtime_id) 기반으로 충분하며, 본 Discovery는 신규 권한 체계나 토큰 종류를 도입하지 않는다.
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: `DELETE /v1/portfolios/{portfolio_id}` 방식 — soft delete(`status='disabled'` 전환, 데이터 보존) vs hard delete(DB row 제거, ledger 함께 또는 `?keep_ledger=true` 옵션 노출)?
- 상태: Answered
- 처리 방안: soft delete로 결정(2026-05-20). `status='disabled'` 전환 + `managed_by_daemon_id` NULL 자동 해제 + 의존 데이터(ledger·comments·tags) 보존. disabled 상태에서 write/도메인 동작은 모두 차단하고, 복구는 admin의 `PATCH status` 전환으로 수행. hard delete 경로는 v1에 노출하지 않는다.
- 반영 위치: FR-4 본문 갱신 완료, R-2 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_DELETE_STRATEGY 처리 완료.
- OQ-2: `POST /v1/portfolios`에서 daemon(service-token)이 portfolio를 직접 생성할 수 있는가? 허용 시 `owner_user_id`는 token에 연결된 owner_user_id로 강제되는가? 거부 시 HTTP 403 응답 정책은 어떻게 정의하는가?
- 상태: Answered
- 처리 방안: 거부로 결정(2026-05-20). daemon(service-token)은 `POST /v1/portfolios` 호출 시 token_type 검사 단계에서 HTTP 403 + `detail="service token cannot create portfolios"`로 fail-closed 응답한다. daemon은 v1 API에서 portfolio CRUD 주체가 아니며 권한은 조회(GET)와 ledger 기록(POST /ledger/trades)에 한정한다.
- 반영 위치: FR-1 본문 갱신 완료, 범위 경계 Out of Scope 갱신 완료, §10 DECIDE에서 DECIDE_DAEMON_CREATE_ALLOWANCE 처리 완료.
- OQ-3: trex-basic 마이그레이션 전략 — 한시적 별칭(구 `/v1/assets` portfolio 조회와 구 ledger path를 신 path로 redirect 또는 dual-serve) 없이 동시 배포만으로 충분한가, 아니면 별칭/소프트 deprecation 기간이 필요한가?
- 상태: Answered
- 처리 방안: 한시적 별칭/redirect 없이 동시 배포로 결정(2026-05-20). 구 path는 동일 release에서 제거되고 신 `/v1/portfolios*`만 단일 서빙. rollback 시 portfolio-hub 단독 rollback은 trex-basic을 깨뜨리므로 release plan에 trex-basic 동반 rollback 전제를 명시한다.
- 반영 위치: NFR-2 본문 갱신 완료, R-1 완화 방안 갱신 완료, A-4 갱신 완료, §10 DECIDE에서 DECIDE_TREX_MIGRATION_STRATEGY 처리 완료. trex-basic 측 release readiness(DATA_TREX_BASIC_RELEASE_WINDOW)는 외부 확인 항목으로 잔존.
- OQ-4: 매수/매도/입출금/이체 등 거래 동사를 별도 sub-route(`POST /v1/portfolios/{portfolio_id}/buy`, `/sell`, `/invest`, `/transfer`)로 노출할 것인가, `PATCH /v1/portfolios/{portfolio_id}`로 통합할 것인가, 또는 v1에는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`만 노출하고 거래 동사는 CLI 전용으로 분리할 것인가?
- 상태: Answered
- 처리 방안: 옵션 C로 결정(2026-05-20). v1에는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`만 노출하고 거래 동사(`buy`/`sell`/`invest`/`transfer`)는 본 Discovery 범위에서 CLI 전용으로 유지. 근거: (a) ledger 기록 경로 단일화 — daemon ledger/trades와 user 거래 동사 sub-route가 동일 portfolio에 ledger를 작성하는 책임 중복을 방지. (b) §3 user 시나리오에 거래 동사 HTTP 호출이 명시되지 않음 — 본 Discovery 핵심 가치는 CRUD + tags + comments + 조회. (c) v1 API 표면 최소화로 batch 인가/검증/테스트 부하 감소. (d) HTTP 노출이 후속에서 필요해지면 별도 Discovery에서 거래 의미론(사후 기록 vs broker 호출 위임) 정의 후 추가 — 회수 비용 낮음.
- 반영 위치: §5 범위 경계 Out of Scope 갱신 완료, §10 DECIDE에서 DECIDE_TRADE_VERB_ROUTING 처리 완료. FR 추가는 없음(기존 FR-6 ledger/trades 노출로 충분).
- OQ-5: 본 Discovery에서 일관 사용하는 "user token" 개념의 `AuthContext.token_type` 매핑은 무엇인가? 현행 코드의 token_type은 `session`(로그인 발급), `client`(CLI/API 발급), `service`(daemon-bound) 3종이며, env admin token과 DB admin token이 동일하게 admin 분기에 들어가는지도 함께 정의해야 한다. FR-1/FR-3/FR-5의 토큰 분기 정책은 이 매핑이 확정되어야 REQ에서 인가 헬퍼(`_require_portfolio_access` 등)로 구현 가능하다.
- 상태: Answered
- 처리 방안: token_type 게이트 + permission 기준 분기로 결정(2026-05-20). (a) `token_type ∈ {service, client}` → HTTP 403 fail-closed. service는 daemon-bound로 portfolio CRUD 주체가 아님(OQ-2). client는 발급 경로 자체를 [dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md)(confirmed 2026-05-20)에서 폐지 확정 — 본 Discovery는 폐지 release 이전 운영에서도 v1 portfolio mutation 진입을 차단해 일관성 유지. (b) 그 외(`token_type=session` 또는 env token)는 `permission` 기준 분기 — admin이면 admin 분기, `read`/`trade-write`이면 user 분기. `token_source`(env/db)는 분기 기준에 사용하지 않으며 env admin token과 admin permission을 가진 session token은 admin 분기에서 동일 처리(dcy-004 OQ-5와 동일 결정). GET /v1/portfolios(FR-5)에서도 client 토큰은 거부되며 service 토큰은 daemon-bound 조회 의미로 별도 분기에서 정상 처리.
- 반영 위치: FR-1 본문 갱신 완료, FR-3 본문 갱신 완료, FR-4 본문 갱신 완료, FR-5 본문 갱신 완료, §3 사용자 시나리오 "session token"으로 명시 변경 완료, §10 DECIDE에서 DECIDE_USER_TOKEN_MAPPING 처리 완료. client token 발급 경로 폐지는 dcy-004(confirmed 2026-05-20)에서 확정되어 §10 DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY는 Collected 상태로 종료.
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다.
- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다.
- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: `POST /v1/portfolios`를 admin/user 토큰으로 호출했을 때 토큰 타입별 ownership 규칙(admin + user_id body로 타 user 생성, user 본인 생성)이 정확히 적용되고, daemon(service-token) 호출은 HTTP 403으로 fail-closed 거부되며, dcy-001 catalog 검증(asset_id active hit/miss 케이스)이 기대대로 응답한다.
- S-2: `PATCH /v1/portfolios/{id}`와 `DELETE /v1/portfolios/{id}`에서 ownership 불일치 시 HTTP 403, 미인증 시 HTTP 401, 비존재 portfolio 호출 시 HTTP 404를 반환한다. daemon(service-token)이 PATCH/DELETE를 호출하면 token_type 검사 단계에서 HTTP 403 fail-closed로 거부된다(FR-1 CREATE 거부와 동일 패턴). DELETE 호출 후 `portfolios.status='disabled'` + `managed_by_daemon_id IS NULL` 사후 상태 및 의존 데이터(ledger/comments/tags) 보존이 검증 가능하다. disabled portfolio에 대한 write 차단(PATCH status 외 필드, ledger trade, tag/comment 변경)이 일관 적용된다.
- S-3: `GET /v1/portfolios`가 service token + daemon-scoped 필터, user token 본인 portfolio, admin token + `?user_id`로 타 user portfolio, `?market` 필터를 모두 정확히 처리한다. NULL daemon_id service token은 fail-closed(HTTP 403).
- S-4: `POST /v1/portfolios/{portfolio_id}/ledger/trades`가 구 `POST /v1/assets/{asset_id}/ledger/trades`와 동일한 idempotency, daemon assignment authorization 3단계, duplicate/conflict 응답(HTTP 200/409)을 보장한다. 동일 batch에서 구 path는 HTTP 404를 반환한다.
- S-5: `/v1/portfolios/{portfolio_id}/tags`와 `/v1/portfolios/{portfolio_id}/comments`가 ownership 규칙과 dcy-002 tag_ids 정책을 위반한 호출에 대해 HTTP 403을 반환한다.
- S-6: trex-basic이 본 batch release window에 새 path로 무중단 이전되어 release 후 portfolio-hub `/health`는 200 OK를 반환하고 sample ledger write가 정상 성공한다.
- S-7: baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`)에 `/v1/portfolios*` 명세, `/v1/assets*` 의미 회수, ledger path 이전 흐름이 모두 반영되어 후속 REQ/Batch가 해당 문서를 단일 source of truth로 사용할 수 있다.
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: pytest로 admin/user principal별 create 시나리오(catalog active hit/miss, ownership 정상/위반, admin `user_id` body 처리) + daemon principal 호출 시 HTTP 403 거부 회귀를 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_crud.py`
- S-2 측정 방식: pytest로 PATCH/DELETE의 ownership 위반·미인증·비존재 케이스 검증 + daemon(service-token) 호출 시 HTTP 403 fail-closed 거부 회귀 + DELETE 사후 상태(`status='disabled'`, `managed_by_daemon_id IS NULL`, 의존 데이터 보존) 확인 + disabled portfolio에 대한 후속 write(ledger/tag/comment) 차단 회귀 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_crud.py`
- S-3 측정 방식: pytest로 GET /v1/portfolios 토큰별 분기, `?user_id`, `?market` 필터, NULL daemon_id 403 회귀 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_list.py`
- S-4 측정 방식: 기존 `tests/test_v1.py`의 ledger trade idempotency·daemon authorization 케이스를 신 path로 갱신해 회귀 통과 + 구 path 호출 시 HTTP 404 확인 / 데이터 출처: 갱신 `tests/test_v1.py`, 신규 `tests/test_v1_portfolios_ledger.py`
- S-5 측정 방식: pytest로 tag/comment CRUD 시나리오와 dcy-002 정책(is_admin_tag, 소유자 검증) 위반 케이스 검증 / 데이터 출처: 신규 `tests/test_v1_portfolios_tags_comments.py`
- S-6 측정 방식: release verification checklist의 health endpoint + sample portfolio create → ledger write smoke / 데이터 출처: 본 Discovery 후속 release 문서의 verification 섹션
- S-7 측정 방식: baseline 문서 갱신 후 docs/interface-contract.md, docs/runtime-flows.md를 검토해 `/v1/portfolios*` 명세, `/v1/assets*` 의미 회수, ledger path 이전 항목이 모두 반영되었는지 확인 / 데이터 출처: 갱신된 baseline 문서
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: OQ-1~5 전체 Answered 완료(2026-05-20). 토큰 분기 정책까지 명확해 In/Out of Scope 경계 최종화 완료. CONFIRM 2건 + DATA 2건은 §10에 잔존(사용자 확인 + 외부 데이터 + 후속 Discovery 작성 필요).
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~7 측정 방식이 §7 측정 방식 섹션에 각각 신규/갱신 pytest 시나리오(`tests/test_v1_portfolios_crud.py`, `tests/test_v1_portfolios_list.py`, `tests/test_v1_portfolios_ledger.py`, `tests/test_v1_portfolios_tags_comments.py`) 또는 release verification 체크 항목(S-6/S-7)으로 명시 연결됨.
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(trex-basic 동시 배포) 완화 방안 4단계(a~d) 명시, R-2(disabled 누적·write 차단) 완화 방안 명시, R-3 해소(REQ-013 Deprecated 처리 완료), R-4(legacy 라우트 책임 경계)·R-5(CLI 회귀) 완화 방안 명시. 책임자 = repository-maintainers(본 Discovery 단계). REQ 작성 시 작업별 책임자가 REQ Impacted Area에 반영될 예정.
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1~5 전체 Answered 완료(2026-05-20). Open 상태 OQ 0건.
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: §11 Confirmed By에 Justin 지정 완료(2026-05-20).
- [x] dcy-001 FR-5 change-req 처리 계획 확정 + 실행
- 기준: REQ-013(Approved, dcy-001 FR-5 산출물)에 change-req CHG-20260520-01 적용 완료 (Status → Deprecated, 2026-05-20). dcy-001 §0 후속 Discovery 참조 갱신 + REQ-010 Notes 보강 처리 완료. 후속: `GET /v1/assets` catalog 명세는 dcy-001 batch design 단계에서 REQ-010 보강 또는 신규 REQ로 처리.
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md
- 참조 문서:
- docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md (선행 Discovery — FR-5 override 대상, Out of Scope 후속 예고의 본 Discovery 해당)
- docs/discovery/dcy-002_20260519_tag-ownership-and-applicability-policy.md (병행 Discovery — portfolio tag 적용 정책 준수)
- docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (후속 Discovery, confirmed 2026-05-20 — client token 발급 경로 폐지 + 토큰 surface 정비. 본 Discovery의 FR-1/3/4/5 token_type 게이트가 dcy-004 폐지 결정과 정합)
- temp/20260519_old_sdlc/discovery/dcy-007_20260518_v1-asset-crud-endpoints.md (참조 — 폐기된 옛 SDLC의 draft 초안, FR/OQ 일부 승계 및 재구성)
- docs/interface-contract.md (현행 v1 API 계약, 본 Discovery로 갱신 예정)
- docs/runtime-flows.md (현행 v1 API flow, 본 Discovery로 갱신 예정)
- docs/project-structure.md (현행 패키지/모듈 baseline, v1 라우터 파일 추가에 따라 일부 영향)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- ~~{{DECIDE_DELETE_STRATEGY}}~~: **Decided** — soft delete(`status='disabled'` 전환, 데이터 보존)로 확정. hard delete 경로는 v1에 노출하지 않음. FR-4·R-2·OQ-1 본문 반영 완료. (OQ-1 Answered 2026-05-20)
- ~~{{DECIDE_DAEMON_CREATE_ALLOWANCE}}~~: **Decided** — daemon(service-token)의 `POST /v1/portfolios` 호출 거부 확정. HTTP 403 + `detail="service token cannot create portfolios"` fail-closed 응답. FR-1·범위 경계 Out of Scope·OQ-2 본문 반영 완료. (OQ-2 Answered 2026-05-20)
- ~~{{DECIDE_TREX_MIGRATION_STRATEGY}}~~: **Decided** — 한시적 별칭/redirect 없이 trex-basic + portfolio-hub 동시 배포로 확정. 구 path는 동일 release에서 제거. rollback 시 trex-basic 동반 rollback 전제. NFR-2·R-1·A-4·OQ-3 본문 반영 완료. (OQ-3 Answered 2026-05-20)
- ~~{{DECIDE_TRADE_VERB_ROUTING}}~~: **Decided** — 옵션 C로 확정. v1에는 `POST /v1/portfolios/{portfolio_id}/ledger/trades`만 노출, 거래 동사(buy/sell/invest/transfer)는 본 Discovery 범위에서 CLI 전용 유지. ledger 기록 경로 단일화, v1 표면 최소화, 후속 Discovery에서 거래 의미론 정의 후 추가 가능. §5 범위 경계 Out of Scope·OQ-4 본문 반영 완료. (OQ-4 Answered 2026-05-20)
- ~~{{DECIDE_USER_TOKEN_MAPPING}}~~: **Decided** — token_type 게이트 + permission 기준 분기. `service`/`client` token은 v1 portfolio mutation 진입 차단(HTTP 403). `session` 또는 env token은 `permission`이 admin이면 admin 분기, 그 외는 user 분기. `token_source`(env/db)는 분기 기준에 사용하지 않음. FR-1·FR-3·FR-4·FR-5·§3 시나리오·OQ-5 본문 반영 완료. client token 발급 경로 폐지는 후속 Discovery로 분리(DATA 항목 추적). (OQ-5 Answered 2026-05-20)
## CONFIRM
- ~~{{CONFIRM_DCY001_FR5_CHANGE_REQ}}~~: **Confirmed + Executed (2026-05-20)** — 옵션 B-3(REQ-013 Deprecated + REQ-010 보강 + 신규 REQ로 분리) 채택. REQ-013(Approved, dcy-001 FR-5 산출물)에 change-req CHG-20260520-01 적용 완료, Status → Deprecated. dcy-001 §0 후속 Discovery 참조 갱신, REQ-010 Notes 보강, docs/srs/index.md Register/Change Log Summary 갱신 완료. `GET /v1/assets` catalog 명세 처리는 dcy-001 batch design 단계로 이관.
- ~~{{CONFIRM_LEGACY_PORTFOLIOS_ROUTE_SCOPE}}~~: **Confirmed (2026-05-20)** — 본 Discovery는 `/v1/portfolios*` 신설만 다루고 기존 `app/api/portfolios.py`의 legacy `/portfolios/*` 라우트 제거는 후속 Discovery로 분리하는 범위 결정에 사용자 동의 완료. §4 영향 범위·§5 Out of Scope·R-4 본문에 이미 명시됨. 후속 Discovery 작성 시점은 본 Discovery REQ drafting 또는 batch 진행 중 별도 결정.
## DATA
- ~~{{DATA_TREX_BASIC_RELEASE_WINDOW}}~~: **External tracking (release plan 단계 수집)** — trex-basic 배포 운영 담당자가 본 batch release window에 동시 배포 가능한 일정과 rollback 절차를 확정해 제공한다. 본 Discovery는 정책(NFR-2 동시 배포·R-1 완화 방안)을 정의하고 실제 release window 확정은 batch design / release plan 단계 운영 작업이라 confirm-discovery 게이트 차단 사유 아님. release plan 작성 시 evidence(예: trex-basic 배포 일정, rollback 절차 협의 결과)를 batch verification 문서에 기록.
- ~~{{DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY}}~~: **Collected (2026-05-20)** — 후속 Discovery [dcy-004](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md) (confirmed 2026-05-20) 작성 완료. dcy-004 FR-1에서 `POST /auth/tokens`, `issue_client_token`, `CreateClientTokenRequest`, `IssuedClientTokenResponse` 모두 폐지로 확정. DATA-1 inventory 0건 확인으로 grace period/revoke 스크립트 불필요. 본 Discovery의 FR-1/3/4/5 token_type 게이트는 dcy-004 폐지 release 이전·이후 모두 정합.
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: OQ-1~5 Answered + CONFIRM 2건 Confirmed + DATA-1 Collected(dcy-004) + DATA-2 External tracking(release plan 단계) + §8 체크리스트 6건 모두 [x] 완료(2026-05-20). confirm-discovery 게이트 통과로 본 Discovery는 `confirmed` 상태로 승급되어 REQ 초안 작성 가능. **외부 의존**: DATA_TREX_BASIC_RELEASE_WINDOW(release plan 단계 수집), 향후 batch design 단계에서 dcy-001 REQ-010 보강 또는 신규 REQ(`GET /v1/assets` catalog 명세) 처리.
- Ready for REQ Drafting: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-05-20T14:00+09:00
@@ -0,0 +1,322 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-004_20260520_token-issuance-and-lifecycle-overhaul
- 이슈명: 토큰 발급 및 관리 전면 정비 — client token 폐지, service-token rotate 정상화, 발급 권한 매트릭스, 만료·회전·자동 revoke 정책
- 작성 시각(KST): 2026-05-20T07:00+09:00
- 마지막 갱신 시각(KST): 2026-05-20T14:30+09:00 (draft-req 완료 — REQ-016~024 9건 생성. FR-1/2/3/4+10/5/6/7/8/9 분해, NFR-1~6 흡수, docs/srs/index.md 갱신)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: temp/2026-05-18_todo.md (운영 결함 inventory — service-token rotate 실패, HTTP daemon_id forward 누락, rebind/AuthContext 문서 누락), temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md (rotate verification 실패 기록 + Feedback Handoff에 NULL daemon_id 마이그레이션·daemon deactivation 자동 revoke 항목 잔존)
- 해석:
- 현행 토큰 surface는 4종(session/client/service/env)이 발급 경로·만료·회전·문서화에서 부분 정합성 결함을 누적해 왔고, 작은 결함들(client v1 mutation 진입, service-token rotate 실 DB 실패, HTTP body daemon_id silently drop, rebind/AuthContext 문서 누락)이 운영 단계에서 노출되었다. dcy-003에서 client token 폐지를 후속 Discovery에 명시 위임하면서 토큰 surface 정비를 단일 batch로 묶을 시점이 됐다.
- 본 Discovery는 토큰 surface 전면 정비 — client token 발급 경로 폐지, rotate 정상화, HTTP/CLI 일관성, 발급 권한 매트릭스, 만료·회전 정책, daemon deactivation 자동 revoke, NULL daemon_id legacy token 마이그레이션, env admin token 운영 SOP — 를 단일 주기로 처리한다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 활성 SDLC(dcy-001 asset catalog, dcy-002 tag 정책, dcy-003 portfolio v1)는 모두 도메인 모델·portfolio API에 집중하며 토큰 surface 정비를 다루는 active Discovery는 없다. dcy-003 §10 DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY가 본 Discovery의 명시 예고에 해당한다.
- 폐기된 옛 SDLC의 dcy-005(production-admin-bootstrap)는 운영 admin 토큰 절차의 일부만 다루며 본 Discovery의 토큰 surface 전반 정비 범위와는 부분 겹침에 그친다. 새 SDLC 리셋(2026-05-19) 이후 토큰 surface 정비는 미수용 backlog로 남아 있었다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- 토큰 surface 4종: `app/core/auth.py`의 `AuthContext`가 `token_type ∈ {session, client, service, None(env)}` × `token_source ∈ {db, env}` 두 축으로 분류. 발급 경로는 `app/services/auth_service.py`의 `login`(session), `issue_client_token`(client), `issue_service_token`(service)에 분리. env token은 `settings.admin_tokens`/`trade_write_tokens`/`read_tokens`로 환경 변수에 박힌 형태.
- **결함 1 — client token 발급 경로 잔존**: `POST /auth/tokens` HTTP API([app/api/auth_routes.py:100-109](app/api/auth_routes.py#L100-L109))로 user가 본인 명의 발급 가능. dcy-003에서 v1 portfolio mutation 진입을 차단하기로 결정(token_type 게이트)했으나 발급 경로·`issue_client_token`·`CreateClientTokenRequest` 코드는 잔존.
- **결함 2 — service-token rotate 실 DB 실패**: `app/repositories/postgres.py:161-167`의 `idx_auth_tokens_service_name_unique` partial index가 `WHERE token_type='service'` 조건만 적용해 revoked row와 새 row가 같은 name으로 unique 충돌. [REL-006 verification](temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md#L74)에 "rotate insert 막힘" 검증 실패 기록.
- **결함 3 — POST /auth/service-tokens body `daemon_id` silently drop**: `app/schemas/auth.py:74-80`의 `IssueServiceTokenRequest.daemon_id` 필드는 노출되나 [app/api/auth_routes.py:131-137](app/api/auth_routes.py#L131-L137)에서 `service.issue_service_token` 호출 시 forward 하지 않음. CLI `--daemon-id`는 정상 forward하나 HTTP 경로만 daemon binding 불가.
- **결함 4 — `pfhub service-token rebind` CLI 문서화 누락**: [app/cli/commands/service_tokens.py:119-141](app/cli/commands/service_tokens.py#L119-L141)에 구현 완료, repository protocol/postgres 모두 존재하나 `docs/interface-contract.md` CLI surface 열거에 명시 없음.
- **결함 5 — runtime-flows.md AuthContext 필드 목록 daemon_id 누락**: `docs/runtime-flows.md:76`이 `user_id, token_type, permission, runtime_id`로 `daemon_id` 누락. 실제 코드는 보유.
- **정책 공백 1 — 발급 권한 매트릭스 부재**: 누가 어떤 token type을 발급/회전/폐기할 수 있는가가 코드에 흩어진 dependency(require_admin/require_read)로만 표현되고 baseline에 명시 없음.
- **정책 공백 2 — 만료·회전 정책 부재**: session은 `settings.session_token_ttl_hours` 강제, client는 발급 시 ttl 인자, service는 `--no-expiry` 허용. 일관 기준·강제 회전 주기 미정의.
- **정책 공백 3 — daemon deactivation 자동 revoke 부재**: `pfhub daemon update --deactivate`가 daemon `is_active=false`만 설정하고 바인딩 service token에 영향 없음 (REL-006 Feedback Handoff에 후속 항목으로 기록).
- **정책 공백 4 — NULL daemon_id legacy token 마이그레이션 자동화 부재**: REL-006 Feedback Handoff에 "rotate + rebind 자동화" 후보로 기록만 됨.
- **정책 공백 5 — env admin token 운영 SOP 부재**: 환경 변수 갱신·회전·폐기 절차가 baseline에 없음.
- 기대 상태
- 토큰 surface 3종(session/service/env)으로 정리, client token 발급 경로 코드 폐지
- service-token rotate가 실 DB에서 정상 작동
- HTTP/CLI 토큰 발급 surface가 일관 (daemon_id forward 누락 해소, rebind/AuthContext 문서 반영)
- 발급 권한 매트릭스·만료·회전 정책이 baseline에 명시
- daemon deactivation 시 바인딩 service token 자동 revoke (또는 grace period)
- NULL daemon_id legacy token 마이그레이션 자동화(CLI 서브커맨드 또는 스크립트, dry-run 포함)
- env admin token 회전·폐기 운영 SOP 정의
- 이해관계자
- admin/운영자: 토큰 발급·회전·폐기 SOP의 일관성 확보, env admin token 회전 책임
- 로그인 user: client 폐지 후 자동화 use case의 대체 경로 명확화
- daemon 운영 주체 (trex-basic 등): rotate/rebind 정상 작동, daemon deactivation 동기화
- repository-maintainers: 토큰 surface 정합성 유지, baseline 문서 일관성
- 제약사항
- dcy-003(draft, 본 Discovery 결정 완료 후 진행 재개 예정)이 client token v1 portfolio mutation 진입 차단을 이미 결정(OQ-5 Answered 2026-05-20) → 본 Discovery는 그 결정 위에서 발급 경로 자체 폐지로 진행. 진행 순서: 본 Discovery 결정·confirm 완료 → dcy-003 잔여 항목(CONFIRM 2건, DATA 1건, dcy-001 REQ Approved 선행 조건) 처리 재개
- 기존 발급된 client token이 운영 중일 수 있어 폐지 마이그레이션 절차 필요 (DB inventory 사전 조사)
- service-token rotate 정상화는 unique index 변경을 포함 → Alembic migration 추가
- env admin token 회전은 코드 변경과 환경 변수 갱신을 동시 수행해야 운영 차단 회피
- 본 Discovery 범위는 토큰 surface 정비에 한정. 신규 토큰 타입 도입(OAuth, PAT 등), 외부 SSO 통합, 감사 로그 정책은 별도 Discovery로 분리
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- admin이 alice의 trex-basic daemon용 service-token을 발급한다. `pfhub service-token issue NAME --user alice --daemon-id DID` (CLI) 또는 `POST /auth/service-tokens` body `{"name": "...", "username": "alice", "daemon_id": "DID", ...}` (HTTP) 두 경로 모두 동일하게 daemon_id binding이 작동한다.
- admin이 alice의 service-token을 회전한다. `pfhub service-token rotate NAME --force` 실행 시 새 raw token이 발급되고 unique index 충돌 없이 성공한다. 새 토큰의 `runtime_id`는 새로 발급, `daemon_id`는 유지(invariant).
- admin이 alice의 daemon을 비활성화한다(`pfhub daemon update DID --deactivate`). 해당 daemon에 바인딩된 service token이 자동 revoke되어 후속 v1 호출은 HTTP 401/403.
- user가 본인 명의 client token 발급을 시도하면 HTTP 404 또는 405 (path 자체 제거)를 받는다. 자동화 use case는 admin이 발급한 service token으로 흡수 또는 OQ-8 결정에 따른 새 경로.
- 운영자가 NULL daemon_id legacy service token을 일괄 마이그레이션한다. `pfhub service-token migrate-null-daemon --dry-run`으로 inventory 확인 후 매핑 결정, 실제 실행으로 rebind 또는 revoke 처리.
- env admin token 회전 시점에 운영자가 SOP에 따라 신규 admin token을 환경 변수에 추가 → service rolling restart → 구 admin token 환경 변수 제거 절차를 단계적으로 수행한다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- client token 발급 경로 코드 폐지: `POST /auth/tokens` HTTP route, `issue_client_token` service 메서드, `CreateClientTokenRequest`/`IssuedClientTokenResponse` schemas, 관련 테스트
- service-token rotate unique index 보강: `idx_auth_tokens_service_name_unique`에 `AND revoked_at IS NULL` 조건 추가 또는 동등 효과 정책 채택
- `POST /auth/service-tokens` HTTP body의 `daemon_id` forward 정상화: `auth_routes.py:131-137` 호출 시 `payload.daemon_id` 전달
- 토큰 발급 권한 매트릭스·만료·회전 정책을 baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`)에 명시
- daemon deactivation 시 바인딩 service token 자동 revoke 동작 추가 (`pfhub daemon update --deactivate` 흐름)
- NULL daemon_id legacy token 마이그레이션 자동화: 신규 CLI 서브커맨드(`pfhub service-token migrate-null-daemon`) 또는 일괄 스크립트, dry-run + report 포함
- env admin token 운영 SOP 정의: `docs/runtime-flows.md` 또는 `README.md`에 회전·폐기 절차 추가
- baseline 문서 정합성 회복: `pfhub service-token rebind` CLI 명세 추가, `AuthContext` 필드 목록에 `daemon_id` 추가, 토큰 surface 전체 명세 갱신
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `app/api/auth_routes.py`: `POST /auth/tokens` 라우트 + 관련 dependency 제거; `POST /auth/service-tokens`에서 `payload.daemon_id` forward 보정
- `app/services/auth_service.py`: `issue_client_token` 메서드 제거; `revoke_tokens_for_daemon(daemon_id)` 등 신규 헬퍼 도입 검토
- `app/schemas/auth.py`: `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거
- `app/repositories/postgres.py`: `idx_auth_tokens_service_name_unique` 조건 보강(`ensure_schema` + Alembic migration), daemon deactivation 시 바인딩 token 일괄 revoke를 위한 repository 메서드 추가
- `app/repositories/portfolio_repository.py`: Protocol에 신규 메서드 선언
- `app/cli/commands/daemon.py`: `update --deactivate` 동작에 자동 revoke 호출 추가
- `app/cli/commands/service_tokens.py`: `migrate-null-daemon` 신규 서브커맨드(또는 별도 모듈) 추가
- `migrations/versions/`: 신규 Alembic revision — partial index 조건 보강(CONCURRENTLY 적용 권장)
- `tests/`: client 폐지 후 호출 경로 회귀 테스트, rotate 정상화 통합 테스트, daemon deactivation 자동 revoke 회귀 테스트, NULL daemon_id 마이그레이션 dry-run 테스트
- 문서 영향:
- `docs/interface-contract.md`: client 발급 경로 제거, `pfhub service-token rebind` 명세 추가, 토큰 발급 권한 매트릭스 표 추가, 만료·회전 정책 명시
- `docs/runtime-flows.md`: 토큰 발급/회전/리바인드/자동 revoke flow, `AuthContext` 필드 목록에 `daemon_id` 추가, env admin token 운영 SOP flow
- `docs/data-model.md`: `auth_tokens` 인덱스 변경 반영, 토큰 surface 3종 정리
- `docs/project-structure.md`: 신규 마이그레이션 스크립트/CLI 서브커맨드 추가에 따른 패키지 책임 갱신(필요 시)
- `README.md`: daemon provisioning runbook 갱신 (auto-revoke 정책 반영), env admin token 회전 SOP 섹션 추가
- 운영 영향:
- 기존 발급된 client token 보유자 영향: 폐지 release 전 inventory 조사 + 보유자 통보 필요 (R-1)
- service-token rotate migration이 partial index recreate를 포함하므로 운영 시간대·CONCURRENTLY 적용 검토 필요 (R-2)
- daemon deactivation 자동 revoke가 in-flight 거래에 영향 가능 — graceful drain 절차 검토 (R-3)
- env admin token 회전 SOP가 잘못 적용되면 운영 차단 발생 — 다단계 절차 + rollback 명시 (R-4)
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: client token 발급 경로 코드 폐지 — `POST /auth/tokens` HTTP route, `app/services/auth_service.py:issue_client_token`, `CreateClientTokenRequest`, `IssuedClientTokenResponse`, 관련 테스트 모두 제거. 기존 발급된 client token 처리 정책(OQ-1 Answered 2026-05-20): **즉시 코드 폐지로 진행**. DATA-1 inventory(2026-05-20)에서 active client token 0건 확인됐으므로 별도 revoke 스크립트 실행이나 grace period 운영 불필요. 폐지 release 직전 동일 쿼리로 재확인하는 절차만 release plan에 포함한다.
- FR-2: `idx_auth_tokens_service_name_unique` partial index 조건 보강 (OQ-2 Answered 2026-05-20) — 현행 `WHERE token_type='service'`에 `AND revoked_at IS NULL` 추가. `ensure_schema()`(app/repositories/postgres.py)와 신규 Alembic migration 양쪽 갱신. 기존 active service token 행에는 영향 없음. migration은 `CREATE INDEX CONCURRENTLY` 적용으로 R-2 lock 영향 최소화(autocommit 외부 트랜잭션 필요).
- FR-3: `POST /auth/service-tokens` HTTP body `daemon_id` forward 정상화 — `app/api/auth_routes.py:131-137`이 `payload.daemon_id`를 `service.issue_service_token` 호출에 전달. CLI `--daemon-id`와 동등 동작 보장. 비존재/비활성 daemon 지정 시 동일한 ValueError → HTTP 4xx 변환.
- FR-4: baseline 문서 정합성 회복 — `docs/interface-contract.md` CLI surface에 `service-token rebind` 추가, `docs/runtime-flows.md` `AuthContext` 필드 목록에 `daemon_id` 추가, 토큰 surface 전체(env vs DB, session/service 만료 정책, rotate runtime_id/daemon_id invariant) 명세 갱신.
- FR-5: 토큰 발급 권한 매트릭스 정의 — type(session/service/env) × 발급자 권한(admin/trade-write/read) × 발급 가능 여부의 표를 `docs/interface-contract.md`에 명시. (client는 폐지 후 표에서 제거.) 매트릭스 위반 호출은 HTTP 403 fail-closed. **admin이 자기 자신에게 service token을 발급하는 경로는 허용**(OQ-4 Answered 2026-05-20)하며 admin은 본인 user_id를 대상으로 daemon-bound 또는 unbound service token을 발급할 수 있다. 발급된 self-service token의 권한 한도는 admin이 명시한 permission이지만, 관행상 자기 자동화에 필요한 최소 권한(예: trade-write)으로 발급할 것을 매트릭스 주석에 권장.
- FR-6: 토큰 만료·회전 정책 정의 (OQ-5 Answered 2026-05-20 — 현행 유지) — type별 기본 TTL을 `docs/runtime-flows.md`에 명시: session = `settings.session_token_ttl_hours`(현행 값 유지), service = 365일 default, env = 만료 없음. `no_expiry` 허용 범위는 **service 한정**(session/client 발급 경로 모두 거부). 강제 회전 주기는 본 정책에 두지 않으며 env admin token 회전 주기만 OQ-6에서 별도 결정한다. 정책 위반 발급(예: `no_expiry`를 session에 요청)은 HTTP 422 또는 ValueError로 거부.
- FR-7: daemon deactivation 자동 revoke (OQ-3 Answered 2026-05-20 — 즉시 revoke) — `pfhub daemon update <id> --deactivate` 실행 시 해당 daemon에 바인딩된 service token(`auth_tokens.daemon_id == <id>` AND `revoked_at IS NULL`)을 **동일 트랜잭션 내에서 즉시 revoke**. graceful drain은 daemon 측 책임이며 trex-basic 등 daemon 구현이 종료 전 in-flight 처리를 수행할 것을 release plan에 명시. CLI에 `--keep-tokens` 같은 회피 옵션은 노출하지 않는다(정책 일관성). 동작 evidence는 revoked_at 갱신 timestamp로 추적.
- FR-8: NULL daemon_id legacy token 마이그레이션 (OQ-7 Answered 2026-05-20 — 운영자 수동 매핑) — 신규 CLI 서브커맨드 `pfhub service-token migrate-null-daemon` 또는 동등 진단 스크립트. dry-run(`--dry-run`) 옵션으로 inventory + 매핑 후보(같은 `user_id`의 active daemon 목록) 출력. 실제 적용은 운영자가 token별로 `pfhub service-token rebind NAME --daemon-id ID`(매핑) 또는 `pfhub service-token revoke NAME`(폐기)을 수동 실행한다. 자동 매핑/auto-create daemon 경로는 노출하지 않는다(권한 escalation 위험). idempotent 작동(재실행 안전) 필수. 본 batch release 시점에는 DATA-2 inventory에서 발견된 admin 테스트 fixture 1건을 운영자가 직접 revoke 처리(별도 운영 작업).
- FR-9: env admin token 운영 SOP 정의 (OQ-6 Answered 2026-05-20 — 분기 권장 주기, 강제 없음) — `docs/runtime-flows.md` 또는 `README.md`에 명시할 항목: (1) **권장 회전 주기**: 분기(3개월). 강제 회전·만료 알람 없음. (2) **운영자 권한 매트릭스**: env admin token 추가/제거는 인프라 운영자 권한. (3) **다단계 회전 절차**: 신규 token 생성(`python -c "import secrets; print(secrets.token_urlsafe(48))"` 권장) → `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token 추가(콤마 분리) → service rolling restart → 운영 검증 → 구 token 제거 → service rolling restart. (4) **회전 트리거 조건**: 정기(분기) / token leak 의심 / 운영자 교체·퇴사 / 보안 사고 대응. (5) **rollback 절차**: 회전 실패 시 환경 변수에 구 token 재추가 후 rolling restart. (6) **token 강도 가이드**: 최소 48-byte URL-safe 무작위. 평문 단어·예측 가능한 패턴 금지.
- FR-10: `docs/data-model.md`, `docs/project-structure.md` baseline 갱신 — `auth_tokens` 인덱스 변경 반영, 토큰 surface 3종 정리, 신규 CLI/migration 파일 책임 반영(필요 시).
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: client token 폐지 후 dcy-003 v1 portfolio mutation 동작에 회귀 없음. `token_type='client'` 자체가 생성 불가하므로 dcy-003 FR-1/3/4/5의 게이트 로직은 유지하되 실 트래픽에서는 자연 사라짐.
- NFR-2: service-token rotate는 `docker-compose.local-test.yml` 실 DB 환경에서 unique 위반 없이 성공해야 한다. staging 환경 통합 검증 필수.
- NFR-3: 기존 service-token의 daemon_id invariant(rotate 시 daemon_id 유지)는 본 Discovery 변경 후에도 보존. runtime_id는 매 rotate마다 갱신.
- NFR-4: 토큰 발급 권한 매트릭스의 위반 호출은 HTTP 403 fail-closed로 통일.
- NFR-5: NULL daemon_id 마이그레이션은 idempotent. 재실행 시 안전, 부분 실패 시 보고된 예외 목록과 일치.
- NFR-6: `idx_auth_tokens_service_name_unique` 변경 migration은 운영 환경에서 long-running lock 회피를 위해 CONCURRENTLY 옵션 적용 또는 운영 시간대 적용 권장.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- client token 발급 경로 코드 폐지 + 기존 토큰 처리 절차
- service-token rotate unique index 보강 + migration
- HTTP `POST /auth/service-tokens` body daemon_id forward 정상화
- 토큰 발급 권한 매트릭스 정의 (baseline 명시)
- 토큰 만료·회전 정책 정의 (baseline 명시)
- daemon deactivation 자동 revoke
- NULL daemon_id legacy token 마이그레이션 자동화 (CLI + dry-run)
- env admin token 운영 SOP 정의
- baseline 문서 정합성 회복 (rebind, AuthContext daemon_id, 토큰 surface 전체)
- Out of Scope
- 신규 토큰 타입 도입 (Personal Access Token, OAuth, API key 서비스) — 별도 Discovery
- user 신원/SSO 통합 — 별도 Discovery
- 토큰 사용 감사 로그·audit trail 정책 — 별도 Discovery (단, daemon deactivation 자동 revoke의 logging은 본 Discovery 범위)
- token 시각 동기화·시간대 정책 — 별도 운영 Discovery
- service-token 발급량/rate-limit 정책 — 별도 Discovery
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (해소됨, 잔여 모니터링 Low): DATA-1 inventory(2026-05-20)에서 active client token 0건 확인됐으므로 폐지 release 시 보유자 영향 없음. 잔여 리스크는 release 직전 새로 발급된 client token이 0건이 아닐 가능성뿐이다.
- 완화 방안: 폐지 release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` 재확인을 release plan에 포함. >0이면 발급 사유 조사 후 즉시 revoke 또는 release 보류.
- R-2 (High): `idx_auth_tokens_service_name_unique` partial index 재생성이 long-running lock을 유발하면 운영 중 auth_tokens 쓰기가 블로킹된다.
- 완화 방안: Alembic migration에 `CREATE INDEX CONCURRENTLY` 적용(단, autocommit 외부 트랜잭션 필요), 운영 시간대 적용, staging 환경 dry-run으로 lock 시간 사전 측정. partial index 변경이 어려우면 OQ-2의 대안(name suffix 부여)으로 전환.
- R-3 (Medium): daemon deactivation 즉시 revoke(OQ-3 Answered)는 정책상 단순하나 in-flight ledger trade가 진행 중인 시점에 deactivation이 실행되면 거래 기록이 누락될 수 있다.
- 완화 방안: graceful drain은 daemon 구현 측 책임으로 분리(trex-basic이 종료 신호 수신 시 in-flight 마무리 후 종료). release plan에 deactivation SOP를 "(1) daemon 프로세스 종료/drain 완료 확인 → (2) `pfhub daemon update --deactivate` 실행 → (3) 자동 revoke" 순서로 명시. deactivation 호출 전 daemon process health check 권장.
- R-4 (Medium): env admin token 회전 절차가 잘못 적용되면 운영자가 admin HTTP/CLI 접근을 잃어 회복 자체가 불가.
- 완화 방안: env admin token 회전 SOP에 다단계 절차(신규 token 추가 → 동시 운영 기간 → 구 token 제거) + rollback 절차 명시. 회전 중 fallback admin 접근 경로(예: DB 직접 접근으로 토큰 재생성) 보장.
- R-5 (해소됨, 잔여 모니터링 Low): OQ-7에서 자동 매핑/auto-create daemon 경로를 채택하지 않기로 결정(2026-05-20)했으므로 권한 escalation 리스크는 본 Discovery 범위에서 발생하지 않는다. DATA-2 inventory(2026-05-20)에서 NULL daemon service token 1건만 발견됐고 운영자 확인 결과 테스트 fixture로 분류돼 본 batch 시점 release에 운영자가 직접 revoke 처리한다.
- 완화 방안: release 직전 동일 쿼리로 NULL daemon token 0건 재확인. >0이면 운영자가 token별 `rebind` 또는 `revoke` 수동 실행. `pfhub service-token migrate-null-daemon --dry-run` 보고에 `token.user_id`와 매핑 후보 daemon의 `owner_user_id` 일치 여부를 명시 표시(자동 매핑은 하지 않더라도 진단 데이터 제공).
- R-6 (Low): 토큰 발급 권한 매트릭스 정의가 현행 dependency 체계(require_admin/require_read)와 불일치하면 구현 단계에서 광범위 회귀 가능.
- 완화 방안: 매트릭스 정의 단계에서 코드 grep으로 현행 dependency 사용처와 1:1 매핑 확인, 불일치 시 매트릭스나 dependency 중 한쪽 우선 조정.
- R-7 (High, 본 Discovery 범위 밖 — 별도 hotfix 권장): DATA-3 수집(2026-05-20) 결과 **prod 환경에 env admin token이 약한 placeholder 값(`admin-token-789` — 예측 가능한 영문+숫자 패턴)으로 운영 중**. 환경 변수 leak 또는 단순 brute-force 시 즉시 admin 권한 escalation 가능. 본 Discovery는 토큰 surface 정비 batch이므로 단일 token 값 교체는 본 Discovery 외부 hotfix로 분리하며, 본 Discovery FR-9의 SOP가 이 hotfix의 표준 절차를 제공한다.
- 완화 방안: 본 Discovery confirm-discovery 이전이라도 보안상 즉시 hotfix 권장. 절차(FR-9 SOP 미리 적용): (1) 강한 무작위 token 생성, (2) `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token 추가, (3) service rolling restart, (4) 운영 검증, (5) 구 token(`admin-token-789`) 제거, (6) service rolling restart, (7) 회전 evidence 기록. hotfix 추적은 §10 DATA_PROD_ADMIN_TOKEN_HOTFIX 항목으로 관리.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: 본 Discovery 후 토큰 surface는 4종(session/client/service/env)에서 3종(session/service/env)으로 정리된다. user의 자동화 use case는 admin이 발급한 service token으로 흡수한다(OQ-8 Answered 2026-05-20 — admin 발급 service token으로 충분). PAT 등 새 토큰 타입 도입은 본 batch 후 운영 피드백을 보고 별도 Discovery에서 판단한다.
- A-2: dcy-003 FR-1/3/4/5의 token_type 게이트(`token_type ∈ {service, client}` 거부)는 client 폐지 후 "client는 더 이상 발급 불가"로 자연 강화. 게이트 자체는 env token 외부 침투 방어로 유지.
- A-3: `idx_auth_tokens_service_name_unique` 보강은 단일 Alembic migration으로 처리 가능. 기존 active service token 행 데이터에는 변경 없음.
- A-4: daemon deactivation 자동 revoke는 기존 daemon registry CRUD(dcy-006 모델 — `daemons` 테이블, `is_active` 플래그) 위에 얹는 동작이며 daemon 모델 자체에는 변경 없음.
- A-5: env admin token 회전 SOP는 환경 변수 다중 등록(예: `PORTFOLIOHUB_ADMIN_TOKENS` 콤마 분리)이 가능한 현행 구조([app/core/auth.py:43-50](app/core/auth.py#L43-L50)의 `_resolve_permission`이 set 기반)를 활용해 다단계 운영이 가능하다.
- A-6: 본 Discovery는 기존 service-token의 정상 운영 중 daemon binding을 변경하지 않는다. NULL daemon_id token만 마이그레이션 대상이며, 이미 daemon-bound token은 그대로 유지.
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: 기존 발급된 client token 처리 정책 — 폐지 release에서 즉시 일괄 revoke vs grace period(기간 명시) vs 자연 만료 대기?
- 상태: Answered
- 처리 방안: 즉시 코드 폐지로 결정(2026-05-20). DATA-1 inventory에서 active client token 0건 확인됐으므로 grace period/revoke 스크립트 불필요. 폐지 release 직전 동일 쿼리로 재확인하는 절차만 release plan에 포함.
- 반영 위치: FR-1 본문 갱신 완료, R-1 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_CLIENT_TOKEN_DEPRECATION_POLICY 처리 완료, §10 DATA에서 DATA_CLIENT_TOKEN_INVENTORY 수집 완료(0건).
- OQ-2: `idx_auth_tokens_service_name_unique` 보강 방식 — `AND revoked_at IS NULL` partial 조건 추가 vs rotate 시 revoked row의 name suffix 부여(예: `<name>__revoked_<ts>`) vs unique 제약을 active row로만 한정하는 별도 모델 재설계?
- 상태: Answered
- 처리 방안: partial 조건 추가로 결정(2026-05-20). `WHERE token_type='service' AND revoked_at IS NULL`. 단일 Alembic migration + CONCURRENTLY 적용으로 R-2 lock 영향 완화. ensure_schema와 migration 양쪽 갱신.
- 반영 위치: FR-2 본문 갱신 완료, §10 DECIDE에서 DECIDE_ROTATE_UNIQUE_INDEX_STRATEGY 처리 완료.
- OQ-3: daemon deactivation 자동 revoke 동작 — 즉시 revoke vs grace period(예: 30분 drain) vs 운영자 별도 확정 명령?
- 상태: Answered
- 처리 방안: 즉시 revoke로 결정(2026-05-20). `pfhub daemon update --deactivate` 실행 시 동일 트랜잭션 내에서 바인딩 service token 모두 revoke. graceful drain은 daemon 측 책임으로 분리. release plan에 deactivation SOP(drain 완료 확인 → deactivate 실행 → 자동 revoke) 명시.
- 반영 위치: FR-7 본문 갱신 완료, R-3 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_DAEMON_DEACTIVATION_REVOKE_TIMING 처리 완료.
- OQ-4: 토큰 발급 권한 매트릭스에서 admin이 자기 자신에게 service token을 발급할 수 있는가? (admin이 본인 user_id로 service token 발급해 운영 자동화에 사용하는 시나리오)
- 상태: Answered
- 처리 방안: 허용으로 결정(2026-05-20). admin이 본인 user_id 대상으로 daemon-bound 또는 unbound service token 발급 가능. 권한 한도는 admin이 명시한 permission이며, 관행상 최소 권한(예: trade-write) 발급을 매트릭스 주석에 권장.
- 반영 위치: FR-5 본문 갱신 완료, §10 DECIDE에서 DECIDE_ADMIN_SELF_SERVICE_TOKEN 처리 완료.
- OQ-5: 토큰 type별 기본 TTL — session(현행 `session_token_ttl_hours`), service(현행 365일 default), env(만료 없음) 유지 vs 신규 정책?
- 상태: Answered
- 처리 방안: 현행 유지로 결정(2026-05-20). session/service/env 각 정책이 운영상 안정. `no_expiry` 옵션은 service 한정. 강제 회전 주기는 OQ-6 env admin 결정으로 흡수.
- 반영 위치: FR-6 본문 갱신 완료, §10 DECIDE에서 DECIDE_TOKEN_TTL_POLICY 처리 완료.
- OQ-6: env admin token 회전 주기 — 권장 주기(예: 분기/반기/연간), 강제 회전 여부?
- 상태: Answered
- 처리 방안: 분기(3개월) 권장 주기, 강제 회전 없음으로 결정(2026-05-20). 만료 알람 없음. SOP에 회전 트리거 조건(정기·leak 의심·운영자 교체·보안 사고)과 다단계 절차·rollback·token 강도 가이드를 명시.
- 반영 위치: FR-9 본문 갱신 완료, §10 DECIDE에서 DECIDE_ENV_ADMIN_ROTATION_INTERVAL 처리 완료, §10 DATA에서 DATA_ENV_ADMIN_TOKEN_ROTATION_HISTORY 수집 완료. 추가로 R-7(prod 약한 token hotfix) 신규 추가 및 §10 DATA_PROD_ADMIN_TOKEN_HOTFIX 추적 항목 추가.
- OQ-7: NULL daemon_id legacy token 마이그레이션 매핑 전략 — 운영자 수동 매핑(token name 기반 추정) vs daemon auto-create 후 매핑 vs 폐기 후 재발급?
- 상태: Answered
- 처리 방안: 운영자 수동 매핑으로 결정(2026-05-20). CLI는 dry-run으로 inventory + 매핑 후보(같은 user_id의 active daemon) 진단만 제공하고 실제 적용은 운영자가 `rebind` 또는 `revoke`를 명시적으로 실행. 자동 매핑/auto-create daemon은 권한 escalation 리스크로 채택하지 않음. DATA-2 inventory(2026-05-20)에서 발견된 admin 테스트 fixture 1건은 운영자가 직접 revoke 처리.
- 반영 위치: FR-8 본문 갱신 완료, R-5 완화 방안 갱신 완료, §10 DECIDE에서 DECIDE_NULL_DAEMON_MIGRATION_STRATEGY 처리 완료, §10 DATA에서 DATA_NULL_DAEMON_TOKEN_INVENTORY 수집 완료(1건, 테스트 fixture).
- OQ-8: client token 폐지 후 user 자동화 경로 — admin 발급 service token으로 충분한가, user가 본인 명의로 발급 가능한 새 토큰 타입(예: Personal Access Token)을 후속 Discovery에서 도입할지?
- 상태: Answered
- 처리 방안: admin 발급 service token으로 충분으로 결정(2026-05-20). 본 batch 후 토큰 surface 3종(session/service/env)으로 종료. PAT 등 새 토큰 타입은 본 batch 후 운영 피드백을 확인한 뒤 필요 시 별도 Discovery에서 검토.
- 반영 위치: A-1 갱신 완료, §10 DECIDE에서 DECIDE_USER_AUTOMATION_PATH_AFTER_CLIENT 처리 완료.
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다.
- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다.
- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: codebase에서 client token 관련 코드 모두 제거 — `POST /auth/tokens` 라우트, `issue_client_token` 메서드, `CreateClientTokenRequest`/`IssuedClientTokenResponse` schemas grep 결과 0건.
- S-2: 실 DB 환경에서 `pfhub service-token issue NAME --user U --no-expiry` → `pfhub service-token rotate NAME --force` 시퀀스가 unique 위반 없이 성공. 새 token의 `runtime_id`는 갱신, `daemon_id`는 유지.
- S-3: `POST /auth/service-tokens` body `{"daemon_id": "<id>", "name": "...", "username": "..."}`로 호출 시 issued token의 `auth_token.daemon_id == <id>` 반환.
- S-4: `pfhub daemon update <id> --deactivate` 실행 후 해당 daemon에 바인딩된 service token이 모두 `revoked_at IS NOT NULL` 상태로 전환 (또는 OQ-3 결정 방식대로 grace period 후 전환).
- S-5: `pfhub service-token migrate-null-daemon --dry-run` 실행 시 NULL daemon_id token inventory + 매핑 후보가 보고로 출력. 실 실행 후 NULL daemon_id token이 0건 또는 예외 보고 목록과 일치.
- S-6: baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`)에 토큰 surface 변경(client 제거, rebind 추가, AuthContext daemon_id, 발급 권한 매트릭스, 만료·회전 정책, 자동 revoke flow, env admin SOP) 모두 반영. baseline grep으로 누락 없음 확인.
- S-7: env admin token 회전 SOP가 `docs/runtime-flows.md` 또는 `README.md`에 명시되어 있고, staging 환경에서 dry-run으로 회전 절차 실행 가능.
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: codebase grep(`grep -rn "issue_client_token\|CreateClientTokenRequest\|POST /auth/tokens"`) + pytest로 폐지된 endpoint 호출 시 HTTP 404/405 확인 / 데이터 출처: codebase grep 결과 + 신규 `tests/test_auth_surface_after_client_deprecation.py`
- S-2 측정 방식: 실 DB(`docker-compose.local-test.yml`) 환경 통합 테스트 / 데이터 출처: 신규 `tests/test_service_token_rotate_integration.py` (DB 의존 마킹)
- S-3 측정 방식: pytest로 HTTP request 시뮬레이션 / 데이터 출처: 갱신 `tests/test_auth.py`
- S-4 측정 방식: pytest로 daemon deactivation 후 바인딩 token 상태 검증 / 데이터 출처: 갱신 `tests/test_daemon_registry.py`
- S-5 측정 방식: CLI smoke + 마이그레이션 스크립트 단위 테스트 (dry-run 출력 검증 + idempotency 회귀) / 데이터 출처: 신규 `tests/test_null_daemon_migration.py`
- S-6 측정 방식: baseline 문서 grep + 사람 리뷰 (`grep -n "service-token rebind\|AuthContext.daemon_id\|발급 권한 매트릭스\|만료·회전 정책" docs/`) / 데이터 출처: 갱신된 baseline 문서
- S-7 측정 방식: SOP 문서 + 운영 환경 dry-run 실행 결과 / 데이터 출처: 갱신 `README.md` 또는 `docs/runtime-flows.md`의 env admin SOP 섹션
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: OQ-1~8 전체 Answered 완료(2026-05-20). In/Out of Scope 경계 최종화 완료. CONFIRM 2건 모두 Confirmed. DATA-1/2/3 Collected. R-7(prod 약한 token hotfix)는 본 Discovery 범위 밖 별도 운영 작업으로 분리.
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~7 측정 방식이 §7 측정 방식 섹션에 각각 신규/갱신 pytest 시나리오(`tests/test_auth_surface_after_client_deprecation.py`, `tests/test_service_token_rotate_integration.py`, `tests/test_auth.py`, `tests/test_daemon_registry.py`, `tests/test_null_daemon_migration.py`) 또는 SOP 문서(`README.md`/`docs/runtime-flows.md`)로 명시 연결됨.
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(client token 보유자 식별)은 DATA-1 0건 확인으로 해소(잔여 Low), R-2(migration lock)는 CONCURRENTLY 적용으로 완화. R-7(prod 약한 admin token)은 본 Discovery 외부 hotfix로 분리. 책임자 = repository-maintainers(본 Discovery 단계). REQ 작성 시 개별 작업 책임자가 REQ Impacted Area에 반영될 예정.
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1~8 전체 Answered 완료(2026-05-20). Open 상태 OQ 0건.
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: §11 Confirmed By에 Justin 지정 완료(2026-05-20).
- [x] dcy-003 의존성 확인
- 기준: dcy-003(draft)의 client token 폐지 결정이 본 Discovery로 이관됐음을 §3 제약사항·§9 참조에 반영 완료. 진행 순서: 본 Discovery 결정·confirm 완료 → dcy-003 잔여 항목 처리 재개(사용자 결정 2026-05-20).
- [x] 운영 inventory 수집
- 기준: DATA-1(client token, 0건) / DATA-2(NULL daemon service token, 1건 테스트 fixture) / DATA-3(env admin token 1개, 약한 placeholder, 회전 이력 없음) 모두 Collected(2026-05-20). DATA_PROD_ADMIN_TOKEN_HOTFIX는 본 Discovery 외부 hotfix 추적 항목으로 잔존.
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md
- 참조 문서:
- docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md (선행 Discovery — client token 폐지 결정 위임, DATA_CLIENT_TOKEN_DEPRECATION_DISCOVERY 명시 예고)
- temp/2026-05-18_todo.md (운영 결함 inventory — service-token rotate 실패, HTTP body daemon_id 무시, rebind/AuthContext 문서 누락)
- temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md (rotate verification 실패 기록 + Feedback Handoff에 NULL daemon_id 마이그레이션·daemon deactivation 자동 revoke 잔존)
- app/core/auth.py, app/services/auth_service.py, app/api/auth_routes.py, app/schemas/auth.py, app/repositories/postgres.py, app/cli/commands/service_tokens.py, app/cli/commands/daemon.py (저장소 근거)
- docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md, docs/project-structure.md (본 Discovery로 갱신 예정)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- ~~{{DECIDE_CLIENT_TOKEN_DEPRECATION_POLICY}}~~: **Decided** — 즉시 코드 폐지. DATA-1 inventory(2026-05-20) active client token 0건 확인, grace period/revoke 스크립트 불필요. release 직전 재확인만 release plan에 포함. FR-1·R-1·OQ-1 본문 반영 완료. (OQ-1 Answered 2026-05-20)
- ~~{{DECIDE_ROTATE_UNIQUE_INDEX_STRATEGY}}~~: **Decided** — `idx_auth_tokens_service_name_unique`에 `AND revoked_at IS NULL` partial 조건 추가. CONCURRENTLY migration으로 R-2 lock 완화. FR-2·OQ-2 본문 반영 완료. (OQ-2 Answered 2026-05-20)
- ~~{{DECIDE_DAEMON_DEACTIVATION_REVOKE_TIMING}}~~: **Decided** — daemon deactivation 즉시 revoke. graceful drain은 daemon 측 책임 분리. release plan에 SOP 명시. FR-7·R-3·OQ-3 본문 반영 완료. (OQ-3 Answered 2026-05-20)
- ~~{{DECIDE_ADMIN_SELF_SERVICE_TOKEN}}~~: **Decided** — admin이 본인 user_id 대상으로 service token 발급 허용. 권한 한도는 명시 permission, 최소 권한 발급 권장. FR-5·OQ-4 본문 반영 완료. (OQ-4 Answered 2026-05-20)
- ~~{{DECIDE_TOKEN_TTL_POLICY}}~~: **Decided** — 현행 TTL 정책 유지(session/service/env 각 정책). `no_expiry`는 service 한정. 강제 회전 주기는 env admin(OQ-6)에서 별도 결정. FR-6·OQ-5 본문 반영 완료. (OQ-5 Answered 2026-05-20)
- ~~{{DECIDE_ENV_ADMIN_ROTATION_INTERVAL}}~~: **Decided** — 분기(3개월) 권장 주기, 강제 회전 없음. SOP에 다단계 절차·rollback·token 강도 가이드 명시. FR-9·OQ-6 본문 반영 완료. (OQ-6 Answered 2026-05-20)
- ~~{{DECIDE_NULL_DAEMON_MIGRATION_STRATEGY}}~~: **Decided** — 운영자 수동 매핑. CLI는 dry-run 진단(inventory + 매핑 후보 user_id 일치 표시)만 제공, 실제 rebind/revoke는 운영자가 명시적으로 실행. 자동 매핑/auto-create 미채택(권한 escalation 리스크). DATA-2 inventory(2026-05-20) 1건(admin 테스트 fixture)은 운영자가 직접 revoke 처리. FR-8·R-5·OQ-7 본문 반영 완료. (OQ-7 Answered 2026-05-20)
- ~~{{DECIDE_USER_AUTOMATION_PATH_AFTER_CLIENT}}~~: **Decided** — admin 발급 service token으로 충분. PAT 등 새 토큰 타입은 본 batch 후 운영 피드백 확인 후 별도 Discovery에서 검토. A-1·OQ-8 본문 반영 완료. (OQ-8 Answered 2026-05-20)
## CONFIRM
- ~~{{CONFIRM_DISCOVERY_SCOPE_OPTION_C}}~~: **Confirmed** — 옵션 C(Wide — 토큰 발급/관리 전면 정비) 범위로 9개 변경 항목을 단일 batch로 다루는 결정 사용자 동의 완료. (2026-05-20)
- ~~{{CONFIRM_DCY003_DEPENDENCY}}~~: **Confirmed** — dcy-003(draft)에서 결정된 client token v1 mutation 진입 차단 정책이 본 Discovery로 이관됐다는 의존성 관계 사용자 동의. 진행 순서: 본 Discovery 결정·confirm 완료 → dcy-003 잔여 항목 처리 재개. §3 제약사항·§8 체크리스트·§11 Handoff Rationale 반영 완료. (2026-05-20)
## DATA
- ~~{{DATA_CLIENT_TOKEN_INVENTORY}}~~: **Collected (2026-05-20)** — active client token 0건. `SELECT ... WHERE token_type='client' AND revoked_at IS NULL` 결과 0행. OQ-1 즉시 폐지 결정의 evidence.
- ~~{{DATA_NULL_DAEMON_TOKEN_INVENTORY}}~~: **Collected (2026-05-20)** — NULL daemon_id active service token 1건. token name `admin`(user `500e336a-...`, created 2026-05-11, no_expiry). 운영자 확인 결과 **테스트 fixture, 운영 용도 없음**. release 시점에 운영자가 `pfhub service-token revoke admin` 수동 실행으로 정리. OQ-7 수동 매핑 결정의 evidence.
- ~~{{DATA_ENV_ADMIN_TOKEN_ROTATION_HISTORY}}~~: **Collected (2026-05-20)** — prod 환경 `PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789` 1개 운영 중. **token 강도: 약함**(예측 가능 영문+숫자 placeholder, dev default 추정). 회전 이력 없음(최초 발급 후 미회전). SOP 부재. OQ-6 회전 주기 결정 및 R-7 hotfix 트리거의 evidence.
- ~~{{DATA_PROD_ADMIN_TOKEN_HOTFIX}}~~: **External tracking (본 Discovery 외부 hotfix)** — prod 환경 `admin-token-789` 교체는 §6 R-7로 추적. 본 Discovery는 SOP(FR-9)를 정의하고 hotfix는 그 SOP를 적용하는 운영 작업이라 본 Discovery confirm-discovery 게이트 차단 사유는 아님. 실행 evidence는 운영 노트에 별도 기록.
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: OQ-1~8 전체 Answered + CONFIRM 2건 모두 Confirmed + DATA-1/2/3 모두 Collected + §8 체크리스트 7건 모두 [x] 완료(2026-05-20). confirm-discovery 게이트 통과로 본 Discovery는 `confirmed` 상태로 승급되어 REQ 초안 작성 가능. **외부 의존 항목**: R-7(prod 약한 admin token) hotfix는 본 Discovery 외부 운영 작업이라 별도 처리, dcy-003 잔여 처리 + dcy-001 REQ Approved는 batch 통합 시점 의존성으로 §3 제약사항에 명시됨.
- Ready for REQ Drafting: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-05-20T11:00+09:00
+2
View File
@@ -8,3 +8,5 @@
|---|---|---|---|---|
| dcy-001_20260519_asset-catalog-and-user-portfolio-model | 20260519 | 자산 관리 구조 재설계 — admin 관리 asset catalog와 user portfolio 분리 모델 도입 | [Discovery](./dcy-001_20260519_asset-catalog-and-user-portfolio-model.md) | confirmed |
| dcy-002_20260519_tag-ownership-and-applicability-policy | 20260519 | 태그 소유권 및 적용 대상 정책 — admin/user 구분 태그 모델과 asset·portfolio 적용 정책 도입 | [Discovery](./dcy-002_20260519_tag-ownership-and-applicability-policy.md) | confirmed |
| dcy-003_20260520_user-portfolio-mutation-v1-api-migration | 20260520 | User Portfolio Mutation v1 API 이전 및 path 명명 분리 (/v1/portfolios* 신설) | [Discovery](./dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md) | confirmed |
| dcy-004_20260520_token-issuance-and-lifecycle-overhaul | 20260520 | 토큰 발급 및 관리 전면 정비 — client token 폐지, service-token rotate 정상화, 발급 권한 매트릭스, 만료·회전·자동 revoke 정책 | [Discovery](./dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md) | confirmed |
@@ -0,0 +1,110 @@
# REQ-019: 토큰 surface baseline 문서 정합성 회복
- Status: Approved
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
## Intent
- 현행 baseline 문서가 토큰 surface 결함(rebind 명세 누락, AuthContext daemon_id 필드 누락) + 본 batch 변경 사항(client token 폐지, rotate index 보강, HTTP daemon_id forward, auth_tokens 인덱스 변경)을 모두 반영해 후속 REQ·batch가 단일 source of truth로 사용할 수 있도록 정합성을 회복한다.
- dcy-004 FR-4와 FR-10을 단일 REQ로 통합 처리한다(둘 다 baseline 갱신 작업).
## Requirement
- `docs/interface-contract.md` 갱신:
- `pfhub service-token rebind` CLI 명세 추가([app/cli/commands/service_tokens.py:119-141](app/cli/commands/service_tokens.py#L119-L141) 구현 반영)
- `POST /auth/tokens`(client token 발급) 명세 제거(REQ-016 처리 반영)
- `POST /auth/service-tokens` body `daemon_id` forward 동작 명시(REQ-018 처리 반영)
- `docs/runtime-flows.md` 갱신:
- `AuthContext` 필드 목록에 `daemon_id` 추가([docs/runtime-flows.md:76](docs/runtime-flows.md#L76) 보강)
- 토큰 surface 3종(session/service/env)으로 축소 반영
- rotate runtime_id 갱신 + daemon_id invariant 보존 흐름 명시(REQ-017 처리 반영)
- `docs/data-model.md` 갱신:
- `auth_tokens` 인덱스 변경(`idx_auth_tokens_service_name_unique`에 `AND revoked_at IS NULL` 조건 추가) 반영
- 토큰 타입 surface 정리(client 제거)
- `docs/project-structure.md` 갱신:
- 신규 마이그레이션 스크립트 또는 CLI 서브커맨드(REQ-022 자동 revoke, REQ-023 NULL daemon 마이그레이션) 추가에 따른 패키지 책임 갱신 — 필요 시
- 갱신은 baseline 정합성 회복에 한정한다. 새 정책 정의(매트릭스, TTL 정책, SOP)는 REQ-020/021/024가 별도로 다룬다.
## Acceptance Criteria
1. Given `grep -n "service-token rebind" docs/interface-contract.md`, When 본 REQ 구현 후, Then 명세 entry가 1건 이상 발견된다.
2. Given `grep -n "POST /auth/tokens" docs/interface-contract.md`, When 본 REQ 구현 후, Then client token 발급 명세가 제거되어 0건이거나 deprecated 표시로만 잔존한다.
3. Given `grep -n "AuthContext.*daemon_id\|daemon_id.*AuthContext" docs/runtime-flows.md`, When 본 REQ 구현 후, Then `AuthContext` 필드 목록에 `daemon_id`가 명시된다.
4. Given `grep -n "revoked_at IS NULL\|active service token" docs/data-model.md`, When 본 REQ 구현 후, Then `idx_auth_tokens_service_name_unique` 변경 사항이 반영되어 발견된다.
5. Given 사람 리뷰, When baseline 4종 문서를 점검, Then client token 잔재(예: `token_type='client'` 발급 경로 언급)가 모두 제거되거나 deprecated 표시되어 있다.
## Impacted Area
- Docs:
- `docs/interface-contract.md`
- `docs/runtime-flows.md`
- `docs/data-model.md`
- `docs/project-structure.md` (필요 시)
- Modules: 없음 (문서 전용)
- Tests: 없음 (문서 전용. AC-1~5는 grep + 사람 리뷰)
## Notes
- 본 REQ는 REQ-016/017/018/022/023 코드 변경 후 또는 동일 batch에서 함께 처리하는 것이 적절하다. baseline 문서가 코드 변경 사항을 반영하지 않으면 후속 REQ/batch가 잘못된 source of truth를 참조한다.
- 본 REQ는 surface 정합성 회복만 다루고 신규 정책 명세는 다음 REQ에서 처리:
- REQ-020: 토큰 발급 권한 매트릭스
- REQ-021: 토큰 만료·회전 정책
- REQ-024: env admin token 운영 SOP
- Source: dcy-004 FR-4, dcy-004 FR-10.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-4 + FR-10 통합 산출물.
#### Intent
- 토큰 surface baseline 4종 문서의 정합성 회복(rebind 명세 누락, AuthContext daemon_id 누락, client token 잔재 제거, auth_tokens 인덱스 변경 반영).
#### Acceptance Criteria Delta
- Added:
- AC-1~5 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md, docs/project-structure.md — 최초 지정
- Modules: 없음
- Tests: 없음
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-016/017/018/022/023 코드 변경 후 또는 동일 batch에서 처리
#### Validation Plan
- grep 명령 + 사람 리뷰로 AC-1~5 검증
@@ -0,0 +1,93 @@
# REQ-020: 토큰 발급 권한 매트릭스 정의
- Status: Approved
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
## Intent
- 누가 어떤 token type을 발급/회전/폐기할 수 있는가가 현재 코드에 흩어진 dependency(require_admin/require_read)로만 표현되고 baseline 문서에 없어 운영 일관성과 정책 추적이 어렵다. 토큰 발급 권한 매트릭스를 baseline에 명시해 정책 source of truth를 확보한다.
## Requirement
- `docs/interface-contract.md`에 "토큰 발급 권한 매트릭스" 섹션을 추가한다. 매트릭스 차원: token type(session / service / env) × 발급 동사(issue / rotate / rebind / revoke / list) × 발급자 권한(admin / trade-write / read).
- client token은 REQ-016에서 폐지되므로 표에서 제거한다.
- admin 자기 발급 정책 명시: admin이 본인 user_id 대상으로 daemon-bound 또는 unbound service token 발급 가능(dcy-004 OQ-4 결정). 매트릭스 주석에 "관행상 최소 권한 발급 권장" 명시.
- 매트릭스 위반 호출은 HTTP 403 fail-closed로 통일한다(dcy-004 NFR-4).
- 매트릭스 정의는 현행 코드 dependency(require_admin/require_read, AuthService 발급 메서드의 분기 로직)와 1:1 매핑되어야 한다. 불일치 시 본 REQ가 우선이며 코드 보정은 REQ-018 또는 별도 후속에서 처리.
## Acceptance Criteria
1. Given `docs/interface-contract.md` 검토, When 본 REQ 구현 후, Then "토큰 발급 권한 매트릭스" 섹션이 존재하고 token type 3종(session/service/env) × 발급 동사 5종(issue/rotate/rebind/revoke/list) × 발급자 권한 3종(admin/trade-write/read) 조합이 모두 명시된다.
2. Given 매트릭스의 admin 자기 발급 정책 항목, When 본 REQ 구현 후 검토, Then admin이 본인 user_id로 service token 발급 가능함이 명시되고 최소 권한 권장 주석이 포함된다.
3. Given 매트릭스 위반 호출 정책, When 본 REQ 구현 후 검토, Then 모든 위반 호출에 HTTP 403 fail-closed 응답이 명시된다.
4. Given 매트릭스와 현행 코드 dependency(`require_admin`/`require_read`) 매핑, When 사람 리뷰, Then 1:1 매핑이 확인되고 불일치 항목은 별도 follow-up으로 기록된다.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — "토큰 발급 권한 매트릭스" 섹션 신규 추가
- Modules: 없음 (문서 전용. 코드 dependency 불일치 발견 시 별도 REQ 또는 REQ-018 보강)
- Tests: 없음 (문서 전용. NFR-4 위반 호출 403 검증은 REQ-016/018/022 등 코드 REQ의 회귀 테스트에 포함)
## Notes
- 본 REQ는 매트릭스 baseline 문서화만 다룬다. 매트릭스 위반 호출의 코드 레벨 enforcement는 기존 dependency 체계로 충분하며 본 REQ에서는 정책 정의만 한다.
- env admin token의 회전 주기·강도 가이드는 REQ-024(env admin SOP)가 별도로 다룬다. 본 REQ는 발급 권한 매트릭스에만 집중한다.
- Source: dcy-004 FR-5, dcy-004 NFR-4, dcy-004 OQ-4 결정.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-5 산출물.
#### Intent
- 토큰 발급 권한 정책 source of truth 확보 + admin 자기 발급 허용 정책 baseline 명시.
#### Acceptance Criteria Delta
- Added:
- AC-1~4 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md — 최초 지정
- Modules: 없음
- Tests: 없음
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-019 baseline 정합 작업과 동일 batch 권장
#### Validation Plan
- 사람 리뷰로 AC-1~4 검증; 매트릭스-코드 dependency 매핑 사전 검토
@@ -0,0 +1,98 @@
# REQ-021: 토큰 만료·회전 정책 정의
- Status: Approved
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
## Intent
- 현행 토큰 만료 정책이 코드에 흩어져 있고(session은 settings.session_token_ttl_hours, service는 365일 default, `--no-expiry` 옵션) baseline 정책 명세가 없어 운영 일관성이 떨어진다. 토큰 type별 기본 TTL과 `no_expiry` 허용 범위·강제 회전 정책을 baseline에 명시한다.
- dcy-004 OQ-5 결정에 따라 현행 TTL 유지로 시작하되 정책 표면을 명시해 후속 변경 추적 기반을 확보한다.
## Requirement
- `docs/runtime-flows.md`(또는 `docs/interface-contract.md`)에 "토큰 만료·회전 정책" 섹션을 추가한다. 명시 항목:
- **type별 기본 TTL**: session = `settings.session_token_ttl_hours`(현행 값 유지), service = 365일 default, env = 만료 없음
- **no_expiry 허용 범위**: service token 한정. session/(client는 폐지됨) 발급 경로는 모두 거부
- **강제 회전 주기**: 본 정책에서는 강제 주기 두지 않음. env admin token의 권장 회전 주기는 REQ-024(env admin SOP)에서 별도 정의
- **정책 위반 발급 처리**: `no_expiry`를 session에 요청하면 HTTP 422 또는 ValueError로 거부
- 정책 명세는 현행 코드 구현과 일치해야 한다(`AuthService.login`, `AuthService.issue_service_token`의 만료 처리 로직 reference 명시).
- 본 REQ는 정책 baseline 명시에 집중하고 코드 enforcement(no_expiry session 거부 등)가 누락된 경우 follow-up REQ로 분리한다.
## Acceptance Criteria
1. Given `docs/runtime-flows.md` 검토, When 본 REQ 구현 후, Then "토큰 만료·회전 정책" 섹션이 존재하고 type별 기본 TTL이 명시된다.
2. Given 정책 검토, When `no_expiry` 허용 범위 항목을 확인, Then service 한정으로 명시되고 session/client는 거부됨이 명시된다.
3. Given 정책 검토, When 강제 회전 주기 항목을 확인, Then 본 정책에서는 두지 않으며 env admin은 REQ-024에서 별도 정의됨이 명시된다.
4. Given 정책 위반 발급 시도(예: `no_expiry`를 session에 요청), When 정책 명세 검토, Then HTTP 422 또는 ValueError 거부 응답이 명시된다.
5. Given 정책 명세와 현행 코드 매핑, When `app/services/auth_service.py`의 `login`(session 만료)·`issue_service_token`(service 만료·no_expiry) 분기와 비교, Then 정책 본문이 코드와 일치하거나 불일치 항목이 follow-up으로 기록된다.
## Impacted Area
- Docs:
- `docs/runtime-flows.md` — "토큰 만료·회전 정책" 섹션 신규 추가 (또는 `docs/interface-contract.md`에 추가 후 runtime-flows에서 참조)
- Modules: 없음 (정책 baseline 문서 정의. 코드 enforcement는 현행 그대로 유지)
- Tests: 없음 (문서 전용. AC-4 정책 위반 거부 회귀는 향후 코드 변경 시점에 추가)
## Notes
- dcy-004 OQ-5 결정으로 현행 TTL 유지가 채택됐다. 본 REQ는 정책 명세 baseline화를 통해 후속 변경 추적 기반을 확보한다.
- session 만료 시 in-flight 거래 영향 같은 정책 세부 사항은 본 REQ 범위 밖이며 release plan 또는 별도 운영 Discovery에서 다룬다.
- 강제 회전 주기(분기/반기/연간)는 env admin token에 한정해 REQ-024에서 다루며, session/service에는 강제 주기를 두지 않는다.
- Source: dcy-004 FR-6, dcy-004 OQ-5 결정.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-6 산출물.
#### Intent
- 토큰 만료 정책 source of truth 확보 + 현행 TTL 유지 정책 baseline 명시.
#### Acceptance Criteria Delta
- Added:
- AC-1~5 최초 작성
#### Impacted Area Delta
- Docs: docs/runtime-flows.md — 최초 지정
- Modules: 없음
- Tests: 없음
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-019/020과 동일 batch 권장
#### Validation Plan
- 사람 리뷰로 AC-1~5 검증
@@ -0,0 +1,107 @@
# REQ-024: env admin token 운영 SOP 정의
- Status: Approved
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
## Intent
- 현행 env admin token(`PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수)의 회전·폐기·강도 기준이 baseline에 명시되지 않아 운영 단계에서 회전이 잘못 적용되면 운영자 admin 접근이 차단될 수 있는 운영 리스크가 있다(dcy-004 R-4).
- dcy-004 OQ-6 결정에 따라 분기(3개월) 권장 회전 주기, 강제 회전 없음 정책으로 SOP를 baseline에 명시한다.
## Requirement
- `docs/runtime-flows.md` 또는 `README.md`에 "env admin token 운영 SOP" 섹션을 추가한다. 명시 항목:
- **권장 회전 주기**: 분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율로 회전 시점 결정.
- **회전 트리거 조건**: (a) 정기(분기), (b) token leak 의심, (c) 운영자 교체·퇴사, (d) 보안 사고 대응.
- **다단계 회전 절차**:
1. 강한 무작위 token 생성: `python3 -c "import secrets; print(secrets.token_urlsafe(48))"`
2. `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token을 콤마 분리로 추가(다중 token 동시 운영)
3. service rolling restart
4. 신규 token으로 admin 호출 검증(예: `GET /health`, admin 작업 sanity check)
5. 구 token을 `PORTFOLIOHUB_ADMIN_TOKENS`에서 제거
6. service rolling restart(구 token 무효화)
7. evidence 기록(회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과)
- **rollback 절차**: 회전 실패 시 환경 변수에 구 token 재추가 후 rolling restart. fallback admin 접근 경로(예: DB 직접 접근으로 token 재생성) 보장.
- **운영자 권한 매트릭스**: env admin token 추가/제거는 인프라 운영자 권한. release approver 또는 운영자 교체 시 SOP를 참조.
- **token 강도 가이드**: 최소 48-byte URL-safe 무작위. 평문 단어·예측 가능한 패턴(예: `admin-token-789`) 금지.
- SOP는 dev/test 환경과 prod 환경 모두에 적용되며, prod 환경의 약한 placeholder token이 발견된 경우(R-7) 본 SOP를 즉시 적용하도록 권고한다.
## Acceptance Criteria
1. Given `docs/runtime-flows.md` 또는 `README.md` 검토, When 본 REQ 구현 후, Then "env admin token 운영 SOP" 섹션이 존재하고 권장 회전 주기 + 강제 회전 없음 정책이 명시된다.
2. Given SOP의 다단계 회전 절차 항목, When 검토, Then 7단계(token 생성 → 추가 → restart → 검증 → 구 token 제거 → restart → evidence 기록)가 명시된다.
3. Given SOP의 rollback 절차 항목, When 검토, Then 회전 실패 시 환경 변수 복구 + fallback admin 접근 경로 명시.
4. Given SOP의 token 강도 가이드, When 검토, Then 최소 48-byte URL-safe 무작위 요구 + 약한 placeholder 금지 명시.
5. Given staging 환경에서 SOP에 따라 dry-run 회전 실행, When 7단계 절차 수행, Then 각 단계가 실제 환경에서 적용 가능함이 확인된다(release plan verification).
## Impacted Area
- Docs:
- `docs/runtime-flows.md` — "env admin token 운영 SOP" 섹션 신규 추가 (또는 `README.md`에 추가 후 runtime-flows에서 참조)
- `README.md` — daemon provisioning runbook에 env admin token SOP 링크 추가 (선택)
- Modules: 없음 (정책 baseline 문서. 코드 변경 없음. 환경 변수 자체는 운영 작업)
- Tests: 없음 (문서 전용. AC-5는 release plan verification 영역)
## Notes
- dcy-004 R-7(prod 환경 약한 admin token)은 본 SOP의 첫 번째 적용 사례로, dcy-004 confirm-discovery 시점 또는 이전에 운영자가 본 SOP의 7단계 절차를 적용해 `admin-token-789`를 강한 무작위 값으로 교체하기를 권고. hotfix 추적은 dcy-004 §10 DATA_PROD_ADMIN_TOKEN_HOTFIX 항목으로 관리.
- env admin token 다중 운영 허용은 현행 `app/core/auth.py:_resolve_permission`이 set 기반으로 처리하므로 코드 변경 없이 가능([app/core/auth.py:43-50](app/core/auth.py#L43-L50)).
- 본 SOP는 prod 환경 적용을 우선으로 작성됐으나 dev/test 환경에서도 약한 placeholder 사용을 금지한다. dev 환경 사용자는 `secrets.token_urlsafe` 또는 동등한 강도의 값을 생성해 .env 파일에 보관.
- Source: dcy-004 FR-9, dcy-004 OQ-6 결정.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-9 산출물.
#### Intent
- env admin token 운영 SOP source of truth 확보 + 분기 권장 회전 주기 baseline 명시 + dcy-004 R-7 hotfix 표준 절차 정의.
#### Acceptance Criteria Delta
- Added:
- AC-1~5 최초 작성
#### Impacted Area Delta
- Docs: docs/runtime-flows.md, README.md — 최초 지정
- Modules: 없음
- Tests: 없음
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정). dcy-004 R-7 hotfix는 본 REQ confirm-req 이전이라도 본 SOP 절차로 즉시 적용 권장.
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-019/020/021과 동일 batch 권장
#### Validation Plan
- 사람 리뷰로 AC-1~4 검증; AC-5는 staging 환경 dry-run으로 release plan 검증
@@ -0,0 +1,116 @@
# REQ-032: portfolio v1 baseline 문서 갱신
- Status: Approved
- Type: Documentation
- Priority: Medium
- Owner: Justin Ko
## Intent
- REQ-025~031의 v1 portfolio mutation 라우트 신설 + path 분리 + soft delete + 토큰 분기 정책을 baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md`)에 일괄 반영해 후속 batch/release가 단일 source of truth로 사용할 수 있도록 정합성을 회복한다.
- dcy-001 FR-5(REQ-013) Deprecated 후속 정리도 함께 반영 — `GET /v1/assets`는 catalog 전용 의미로 baseline에 복원.
## Requirement
- `docs/interface-contract.md` 갱신:
- `/v1/portfolios*` 라우트 명세 추가: POST(REQ-025), PATCH(REQ-026), DELETE(REQ-027), GET(REQ-028), `/ledger/trades`(REQ-029), `/tags`(REQ-030), `/comments`(REQ-031)
- 각 라우트의 토큰 분기 규칙(`token_type ∈ {service, client}` 거부 + `permission` 기준 분기) 일관 명시
- 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거 + 동일 batch release 정책 명시
- `GET /v1/assets`는 dcy-001 FR-2 admin catalog 조회 전용으로 의미 복원 (REQ-013 Deprecated 정합)
- daemon authorization model 갱신 (3단계 fail-closed)
- `docs/runtime-flows.md` 갱신:
- portfolio mutation flow(create + catalog 검증 + ownership 검증 + disabled 보호)
- GET /v1/portfolios 토큰 분기 조회 flow
- ledger trade path 이전 흐름 + daemon assignment 3단계 인가
- soft delete flow (status 전환 + daemon 자동 해제 + 의존 데이터 보존 + 복구 경로)
- `docs/data-model.md` 갱신:
- Portfolio 엔티티의 `status` 필드 `disabled` 의미 명시 + 의존 데이터 보존 정책
- dcy-001 catalog/portfolio 분리 모델 baseline 반영(REQ-009/011 정합 — dcy-001 REQ-015에서 처리될 수 있음, 본 REQ는 portfolio v1 측면만 보강)
- `docs/project-structure.md` 갱신:
- `app/api/v1_portfolios.py` 신규 파일 추가 + 라우터 책임 갱신
- `app/api/v1_ledger.py` 제거 또는 v1_portfolios.py에 흡수 반영
- 갱신은 v1 portfolio mutation surface 정합성 회복에 집중한다. dcy-002 tag 정책의 baseline 반영은 dcy-002 REQ-008이 별도로 다루며, 본 REQ는 portfolio tag 적용 라우트 명세만 포함한다.
## Acceptance Criteria
1. Given `grep -n "POST /v1/portfolios\|PATCH /v1/portfolios\|DELETE /v1/portfolios\|GET /v1/portfolios" docs/interface-contract.md`, When 본 REQ 구현 후, Then 각 라우트 명세 entry가 1건 이상 발견된다.
2. Given `grep -n "POST /v1/assets/{asset_id}/ledger/trades" docs/interface-contract.md`, When 본 REQ 구현 후, Then 구 path 명세가 제거되어 0건이거나 "deprecated/removed" 표시로만 잔존.
3. Given `grep -n "GET /v1/portfolios.*?market\|managed_by_daemon_id" docs/runtime-flows.md`, When 본 REQ 구현 후, Then `GET /v1/portfolios` 토큰 분기 flow + daemon-scoped 필터가 명시된다.
4. Given `grep -n "soft delete\|status='disabled'\|disabled" docs/data-model.md`, When 본 REQ 구현 후, Then Portfolio.status='disabled' 의미와 의존 데이터 보존 정책이 반영된다.
5. Given `grep -n "v1_portfolios.py" docs/project-structure.md`, When 본 REQ 구현 후, Then 신규 라우터 파일이 명시된다.
6. Given 사람 리뷰, When baseline 4종 문서를 점검, Then REQ-025~031의 v1 portfolio mutation 정책과 충돌하는 잔재가 없다.
## Impacted Area
- Docs:
- `docs/interface-contract.md`
- `docs/runtime-flows.md`
- `docs/data-model.md`
- `docs/project-structure.md`
- Modules: 없음 (문서 전용)
- Tests: 없음 (문서 전용. AC-1~6은 grep + 사람 리뷰)
## Notes
- 본 REQ는 REQ-025~031 코드 변경 후 또는 동일 batch에서 함께 처리하는 것이 적절하다. baseline 문서가 코드 변경 사항을 반영하지 않으면 후속 REQ/batch가 잘못된 source of truth를 참조한다.
- dcy-004 REQ-019(토큰 surface baseline 정합성)와 본 REQ는 baseline 갱신 대상이 부분적으로 겹치나 책임 분담:
- REQ-019: 토큰 surface (client 폐지, rebind, AuthContext daemon_id, auth_tokens 인덱스)
- 본 REQ-032: portfolio v1 surface (path 분리, soft delete, ownership/disabled, ledger path 이전)
- 두 REQ가 동일 batch에서 처리될 경우 baseline 문서 동시 갱신 시 conflict 회피를 위해 commit 순서 또는 작업자 분담 명시 필요.
- dcy-001 REQ-015(asset catalog baseline)도 baseline 갱신 대상이라 batch design 단계에서 3개 REQ(REQ-015 + REQ-019 + REQ-032)의 baseline 작업을 통합 batch로 묶거나 의존성 순서를 명시.
- Source: dcy-003 FR-10. dcy-001 REQ-013 Deprecated 후속 baseline 정리 포함.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-10 산출물.
#### Intent
- v1 portfolio mutation surface의 baseline 문서 정합성 회복 + dcy-001 REQ-013 Deprecated 후속 정리.
#### Acceptance Criteria Delta
- Added:
- AC-1~6 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md, docs/project-structure.md — 최초 지정
- Modules: 없음
- Tests: 없음
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-025~031 코드 변경 후 또는 동일 batch에서 처리. dcy-004 REQ-019, dcy-001 REQ-015와의 baseline 작업 충돌 조율 필요.
#### Validation Plan
- grep + 사람 리뷰로 AC-1~6 검증
@@ -47,7 +47,7 @@
- `tests/test_asset_catalog.py` (신규) — admin CRUD CLI/API AC 검증, non-admin 403 검증
## Notes
- `pfhub asset list`의 HTTP 대응(catalog 전용 GET)은 별도 admin 전용 엔드포인트로 구현하거나, GET /v1/assets에 `?mode=catalog` 파라미터로 분기할 수 있다. 설계 결정은 batch design 단계에서 확정한다. REQ-013(GET /v1/assets 포트폴리오 조회)과 충돌하지 않도록 주의한다.
- `pfhub asset list`의 HTTP 대응(catalog 전용 GET): dcy-003 옵션 B(path 명명 분리) 결정에 따라 REQ-013(GET /v1/assets 포트폴리오 조회)이 Deprecated(2026-05-20) 처리되어 path 충돌 우려가 해소됨. **`GET /v1/assets`는 admin catalog 조회 전용**으로 본 REQ에서 다루며, dcy-001 batch design 단계에서 본 REQ에 GET 명세를 보강(change-req)하거나 별도 신규 REQ로 분리한다. 포지션 조회 `GET /v1/portfolios`는 dcy-003의 별도 신규 REQ로 분리 예정.
- deactivate 후 해당 asset을 참조하는 기존 portfolio는 보존된다. 신규 portfolio 생성만 거부(REQ-012에서 처리).
- REQ-009 선행 필요.
- Source: dcy-001 FR-2, NFR-3
@@ -1,9 +1,10 @@
# REQ-013: GET /v1/assets 토큰 타입별 분기 재정의
- Status: Approved
- Status: Deprecated
- Type: Interface
- Priority: High
- Owner: Justin Ko
- Deprecated By: dcy-003 (path 명명 분리 옵션 B 결정), 2026-05-20
## Intent
- 현재 service token(daemon) 전용으로 구현된 `GET /v1/assets`를 user token 및 admin token도 지원하는 통합 portfolio 조회 엔드포인트로 재정의한다.
@@ -46,6 +47,44 @@
## Change Log
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Approved → **Deprecated**. dcy-003(`docs/discovery/dcy-003_20260520_user-portfolio-mutation-v1-api-migration.md`, confirmed 2026-05-20)의 옵션 B(완전 분리) 결정으로 본 REQ의 path 결정이 무효화됨.
#### Intent
- dcy-003은 `GET /v1/assets`를 admin asset catalog 전용으로 회수하고 포지션 조회는 `GET /v1/portfolios` 신설 경로로 분리하는 정책을 채택했다. 본 REQ의 통합 endpoint 정의(`GET /v1/assets`가 토큰 타입별로 catalog/포지션을 동시 처리)는 dcy-003 결정에 따라 더 이상 기준 문서가 아니므로 Deprecated로 전환한다.
- 본 REQ의 후속:
- `GET /v1/assets` (admin catalog 조회 의미) → dcy-001 batch design 단계에서 REQ-010 또는 신규 REQ로 분리 처리 (REQ-010 Notes 참조)
- `GET /v1/portfolios` (포지션 토큰 분기 조회) → dcy-003 FR-5에서 별도 신규 REQ로 작성 예정 (dcy-003 draft-req 단계)
#### Acceptance Criteria Delta
- Removed: AC-1~7 전체 (본 REQ Deprecated로 인해 검증 대상에서 제외). 후속 신규 REQ에서 새 path 기준으로 재작성 예정.
#### Impacted Area Delta
- Docs:
- docs/discovery/dcy-003 §6 OQ-5, §10 CONFIRM_DCY001_FR5_CHANGE_REQ가 본 Deprecated 처리의 근거
- docs/srs/Interface/req-010 Notes에 후속 GET /v1/assets catalog 명세 처리 계획 반영
- Modules:
- 본 REQ Approved 시점에 코드 변경이 일어나지 않은 상태였으므로 코드 회귀 없음. 후속 신규 REQ에서 `app/api/v1_portfolios.py` 신규 + `app/api/v1_assets.py` 정리.
- Tests:
- 신규 `tests/test_v1_asset_catalog.py`(REQ-013 Notes에 지정)는 작성되지 않은 상태. 후속 신규 REQ로 이관.
#### Delivery Trace Delta
- Batches: 본 REQ가 포함된 batch 미정. Deprecated 이후 batch 편입 없음.
- Releases: 본 REQ 포함 release 미정.
#### Revalidation Impact
- Reverification Needed: no (구현 미진행 상태에서 Deprecated)
- Existing Implementation Invalidated: no
- Status Recommendation: deprecated
- Follow-up Action: dcy-003 draft-req 단계에서 `GET /v1/portfolios` 신규 REQ 작성, dcy-001 batch design 단계에서 `GET /v1/assets` catalog 조회 명세 처리(REQ-010 보강 또는 신규 REQ)
#### Validation Plan
- 없음 (Deprecated 전환만)
### CHG-20260519-02
- Date: 2026-05-19
- Author: Justin Ko
@@ -0,0 +1,99 @@
# REQ-016: client token 발급 경로 코드 폐지
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- dcy-003 옵션 B 결정으로 client token이 v1 portfolio mutation에서 거부되며, dcy-004 OQ-8 결정으로 user 자동화 use case는 admin 발급 service token으로 흡수된다. client token 발급 경로 자체를 코드에서 폐지해 토큰 surface를 session/service/env 3종으로 축소하고 발급 경로 중복을 제거한다.
- DATA-1 inventory(2026-05-20) active client token 0건 확인됐으므로 즉시 코드 폐지로 진행하며 별도 revoke 스크립트나 grace period 운영 없이 release 단계에서 직접 제거한다.
## Requirement
- `POST /auth/tokens` HTTP route([app/api/auth_routes.py:100-122](app/api/auth_routes.py#L100-L122)), `app/services/auth_service.py:issue_client_token`, `app/schemas/auth.py:CreateClientTokenRequest`/`IssuedClientTokenResponse`, 관련 헬퍼·테스트를 모두 제거한다.
- HTTP 호출 시 라우트가 등록되어 있지 않아 FastAPI 기본 404 응답을 반환하도록 한다(별도 405 처리 추가하지 않음).
- 폐지 release 직전 `SELECT COUNT(*) FROM auth_tokens WHERE token_type='client' AND revoked_at IS NULL` 재확인을 release plan에 포함하며, >0이면 발급 사유 조사 후 release 보류 또는 즉시 revoke 처리한다.
- 토큰 분기에서 `token_type='client'`는 더 이상 발급될 수 없으므로 dcy-003 FR-1/3/4/5의 게이트는 자연 유효한 상태로 유지되되 게이트 자체는 제거하지 않는다(env token 침투 방어).
## Acceptance Criteria
1. Given codebase grep `grep -rn "issue_client_token\|CreateClientTokenRequest\|IssuedClientTokenResponse"`, When 본 REQ 구현 후, Then 결과는 0건이다.
2. Given `POST /auth/tokens` HTTP 호출, When 본 REQ 구현 후, Then HTTP 404(또는 FastAPI 기본 not-found 응답)가 반환된다.
3. Given 폐지 release 직전 DATA-1 재확인 쿼리, When 실행, Then `COUNT = 0` 결과가 release plan evidence로 첨부된다.
4. Given dcy-003 신규 REQ들의 token_type 게이트 회귀 테스트, When 본 REQ 구현 후 재실행, Then 모두 통과한다(client token 발급 불가에 따른 회귀 없음).
## Impacted Area
- Docs:
- `docs/interface-contract.md` — POST /auth/tokens 명세 제거 (REQ-019 baseline 정합 작업에 포함)
- Modules:
- `app/api/auth_routes.py` — POST /auth/tokens 라우트, CreateClientTokenRequest/IssuedClientTokenResponse import, `create_token`/`revoke_token` 중 client 의존 부분 제거
- `app/services/auth_service.py` — `issue_client_token`, `IssuedClientToken` 데이터 클래스 제거. 단, `list_tokens`/`revoke_token` 등이 client 토큰을 함께 다루는 부분은 token_type 분기 정리 검토
- `app/schemas/auth.py` — `CreateClientTokenRequest`, `IssuedClientTokenResponse` 제거
- Tests:
- 신규 `tests/test_auth_surface_after_client_deprecation.py` — AC-1/AC-2/AC-4 검증
- 기존 `tests/test_auth.py` 중 client token 관련 케이스 제거 또는 service token 케이스로 대체
## Notes
- DATA-1 inventory(2026-05-20) active client token 0건 확인됨. 발급 보유자에 대한 별도 통보 절차 불요.
- 토큰 surface 3종(session/service/env)으로 축소 후 user 자동화 use case는 admin 발급 service token으로 흡수(dcy-004 OQ-8 결정).
- dcy-003 OQ-5 결정의 token_type 게이트는 본 REQ 구현 후에도 코드에 유지(env token 외부 침투 방어).
- Source: dcy-004 FR-1, dcy-004 NFR-1, dcy-003 OQ-5 후속.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-1 (client token 발급 경로 코드 폐지) 산출물.
#### Intent
- 토큰 surface 3종 축소 + client 폐지 정책 코드 반영.
#### Acceptance Criteria Delta
- Added:
- AC-1~4 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md (REQ-019에서 처리)
- Modules: app/api/auth_routes.py, app/services/auth_service.py, app/schemas/auth.py — 최초 지정
- Tests: tests/test_auth_surface_after_client_deprecation.py (신규), 기존 tests/test_auth.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; release 직전 DATA-1 재확인 절차 포함
#### Validation Plan
- pytest로 AC-1~4 검증; codebase grep + HTTP 404 회귀 + dcy-003 token_type 게이트 회귀
@@ -0,0 +1,97 @@
# REQ-018: POST /auth/service-tokens body daemon_id forward 보정
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- 현행 `POST /auth/service-tokens` HTTP route([app/api/auth_routes.py:131-137](app/api/auth_routes.py#L131-L137))가 `IssueServiceTokenRequest.daemon_id` 필드를 스키마에서 노출하지만 `service.issue_service_token` 호출 시 forward하지 않아 silently drop되는 결함을 정상화한다.
- CLI `pfhub service-token issue --daemon-id`는 정상 forward하나 HTTP 경로만 daemon binding이 불가능한 상태였다. HTTP/CLI 일관성을 회복한다.
## Requirement
- `app/api/auth_routes.py:131-137`의 `service.issue_service_token(...)` 호출에 `daemon_id=payload.daemon_id`를 추가한다.
- 비존재 또는 비활성 daemon_id가 body로 전달되면 `AuthService.issue_service_token`의 기존 검증 로직(`get_daemon_by_id` None 또는 `is_active=False`)이 ValueError를 발생시키고, `_raise_auth_error`를 통해 HTTP 4xx로 변환된다.
- daemon_id 검증 실패 응답 코드는 ValueError detail에 따라 HTTP 400 또는 404로 분기된다(`_raise_auth_error` 기존 로직).
- 본 REQ는 HTTP body daemon_id 처리만 보정한다. CLI 동작, daemon registry 자체, service token 발급 정책은 변경하지 않는다.
## Acceptance Criteria
1. Given admin token, When `POST /auth/service-tokens` body `{"name": "...", "username": "...", "daemon_id": "<existing-active-daemon-id>"}` 호출, Then HTTP 201 + issued token의 `auth_token.daemon_id == <daemon-id>` 반환.
2. Given admin token, When `POST /auth/service-tokens` body `{"name": "...", "username": "...", "daemon_id": "<non-existing-daemon-id>"}` 호출, Then HTTP 4xx + `_raise_auth_error` 패턴에 따른 오류 메시지 반환.
3. Given admin token, When `POST /auth/service-tokens` body `daemon_id` 필드 미포함 또는 null, Then 기존 동작 유지(daemon binding 없는 service token 발급, HTTP 201).
4. Given AC-1과 CLI `pfhub service-token issue NAME --user U --daemon-id <daemon-id>` 실행 결과 비교, When 둘 다 발급된 토큰의 `daemon_id` 확인, Then 동일한 값이 저장된다(HTTP/CLI 동등성).
## Impacted Area
- Docs:
- `docs/interface-contract.md` — POST /auth/service-tokens body 명세에 daemon_id forward 동작 추가 (REQ-019에서 처리)
- Modules:
- `app/api/auth_routes.py:131-137` — `issue_service_token` 호출 시 `daemon_id=payload.daemon_id` 추가
- Tests:
- 갱신 `tests/test_auth.py` — AC-1~3 HTTP request 시뮬레이션 추가
- 갱신 `tests/test_daemon_registry.py` — AC-4 HTTP/CLI 동등성 회귀
## Notes
- 본 REQ는 1-line 보정에 가까운 단순 결함 수정이지만, HTTP API 사용자에게는 daemon binding 경로가 새로 작동하기 시작하는 의미 있는 변경이라 별도 REQ로 분리.
- daemon registry 자체(존재/활성 검증)는 기존 `AuthService.issue_service_token` 내부 로직이 그대로 처리하므로 본 REQ에서는 daemon 모델을 건드리지 않는다.
- HTTP body daemon_id를 누락하거나 null로 보내는 경우의 기본 동작(unbound service token 발급)은 dcy-004 OQ-7에서 "수동 매핑" 정책이 결정되었으므로 본 REQ에서 추가 제한하지 않는다. NULL daemon 토큰의 운영 정리는 REQ-023(마이그레이션 CLI)에서 처리.
- Source: dcy-004 FR-3.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-3 산출물.
#### Intent
- HTTP/CLI 토큰 발급 surface 일관성 회복 (daemon_id forward 누락 결함 정상화).
#### Acceptance Criteria Delta
- Added:
- AC-1~4 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md (REQ-019에서 처리)
- Modules: app/api/auth_routes.py — 최초 지정
- Tests: tests/test_auth.py, tests/test_daemon_registry.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입
#### Validation Plan
- pytest TestClient로 AC-1~4 검증; HTTP/CLI 동등성 회귀
@@ -0,0 +1,103 @@
# REQ-022: daemon deactivation 시 바인딩 service token 자동 revoke
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- 현재 `pfhub daemon update --deactivate`가 daemon `is_active=false`만 설정하고 바인딩된 service token에 영향이 없어 비활성 daemon의 토큰이 계속 유효한 상태로 남는 운영 공백을 해소한다.
- dcy-004 OQ-3 결정에 따라 deactivation 즉시 바인딩 service token 모두 revoke해 daemon 권한 종료를 token 단계까지 즉각 반영한다.
## Requirement
- `pfhub daemon update <daemon_id> --deactivate` 실행 시(또는 동등 service 메서드 호출 시) 동일 트랜잭션 내에서 `auth_tokens` 중 `daemon_id == <daemon_id>` AND `revoked_at IS NULL`인 모든 row를 revoke(`revoked_at = NOW()`)한다.
- 회피 옵션(예: `--keep-tokens`)은 노출하지 않는다(정책 일관성).
- graceful drain은 daemon 측 책임으로 분리한다. release plan에 "(1) daemon 프로세스 종료/drain 완료 확인 → (2) `pfhub daemon update --deactivate` 실행 → (3) 자동 revoke" 순서로 SOP를 명시한다(REQ-019 baseline 작업에 포함).
- `app/cli/commands/daemon.py:update_daemon`이 `deactivate=True`로 호출될 때 `repository` 또는 신규 service 메서드(`revoke_tokens_for_daemon(daemon_id)`)를 통해 자동 revoke를 트리거한다.
- 재활성화(`--activate`)는 자동 token 재발급이나 unrevoke를 수행하지 않는다. 운영자가 별도 발급 또는 rebind를 수행해야 한다.
- 본 REQ는 v1 API의 daemon 모델([app/domain/models.py:Daemon](app/domain/models.py))과 service token 모델 위에서 작동하며 daemon registry 자체는 변경하지 않는다.
## Acceptance Criteria
1. Given active daemon에 바인딩된 active service token 1개 이상 존재, When `pfhub daemon update <daemon_id> --deactivate --force` 실행, Then 해당 daemon에 바인딩된 모든 service token의 `revoked_at`가 NOT NULL로 갱신되고 daemon `is_active=false`로 설정된다.
2. Given AC-1 실행 후, When revoke된 token으로 v1 API 호출(예: `GET /v1/portfolios` 또는 `GET /v1/assets`), Then HTTP 401(Invalid token) 반환.
3. Given daemon에 바인딩된 token이 0개인 daemon, When `--deactivate --force` 실행, Then daemon만 비활성화되고 token revoke 동작은 no-op이며 정상 종료.
4. Given AC-1 실행 후 `--activate`로 재활성화, When 재실행, Then daemon만 활성화되고 이미 revoke된 token은 자동 복구되지 않는다(운영자가 신규 발급 필요).
5. Given deactivation 실행 중 in-flight 거래(daemon이 ledger trade 호출 중), When 자동 revoke가 즉시 적용, Then in-flight 거래는 daemon 측 graceful drain 책임이며 본 REQ는 추가 보호하지 않음(release plan SOP 참조).
## Impacted Area
- Docs:
- `docs/runtime-flows.md` — "Daemon Deactivation 자동 Revoke" flow 추가 (REQ-019 baseline 작업에 포함)
- `README.md` — daemon provisioning runbook의 deactivation SOP 갱신 (REQ-019 또는 release plan)
- Modules:
- `app/cli/commands/daemon.py:update_daemon` — `--deactivate` 분기에서 자동 revoke 호출 추가
- `app/services/auth_service.py` 또는 신규 service — `revoke_tokens_for_daemon(daemon_id)` 헬퍼 추가
- `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `revoke_tokens_by_daemon_id(daemon_id)` 메서드 신규 추가
- Tests:
- 갱신 `tests/test_daemon_registry.py` — AC-1~4 회귀 시나리오 추가
- 신규 또는 갱신 `tests/test_v1_*.py` — AC-2 revoke된 token으로 v1 호출 시 401 확인
## Notes
- graceful drain을 본 REQ 범위에 포함하지 않기로 결정(dcy-004 OQ-3). drain은 daemon 측(trex-basic 등)이 종료 신호 수신 시 in-flight 마무리 후 종료하는 형태로 구현되어야 한다. portfolio-hub 측은 즉시 revoke만 수행.
- 본 REQ는 daemon registry CRUD(REQ-009/010 후속의 daemon 모델) 위에 얹는 추가 동작이며 daemon 모델 자체에는 변경 없음.
- 재활성화 시 token 자동 복구를 하지 않는 이유: revoke된 token은 보안상 폐기 의미가 있어 자동 복구하면 권한 escalation 위험. 운영자가 신규 발급 절차를 통해 새 token을 부여하는 게 안전.
- Source: dcy-004 FR-7, dcy-004 OQ-3 결정.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-7 산출물.
#### Intent
- daemon deactivation 운영 공백 해소 — 비활성 daemon의 token이 계속 유효한 문제를 즉시 revoke 정책으로 정상화.
#### Acceptance Criteria Delta
- Added:
- AC-1~5 최초 작성
#### Impacted Area Delta
- Docs: docs/runtime-flows.md, README.md — 최초 지정 (REQ-019 baseline 작업 또는 release plan)
- Modules: app/cli/commands/daemon.py, app/services/auth_service.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py — 최초 지정
- Tests: tests/test_daemon_registry.py, tests/test_v1_*.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입
#### Validation Plan
- pytest로 AC-1~4 검증; AC-5는 정책 reference (release plan SOP 점검)
@@ -0,0 +1,103 @@
# REQ-023: NULL daemon_id legacy token 마이그레이션 CLI
- Status: Implemented
- Type: Interface
- Priority: Medium
- Owner: Justin Ko
## Intent
- `daemon_id IS NULL`인 active service token이 운영 환경에 존재하면 v1 API daemon-scoped endpoints가 fail-closed로 작동해 운영 정리가 필요하다. 자동 매핑은 권한 escalation 리스크로 채택하지 않고(dcy-004 OQ-7), 운영자 수동 매핑을 보조하는 진단 CLI를 제공한다.
- dry-run으로 inventory + 매핑 후보를 진단 출력하고, 운영자가 token별 `rebind` 또는 `revoke`를 명시적으로 실행하는 패턴을 표준화한다.
## Requirement
- 신규 CLI 서브커맨드 `pfhub service-token migrate-null-daemon` 추가:
- `--dry-run` 옵션: NULL daemon active service token inventory를 출력. 각 token에 대해 `id`, `user_id`, `name`, `runtime_id`, `created_at`, 매핑 후보 daemon(같은 `owner_user_id == user_id`인 active daemon 목록)을 표 또는 JSON 형식으로 보고.
- 매핑 후보 검증: `token.user_id == daemon.owner_user_id` 일치 여부를 명시 표시. 불일치 가능성이 있는 token은 별도 경고 표시.
- `--dry-run` 없이 실행하면 안전상 명시적 거부(예: "use --dry-run for inspection, then `pfhub service-token rebind` or `revoke` per token") 또는 no-op.
- 본 CLI는 자동 rebind/revoke를 수행하지 않는다. 실제 적용은 운영자가 token별로 `pfhub service-token rebind NAME --daemon-id ID` 또는 `pfhub service-token revoke NAME`을 명시 실행해야 한다.
- 본 CLI는 idempotent하다(재실행 안전, NULL token이 0건이어도 정상 종료).
- DATA-2 inventory(2026-05-20)에서 발견된 admin 테스트 fixture 1건은 본 batch release 시점에 운영자가 직접 `pfhub service-token revoke admin` 수동 실행으로 정리(release plan에 명시).
## Acceptance Criteria
1. Given `pfhub service-token migrate-null-daemon --dry-run` 실행, When NULL daemon active service token 1건 이상 존재, Then 각 token의 `id`, `user_id`, `name`, `runtime_id`, `created_at`, 매핑 후보 daemon 목록, user_id 일치 여부가 출력된다.
2. Given `pfhub service-token migrate-null-daemon --dry-run` 실행, When NULL daemon 활성 service token이 0건, Then "no NULL daemon tokens" 메시지 출력 후 exit code 0으로 정상 종료.
3. Given `pfhub service-token migrate-null-daemon` (--dry-run 없이) 실행, When 호출, Then 자동 변경을 거부하고(예: error message + non-zero exit) 운영자에게 `rebind`/`revoke` 수동 실행을 안내한다.
4. Given AC-1 출력 후 운영자가 `pfhub service-token rebind NAME --daemon-id ID` 실행, When 매핑이 성공적으로 적용, Then 해당 token의 `daemon_id`가 갱신되고 다시 `--dry-run` 실행 시 해당 token이 inventory에서 제외된다.
5. Given AC-1 실행 후 운영자가 `pfhub service-token revoke admin` 실행, When DATA-2 admin 테스트 fixture가 revoke, Then 다시 `--dry-run` 실행 시 해당 token이 inventory에서 제외된다.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — `pfhub service-token migrate-null-daemon` CLI 명세 추가 (REQ-019 baseline 작업)
- Modules:
- `app/cli/commands/service_tokens.py` — `migrate_null_daemon` 함수 신규 추가 (Typer 서브커맨드 등록)
- `app/services/auth_service.py` — `list_null_daemon_service_tokens()` 및 `find_candidate_daemons_for_user(user_id)` 헬퍼 추가(또는 직접 repository 조회)
- `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `list_service_tokens_with_null_daemon()` 메서드 신규 추가
- Tests:
- 신규 `tests/test_null_daemon_migration.py` — AC-1~5 시나리오. dry-run 출력 검증 + idempotency 회귀 + non-dry-run 거부 동작 검증
## Notes
- dcy-004 OQ-7 결정으로 자동 매핑/auto-create daemon은 채택하지 않음(권한 escalation 리스크). 본 CLI는 진단 도구로만 작동.
- DATA-2 inventory 1건(admin 테스트 fixture)은 본 batch release 시점에 운영자가 직접 처리. CLI 진단 + 운영자 수동 적용이라는 본 REQ 패턴의 첫 번째 적용 사례.
- 매핑 후보가 0건(같은 user_id의 active daemon 없음)인 token은 dry-run 출력에 별도 표시해 운영자가 daemon 신규 생성 후 rebind를 진행하거나 token revoke를 결정하도록 안내.
- 본 CLI는 batch 시점 1회 정리 + 향후 NULL daemon token이 발생할 때마다 사용 가능한 영구 운영 도구로 작동한다.
- Source: dcy-004 FR-8, dcy-004 NFR-5, dcy-004 OQ-7 결정.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-8 산출물.
#### Intent
- NULL daemon legacy token 마이그레이션을 운영자 수동 매핑 패턴으로 표준화 + 진단 CLI 제공.
#### Acceptance Criteria Delta
- Added:
- AC-1~5 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md (REQ-019에서 처리)
- Modules: app/cli/commands/service_tokens.py, app/services/auth_service.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py — 최초 지정
- Tests: tests/test_null_daemon_migration.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정). DATA-2 정리(admin 테스트 fixture revoke)는 release plan에 명시.
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입
#### Validation Plan
- pytest로 AC-1~5 검증; CLI smoke + idempotency 회귀 + non-dry-run 거부 동작
@@ -0,0 +1,113 @@
# REQ-025: POST /v1/portfolios 신설 (토큰 분기 + catalog 검증)
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- v1 API에 portfolio 생성 경로(`POST /v1/portfolios`)를 신설해 user/admin이 HTTP로 본인 또는 타 user의 portfolio를 직접 프로비저닝할 수 있도록 한다.
- dcy-001 FR-4의 catalog 검증(asset_id active)을 portfolio 생성 시점에 통합 적용해 dcy-001 asset catalog 모델 위에서만 portfolio가 생성되도록 강제한다.
## Requirement
- 신규 라우트 `POST /v1/portfolios` (Bearer 인증 필수).
- **토큰 분기 규칙** (dcy-003 OQ-2 + OQ-5 결정, dcy-004 정책과 정합):
- `token_type ∈ {service, client}` → HTTP 403 fail-closed (`detail="service token cannot create portfolios"` 또는 client 대응 메시지). service는 daemon-bound로 portfolio CRUD 주체가 아니며 client는 dcy-004 REQ-016에서 폐지 확정.
- 그 외(`token_type=session` 또는 env token):
- `permission='admin'` → admin 분기. body `user_id` non-null이면 해당 user의 portfolio로, null이면 인증 주체의 user_id로 생성.
- `permission ∈ {read, trade-write}` → user 분기. 인증 주체의 user_id로 생성. body `user_id` 지정은 무시 또는 HTTP 422.
- **catalog 검증** (dcy-001 FR-4 통합): `asset_id`가 `assets` 테이블에 존재하고 `status='active'`인지 검증. 미등록/inactive면 dcy-001 FR-4와 일관된 응답 코드로 거부(HTTP 422 또는 403; REQ 단계 확정은 본 REQ 또는 후속 보강).
- **body 스키마** (`PortfolioCreateRequest` 신규):
- 필수: `asset_id`, `broker`
- 선택: `name`, `cash_balance`, `asset_quantity`, `asset_price`, `profit_buffer`, `strategy_name`, `strategy_params`, `managed_by_daemon_id`, `user_id`(admin 분기 전용)
- 응답: HTTP 201 + 생성된 portfolio의 `PortfolioItem`(`portfolio_id`, `asset_id`, `owner_user_id`, ...).
- catalog 검증 위치(서비스 계층 공통 진입점 vs v1 라우터 레이어 한정)는 본 REQ 구현 시점에 결정. dcy-003 R-5 트레이드오프 reference 참조.
## Acceptance Criteria
1. Given admin token + body `{"asset_id": "<catalog-uuid>", "broker": "kiwoom", "user_id": "<target-user-uuid>"}`, When `POST /v1/portfolios`, Then HTTP 201 + 생성된 portfolio의 `owner_user_id == <target-user-uuid>` 반환.
2. Given session/env token with `permission ∈ {read, trade-write}` + body `{"asset_id": ..., "broker": ...}`, When `POST /v1/portfolios`, Then HTTP 201 + `owner_user_id == auth.user_id` 반환.
3. Given service token, When `POST /v1/portfolios`, Then HTTP 403 fail-closed + `detail="service token cannot create portfolios"`.
4. Given client token(폐지 전 운영 단계), When `POST /v1/portfolios`, Then HTTP 403 fail-closed.
5. Given body `asset_id`가 catalog에 미존재 또는 `status='inactive'`, When `POST /v1/portfolios`, Then HTTP 422 또는 403로 거부되고 portfolio row가 생성되지 않는다.
6. Given Bearer token 없음, When `POST /v1/portfolios`, Then HTTP 401.
7. Given non-admin token + body `user_id` 지정, When `POST /v1/portfolios`, Then `user_id`는 무시되어 `owner_user_id == auth.user_id`로 생성되거나 HTTP 422.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — POST /v1/portfolios 명세 추가 (REQ-032 baseline 작업)
- `docs/runtime-flows.md` — portfolio create flow + catalog 검증 단계 (REQ-032)
- Modules:
- `app/api/v1_portfolios.py` (신규) — POST /v1/portfolios 라우트, 토큰 분기 인증 헬퍼(`_require_portfolio_access`)
- `app/main.py` — v1_portfolios_router include
- `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioItem` 신규
- `app/services/portfolio_service.py:create_portfolio` — catalog 검증 통합(dcy-001 FR-4 정책)
- `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `get_asset_by_id(asset_id, only_active=True)` 헬퍼(또는 직접 query) 추가
- Tests:
- 신규 `tests/test_v1_portfolios_crud.py` — AC-1~7 검증
## Notes
- 본 REQ는 dcy-003 FR-1(생성) + FR-2(catalog 검증)을 통합. 생성 시점에 catalog 검증이 자연 적용되므로 단일 REQ로 분리.
- catalog 검증 위치: dcy-003 §5 FR-2 본문에 "REQ 작성 단계에서 결정" 위임. R-5(CLI 회귀) 완화 방안을 참조해 본 REQ 구현 시 결정.
- 토큰 분기 게이트는 dcy-004 REQ-016 client 폐지 이후 client 분기가 자연 사라지나, 폐지 release 이전·이후 모두 정합하도록 게이트 자체는 유지(env token 침투 방어).
- daemon이 portfolio를 직접 생성하지 못함(OQ-2). v1 API에서 daemon의 권한은 조회(REQ-028)와 ledger 기록(REQ-029)에 한정.
- Source: dcy-003 FR-1, FR-2, OQ-2, OQ-5, NFR-1, NFR-3, NFR-4. dcy-001 FR-4 통합.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-1 + FR-2 통합 산출물.
#### Intent
- v1 portfolio mutation의 핵심 진입점(생성) + dcy-001 catalog 검증 통합.
#### Acceptance Criteria Delta
- Added:
- AC-1~7 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032에서 처리)
- Modules: app/api/v1_portfolios.py (신규), app/main.py, app/schemas/v1.py, app/services/portfolio_service.py, app/repositories/* — 최초 지정
- Tests: tests/test_v1_portfolios_crud.py (신규) — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; dcy-001 REQ-009/011/012 선행 의존성 확인(assets 테이블 + portfolios asset_id FK)
#### Validation Plan
- pytest TestClient로 AC-1~7 검증; 토큰 타입별·permission별 시나리오 + catalog hit/miss
@@ -0,0 +1,110 @@
# REQ-026: PATCH /v1/portfolios/{portfolio_id} 신설
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- v1 API에 portfolio 부분 수정 경로(`PATCH /v1/portfolios/{portfolio_id}`)를 신설해 user/admin이 HTTP로 portfolio 속성(broker/status/name/symbol/strategy 등)을 직접 수정할 수 있도록 한다.
- 토큰 분기 + ownership 검증을 REQ-025와 동일 패턴으로 적용해 v1 portfolio CRUD 인가 일관성을 확보한다.
## Requirement
- 신규 라우트 `PATCH /v1/portfolios/{portfolio_id}` (Bearer 인증 필수).
- **토큰 분기 규칙** (REQ-025 + dcy-003 OQ-2/OQ-5 결정과 정합):
- `token_type ∈ {service, client}` → HTTP 403 fail-closed.
- 그 외(`token_type=session` 또는 env token):
- `permission='admin'` → 모든 portfolio 수정 가능.
- `permission ∈ {read, trade-write}` → 자기 소유(`owner_user_id = auth.user_id`) portfolio만 수정 가능, 불일치 시 HTTP 403.
- 비존재 portfolio는 HTTP 404.
- 수정 가능 필드는 기존 `UpdatePortfolioRequest` 범위: `broker`, `status`, `name`, `symbol`, `strategy_name`, `strategy_params`, `asset_price`, `managed_by_daemon_id` 등.
- **disabled portfolio 보호** (REQ-027 soft delete 정책 정합): `portfolios.status == 'disabled'`인 portfolio에 대해 status 전이(`'enabled'`로 복구) 외 다른 필드 변경 시 HTTP 409 또는 422로 거부.
- 응답: HTTP 200 + 갱신된 `PortfolioItem`.
## Acceptance Criteria
1. Given admin token + 본인 또는 타 user portfolio, When `PATCH /v1/portfolios/{id}` body `{"strategy_name": "..."}`, Then HTTP 200 + 갱신된 portfolio 반환.
2. Given session/env token with `permission ∈ {read, trade-write}` + 본인 portfolio, When `PATCH /v1/portfolios/{id}`, Then HTTP 200 + 갱신된 portfolio.
3. Given session/env token with `permission ∈ {read, trade-write}` + 타 user portfolio, When `PATCH /v1/portfolios/{id}`, Then HTTP 403 (ownership 불일치).
4. Given service token, When `PATCH /v1/portfolios/{id}`, Then HTTP 403 fail-closed.
5. Given client token(폐지 전), When `PATCH /v1/portfolios/{id}`, Then HTTP 403 fail-closed.
6. Given Bearer token 없음, When `PATCH /v1/portfolios/{id}`, Then HTTP 401.
7. Given 비존재 `portfolio_id`, When `PATCH /v1/portfolios/{id}`, Then HTTP 404.
8. Given `status='disabled'` portfolio + body `{"strategy_name": "..."}`(status 외 필드), When `PATCH`, Then HTTP 409 또는 422 (disabled write 차단).
9. Given `status='disabled'` portfolio + body `{"status": "enabled"}` (admin), When `PATCH`, Then HTTP 200 + status 복구.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — PATCH /v1/portfolios/{id} 명세 추가 (REQ-032)
- `docs/runtime-flows.md` — portfolio update flow + ownership 검증 + disabled 보호 (REQ-032)
- Modules:
- `app/api/v1_portfolios.py` — PATCH 라우트 추가 (REQ-025의 인증 헬퍼 재사용)
- `app/schemas/v1.py` — `PortfolioUpdateRequest` 신규 (기존 `UpdatePortfolioRequest` 재사용 또는 v1 전용 래핑)
- `app/services/portfolio_service.py:update_portfolio` — 기존 메서드 재사용 + disabled 보호 검증 추가
- Tests:
- 신규/갱신 `tests/test_v1_portfolios_crud.py` — AC-1~9 검증
## Notes
- ownership 검증은 admin/user 분기 후 service 계층 또는 라우터에서 일관 처리. 본 REQ의 검증 위치는 REQ-025 catalog 검증 위치 결정과 함께 결정한다.
- disabled portfolio 보호는 REQ-027(soft delete) 정책의 일부로, 본 REQ에서 PATCH 측면 검증을 명시. ledger trade/tag/comment 측면은 REQ-029/030/031에서 각각 처리.
- 본 REQ는 dcy-003 FR-3 산출물이며 NFR-1/NFR-3/NFR-4를 검증 기준에 흡수.
- Source: dcy-003 FR-3, OQ-2, OQ-5, NFR-1, NFR-3, NFR-4.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-3 산출물.
#### Intent
- v1 portfolio mutation의 부분 수정 진입점 + ownership 검증 + disabled 보호.
#### Acceptance Criteria Delta
- Added:
- AC-1~9 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032)
- Modules: app/api/v1_portfolios.py, app/schemas/v1.py, app/services/portfolio_service.py — 최초 지정
- Tests: tests/test_v1_portfolios_crud.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-025와 동일 batch 권장
#### Validation Plan
- pytest TestClient로 AC-1~9 검증; ownership·token type·disabled 보호 시나리오
@@ -0,0 +1,121 @@
# REQ-027: DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete)
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- v1 API에 portfolio 종료 경로(`DELETE /v1/portfolios/{portfolio_id}`)를 신설해 user/admin이 HTTP로 portfolio를 종료할 수 있도록 한다.
- dcy-003 OQ-1 결정에 따라 soft delete(`status='disabled'`)로 구현해 데이터 보존성과 운영 복구 가능성을 확보한다. hard delete 경로는 v1에 노출하지 않는다.
## Requirement
- 신규 라우트 `DELETE /v1/portfolios/{portfolio_id}` (Bearer 인증 필수).
- **토큰 분기 규칙** (REQ-025/026과 동일 패턴):
- `token_type ∈ {service, client}` → HTTP 403 fail-closed.
- 그 외(`token_type=session` 또는 env token):
- `permission='admin'` → 모든 portfolio 삭제 가능.
- `permission ∈ {read, trade-write}` → 자기 소유(`owner_user_id = auth.user_id`) portfolio만, 불일치 시 HTTP 403.
- **soft delete 동작**:
- `portfolios.status = 'disabled'`로 전환
- `managed_by_daemon_id`는 `NULL`로 자동 해제
- `updated_at` 갱신
- 의존 데이터(`portfolio_ledger`, `ledger_trades`, `portfolio_comments`, `portfolios.tag_ids`) 모두 보존
- **disabled 상태 write 차단** (REQ-026/029/030/031에서도 검증되는 정책):
- `PATCH`(status 전이 외 필드 변경) → HTTP 409 또는 422
- `POST /ledger/trades` → 차단(REQ-029에서 처리)
- tag/comment 추가·수정·삭제 → 차단(REQ-030/031에서 처리)
- **복구 경로**: admin이 `PATCH /v1/portfolios/{portfolio_id}` body `{"status": "enabled"}`(또는 이전 status)로 전환(REQ-026 AC-9). daemon 재배정은 별도 호출.
- 비존재 `portfolio_id`는 HTTP 404. ownership 불일치는 HTTP 403.
- 이미 `disabled` 상태인 portfolio에 재호출 시 HTTP 200 + 멱등 응답(또는 HTTP 409 — 본 REQ 구현 시 확정).
- 응답: HTTP 200 또는 204 + (HTTP 200 시) disabled 상태 portfolio 또는 확정 응답.
## Acceptance Criteria
1. Given session token + 본인 active portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 200/204 + `portfolios.status='disabled'` + `managed_by_daemon_id IS NULL` 사후 상태.
2. Given AC-1 실행 후, When `portfolio_ledger`/`ledger_trades`/`portfolio_comments`/`portfolios.tag_ids` 조회, Then 의존 데이터가 모두 보존되어 있다.
3. Given admin token + 타 user active portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 200/204 + 동일 사후 상태.
4. Given session token + 타 user portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 403 (ownership 불일치).
5. Given service token, When `DELETE /v1/portfolios/{id}`, Then HTTP 403 fail-closed.
6. Given client token(폐지 전), When `DELETE /v1/portfolios/{id}`, Then HTTP 403 fail-closed.
7. Given Bearer token 없음, When `DELETE /v1/portfolios/{id}`, Then HTTP 401.
8. Given 비존재 `portfolio_id`, When `DELETE /v1/portfolios/{id}`, Then HTTP 404.
9. Given disabled portfolio + admin `PATCH status='enabled'`, When `PATCH`, Then HTTP 200 + 복구. (REQ-026 AC-9와 연계)
10. Given AC-1 실행 후 disabled portfolio에 ledger trade/tag/comment write 시도, When 시도, Then 모두 차단(HTTP 409 또는 422). (REQ-029/030/031과 연계 검증)
## Impacted Area
- Docs:
- `docs/interface-contract.md` — DELETE /v1/portfolios/{id} 명세 (REQ-032)
- `docs/runtime-flows.md` — soft delete flow + disabled write 차단 + 복구 (REQ-032)
- `docs/data-model.md` — Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 (REQ-032)
- Modules:
- `app/api/v1_portfolios.py` — DELETE 라우트
- `app/services/portfolio_service.py:delete` — 기존 메서드 재사용 시 soft delete로 변경 또는 신규 `soft_delete_portfolio` 헬퍼
- `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `set_portfolio_status(portfolio_id, status, clear_daemon=True)` 메서드 신규 또는 기존 `update_portfolio` 활용
- Tests:
- 신규/갱신 `tests/test_v1_portfolios_crud.py` — AC-1~10 검증
## Notes
- dcy-003 R-2(disabled 누적 가시성·write 차단 일관성)는 본 REQ + REQ-028 `?include_disabled` opt-in 정책 + REQ-026/029/030/031의 차단 검증으로 함께 완화.
- 누적 disabled 정리(아카이브, 영구 삭제 운영 SOP)는 본 Discovery 범위 밖. 별도 운영 Discovery로 위임.
- 본 REQ는 hard delete 경로를 v1에 노출하지 않으므로 hard 삭제 운영은 DB 직접 접근 또는 별도 운영 도구로만 가능.
- Source: dcy-003 FR-4, OQ-1, OQ-2, OQ-5, NFR-1, NFR-3, NFR-4.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-4 산출물.
#### Intent
- v1 portfolio 종료 경로 + soft delete 정책 명시.
#### Acceptance Criteria Delta
- Added:
- AC-1~10 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/data-model.md (REQ-032)
- Modules: app/api/v1_portfolios.py, app/services/portfolio_service.py, app/repositories/* — 최초 지정
- Tests: tests/test_v1_portfolios_crud.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-025/026과 동일 batch 권장
#### Validation Plan
- pytest TestClient로 AC-1~10 검증; soft delete 사후 상태 + 의존 데이터 보존 + 복구 경로 + write 차단 일관성
@@ -0,0 +1,116 @@
# REQ-028: GET /v1/portfolios 신설 (토큰 분기 조회)
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- v1 API에 portfolio 조회 경로(`GET /v1/portfolios`)를 신설해 service/user/admin 세 principal이 단일 엔드포인트로 분기 조회할 수 있도록 한다.
- dcy-001 FR-5(REQ-013 Deprecated 처리)에서 회수된 `GET /v1/assets` 통합 endpoint 정의를 본 신규 path로 이전한다(dcy-003 옵션 B 결정).
## Requirement
- 신규 라우트 `GET /v1/portfolios` (Bearer 인증 필수).
- **토큰 분기 규칙** (dcy-003 OQ-5 결정):
- `token_type='service'`: daemon-bound 조회. `daemon_id is None`이면 HTTP 403 fail-closed. 그 외 `managed_by_daemon_id = auth.daemon_id` 필터 + `?market=<code>` 지원.
- `token_type='client'`: HTTP 403 fail-closed (dcy-004 REQ-016 폐지 확정 정합).
- `token_type ∈ {session, None(env)}`:
- `permission='admin'` → 기본 `owner_user_id = auth.user_id`(본인 portfolio) 반환. `?user_id=<id>` 지정 시 해당 user의 portfolio 반환.
- `permission ∈ {read, trade-write}` → `owner_user_id = auth.user_id` 필터, 본인 portfolio만 반환.
- **`?market=<code>` 필터**: 모든 토큰 타입에서 지원. `<code> = ANY(assets.markets)`인 portfolio만 반환(catalog와 join).
- **`?include_disabled=true` 옵션** (dcy-003 R-2 완화): 기본 응답은 `status='disabled'`인 portfolio 제외. opt-in 시에만 포함.
- 응답 항목 schema: 신규 `PortfolioItem` (`portfolio_id`, `asset_id`, `owner_user_id`, `broker`, `status`, `cash_balance`, ..., `tag_ids`, `managed_by_daemon_id`, ...). `asset_id`는 catalog의 `assets.asset_id`(UUID) 참조.
- 응답: HTTP 200 + `PortfolioListResponse` (`portfolios: list[PortfolioItem]`, `total: int`).
## Acceptance Criteria
1. Given service token(daemon-bound, `daemon_id` 있음), When `GET /v1/portfolios`, Then `managed_by_daemon_id = auth.daemon_id`인 portfolio 목록만 반환.
2. Given service token(`daemon_id` 없음), When `GET /v1/portfolios`, Then HTTP 403 fail-closed.
3. Given session/env token + `permission ∈ {read, trade-write}`, When `GET /v1/portfolios`, Then `owner_user_id = auth.user_id`인 portfolio 목록만 반환.
4. Given admin token (no `?user_id`), When `GET /v1/portfolios`, Then admin 본인의 portfolio 목록 반환.
5. Given admin token + `?user_id=<target>`, When `GET /v1/portfolios`, Then `<target>` user의 portfolio 목록 반환.
6. Given 임의 토큰 + `?market=KRX`, When `GET /v1/portfolios?market=KRX`, Then `'KRX' = ANY(assets.markets)`인 portfolio만 반환.
7. Given disabled portfolio 1건 + active portfolio 1건 보유 user, When `GET /v1/portfolios` (`?include_disabled` 없음), Then active 1건만 반환.
8. Given AC-7 환경 + `?include_disabled=true`, When `GET /v1/portfolios?include_disabled=true`, Then 2건 모두 반환.
9. Given client token, When `GET /v1/portfolios`, Then HTTP 403 fail-closed.
10. Given Bearer token 없음, When `GET /v1/portfolios`, Then HTTP 401.
11. Given 응답 portfolio 항목, When `asset_id` 필드 확인, Then `assets.asset_id`(catalog UUID)를 참조한다.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — GET /v1/portfolios 명세 + `?market`/`?user_id`/`?include_disabled` 파라미터 (REQ-032)
- `docs/runtime-flows.md` — 토큰 분기 조회 flow + daemon-scoped 필터 (REQ-032)
- Modules:
- `app/api/v1_portfolios.py` — GET 라우트 (REQ-025의 인증 헬퍼 재사용 + GET 전용 service 토큰 분기 추가)
- `app/api/v1_assets.py` — 기존 `list_assets` 함수에서 portfolio 조회 로직 제거(catalog 전용 회수). REQ-013 Deprecated 후속 정리.
- `app/schemas/v1.py` — `PortfolioItem`, `PortfolioListResponse` 신규
- `app/services/portfolio_service.py:list_portfolios` — token-type별 분기 + `?market`/`?include_disabled` 처리. dcy-001 REQ-013 Deprecated 후속 작업 흡수.
- `app/repositories/postgres.py:list_portfolios_with_strategies` — `managed_by_daemon_id` 필터 + `markets` array 필터 + `include_disabled` 분기
- Tests:
- 신규 `tests/test_v1_portfolios_list.py` — AC-1~11 검증
## Notes
- 본 REQ는 dcy-001 REQ-013 Deprecated(CHG-20260520-01)의 후속 신규 REQ. 토큰 분기 정책은 REQ-013의 본문을 path만 변경해 흡수.
- `GET /v1/assets`(catalog 조회 의미)는 본 REQ 범위 밖이며 dcy-001 batch design 단계에서 REQ-010 보강 또는 신규 REQ로 처리.
- service token이 NULL `daemon_id`로 호출하면 fail-closed. NULL daemon legacy token 운영 정리는 dcy-004 REQ-023(`migrate-null-daemon` CLI)로 처리.
- `?include_disabled` 기본 false 정책은 R-2 완화의 핵심. REQ-027 soft delete와 정합.
- Source: dcy-003 FR-5, OQ-5, NFR-1, NFR-3, R-2 완화. dcy-001 REQ-013 후속.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-5 산출물 + REQ-013 Deprecated 후속.
#### Intent
- v1 portfolio 조회 신설 + 토큰 분기 정책 path 이전 + R-2 disabled 가시성 완화.
#### Acceptance Criteria Delta
- Added:
- AC-1~11 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032)
- Modules: app/api/v1_portfolios.py, app/api/v1_assets.py(catalog 회수), app/schemas/v1.py, app/services/portfolio_service.py, app/repositories/postgres.py — 최초 지정
- Tests: tests/test_v1_portfolios_list.py (신규) — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; dcy-001 REQ-013 Deprecated 정리 후속
#### Validation Plan
- pytest TestClient로 AC-1~11 검증; 토큰 분기 + `?market` + `?user_id` + `?include_disabled` 시나리오
@@ -0,0 +1,112 @@
# REQ-029: POST /v1/portfolios/{portfolio_id}/ledger/trades 이전
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- 기존 `POST /v1/assets/{asset_id}/ledger/trades`(`app/api/v1_ledger.py`)의 path param 이름이 `asset_id`이나 실제 값은 `portfolios.portfolio_id`(UUID)로 의미 혼용되는 결함을 해소한다.
- dcy-003 옵션 B(path 명명 분리)에 따라 신 path `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전하고 구 path는 동일 release에서 제거한다. 동작·인가·idempotency는 그대로 보존.
## Requirement
- 신규 라우트 `POST /v1/portfolios/{portfolio_id}/ledger/trades` (Bearer 인증 필수).
- **동작은 기존 `POST /v1/assets/{asset_id}/ledger/trades`와 동일하게 보존**:
- **daemon assignment 3단계 fail-closed 인가**: (1) token `daemon_id IS NULL` → HTTP 403, (2) portfolio `managed_by_daemon_id IS NULL`(미배정 asset) → HTTP 403, (3) `portfolio.managed_by_daemon_id != auth.daemon_id`(불일치) → HTTP 403.
- **idempotency**: `(portfolio_id, order_id, execution_id)` unique constraint (기존 `uq_ledger_trades_idempotency` 재사용).
- **duplicate/conflict 응답**: 동일 키 + 동일 payload → HTTP 200 + `status="duplicate"`. 동일 키 + 다른 payload → HTTP 409.
- **새 ledger row** → HTTP 201 + `status="new"`.
- **disabled portfolio 차단** (REQ-027 정합): `portfolios.status='disabled'`인 portfolio에 대해서는 HTTP 409 또는 422로 차단.
- 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거: 동일 batch release에서 라우트 삭제. 호출 시 HTTP 404.
- **trex-basic 동시 배포 필수** (dcy-003 OQ-3 + NFR-2): 신 path 단일 서빙. 한시적 별칭/redirect 없음. release plan에 배포 순서 잠금(trex-basic 클라이언트 사전 빌드 → portfolio-hub 배포 → trex-basic 배포) + rollback 시 trex-basic 동반 rollback 전제 명시.
## Acceptance Criteria
1. Given service token + 본인 daemon에 배정된 active portfolio + 신규 trade payload, When `POST /v1/portfolios/{portfolio_id}/ledger/trades`, Then HTTP 201 + `status="new"` + 생성된 ledger row.
2. Given AC-1 실행 후 동일 key + 동일 payload 재호출, When 호출, Then HTTP 200 + `status="duplicate"` + 단일 ledger row 유지.
3. Given AC-1 실행 후 동일 key + 다른 payload 호출, When 호출, Then HTTP 409 + ledger row 변경 없음.
4. Given service token `daemon_id IS NULL`, When `POST /v1/portfolios/{portfolio_id}/ledger/trades`, Then HTTP 403 fail-closed.
5. Given service token + `portfolio.managed_by_daemon_id IS NULL`(미배정 asset), When 호출, Then HTTP 403 fail-closed.
6. Given service token + `portfolio.managed_by_daemon_id != auth.daemon_id`(불일치), When 호출, Then HTTP 403 fail-closed.
7. Given service token + `status='disabled'` portfolio, When 호출, Then HTTP 409 또는 422 (disabled write 차단).
8. Given 비존재 `portfolio_id`, When 호출, Then HTTP 404.
9. Given 구 path `POST /v1/assets/{asset_id}/ledger/trades` 호출, When 본 REQ 구현 후 동일 batch release, Then HTTP 404.
10. Given Bearer token 없음, When 호출, Then HTTP 401.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — POST /v1/portfolios/{id}/ledger/trades 명세 + 구 path 제거 (REQ-032)
- `docs/runtime-flows.md` — daemon assignment 3단계 인가 + idempotency + path 이전 흐름 (REQ-032)
- Modules:
- `app/api/v1_portfolios.py` — `POST /v1/portfolios/{portfolio_id}/ledger/trades` 라우트 추가 (또는 별도 `v1_ledger.py`를 신 path로 이전)
- `app/api/v1_ledger.py` — 구 path 제거 또는 신 path로 라우트 prefix 이전
- `app/main.py` — 라우터 include 갱신 (구 ledger 라우터 제거)
- `app/services/portfolio_service.py` 또는 `app/services/ledger_service.py` — 기존 ledger trade 로직 재사용 + disabled 차단 검증 추가
- Tests:
- 갱신 `tests/test_v1.py` — 구 path 케이스를 신 path로 갱신, 구 path 호출 시 HTTP 404 확인
- 신규 `tests/test_v1_portfolios_ledger.py` — AC-1~10 검증
## Notes
- 본 REQ는 기능 추가가 아닌 **path 이전**이라 기존 동작·테스트 시나리오를 그대로 재사용한다. 다만 disabled portfolio 차단(AC-7)은 REQ-027 soft delete 정책 정합 검증으로 신규.
- dcy-003 NFR-2(trex-basic 동시 배포)는 본 REQ의 가장 큰 운영 리스크(R-1)이며, release plan에 배포 순서 잠금·rollback 전제를 명시해야 함. release plan은 batch design/release 단계 작업.
- ledger_trades 테이블의 `asset_id` 컬럼명은 본 REQ에서 변경하지 않는다(dcy-001 `assets.asset_id`와 의미 혼용이 잔존하나, ledger_trades 컬럼은 portfolio_id를 가리킨다는 점이 path 이름 정합화로 충분히 명확해짐). 컬럼명 변경은 별도 migration REQ로 분리 가능.
- Source: dcy-003 FR-6, OQ-3, NFR-1, NFR-2, R-1 완화.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-6 산출물.
#### Intent
- ledger trade path 명명 정합화 + 동시 배포 정책 명시 + disabled 차단 추가.
#### Acceptance Criteria Delta
- Added:
- AC-1~10 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md (REQ-032)
- Modules: app/api/v1_portfolios.py, app/api/v1_ledger.py(제거 또는 이전), app/main.py, app/services/* — 최초 지정
- Tests: tests/test_v1.py(갱신), tests/test_v1_portfolios_ledger.py(신규) — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정). trex-basic 동시 배포 release plan은 batch design 단계에서 작성.
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; trex-basic 측 release readiness(DATA_TREX_BASIC_RELEASE_WINDOW) 외부 확인 필요
#### Validation Plan
- pytest TestClient로 AC-1~10 검증; 신·구 path 회귀 + daemon 인가 3단계 + idempotency·conflict + disabled 차단
@@ -0,0 +1,116 @@
# REQ-030: portfolio tag 적용/해제/조회 v1 라우트
- Status: Approved
- Type: Interface
- Priority: Medium
- Owner: Justin Ko
## Intent
- v1 API에 portfolio tag 적용·해제·조회 경로(`/v1/portfolios/{portfolio_id}/tags`)를 신설해 user/admin이 HTTP로 portfolio에 태그를 적용·관리할 수 있도록 한다.
- dcy-002 FR-3 portfolios.tag_ids 정책(`tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`)을 v1 라우트에 일관 적용해 태그 적용 정책 위반을 fail-closed로 거부한다.
## Requirement
- 신규 라우트 (Bearer 인증 필수):
- `POST /v1/portfolios/{portfolio_id}/tags` — body: `{"tag_id": "<uuid>"}`. 응답 HTTP 201 + 적용된 tag 정보.
- `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` — 응답 HTTP 200 또는 204.
- `GET /v1/portfolios/{portfolio_id}/tags` — 응답 HTTP 200 + 해당 portfolio에 적용된 tag 목록.
- **ownership 규칙** (REQ-026과 동일):
- `token_type ∈ {service, client}` → HTTP 403 fail-closed.
- `permission='admin'` → 모든 portfolio.
- `permission ∈ {read, trade-write}` → 자기 소유 portfolio만, 불일치 시 HTTP 403.
- **dcy-002 FR-3 태그 적용 정책 검증** (POST 시): `tags.is_active=TRUE`이고 (`tags.is_admin_tag=TRUE` 또는 `tags.created_by_user_id = portfolio.owner_user_id`)인 경우에만 적용 허용. 정책 위반 시 HTTP 403.
- **disabled portfolio 차단** (REQ-027 정합): `status='disabled'`인 portfolio에 대해 POST/DELETE는 HTTP 409 또는 422로 거부. GET은 허용(`?include_disabled=true` 별도 제어 불필요 — GET tag는 항상 조회 가능).
- 비존재 `portfolio_id` 또는 `tag_id`는 HTTP 404.
- tag 적용 후 portfolio의 `tag_ids` UUID 배열에 추가(dcy-002 portfolios.tag_ids 스키마 반영). 중복 적용은 HTTP 409 또는 멱등 200 (REQ 구현 시 확정).
## Acceptance Criteria
1. Given session token + 본인 active portfolio + admin이 생성한 active tag, When `POST /v1/portfolios/{id}/tags` body `{"tag_id": ...}`, Then HTTP 201 + portfolio.tag_ids 배열에 tag_id 추가.
2. Given session token + 본인 portfolio + 본인이 생성한 active tag, When `POST`, Then HTTP 201.
3. Given session token + 본인 portfolio + 타 user 생성한 non-admin tag, When `POST`, Then HTTP 403 (dcy-002 FR-3 정책 위반).
4. Given session token + 본인 portfolio + `tags.is_active=FALSE` tag, When `POST`, Then HTTP 403 (비활성 tag 적용 불가).
5. Given admin token + 타 user portfolio + 임의 active tag, When `POST`, Then dcy-002 정책 그대로 적용(admin tag 또는 portfolio.owner 생성 tag만 허용). 위반 시 HTTP 403.
6. Given session token + 타 user portfolio, When `POST`, Then HTTP 403 (ownership 불일치).
7. Given service/client token, When `POST`, Then HTTP 403 fail-closed.
8. Given session token + disabled portfolio, When `POST` 또는 `DELETE`, Then HTTP 409 또는 422.
9. Given session token + 본인 portfolio + 적용된 tag, When `DELETE /v1/portfolios/{id}/tags/{tag_id}`, Then HTTP 200/204 + portfolio.tag_ids 배열에서 제거.
10. Given session token + 본인 portfolio, When `GET /v1/portfolios/{id}/tags`, Then HTTP 200 + 해당 portfolio에 적용된 tag 목록 반환.
11. Given 비존재 `portfolio_id` 또는 `tag_id`, When 호출, Then HTTP 404.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — `/v1/portfolios/{id}/tags` 명세 (REQ-032)
- Modules:
- `app/api/v1_portfolios.py` — tag 서브 라우트 추가
- `app/schemas/v1.py` — `PortfolioTagApplyRequest`, `PortfolioTagResponse` 신규
- `app/services/portfolio_service.py:add_tag`/`remove_tag`/`list_tags` — dcy-002 FR-3 정책 검증 추가
- `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `apply_tag(portfolio_id, tag_id)`, `remove_tag`, `list_tags` 메서드 (dcy-002 REQ-003 portfolios.tag_ids 배열 처리와 정합)
- Tests:
- 신규 `tests/test_v1_portfolios_tags_comments.py` — AC-1~11 (tag 부분)
## Notes
- dcy-002 REQ-001(tags 마스터)·REQ-003(portfolios.tag_ids) 선행 필요. dcy-002 REQ가 모두 Approved 상태이므로 batch design 단계에서 의존성 순서 명시.
- 본 REQ의 tag 적용 정책 검증은 dcy-002 FR-3 본문을 그대로 채택. dcy-002 REQ-005(태그 적용·해제 HTTP API)와 본 REQ의 범위 경계는 다음과 같이 분리:
- dcy-002 REQ-005: `/v1/portfolios/{id}/tags` 적용 정책 정의 + 일반 portfolio 측면
- 본 REQ: v1 portfolio mutation surface 내에서의 라우트·ownership·disabled 통합 검증
- 본 REQ가 dcy-002 REQ-005를 흡수하거나 보강하는 형태로 batch design 단계에서 결정.
- Source: dcy-003 FR-7, dcy-002 FR-3.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-7 산출물.
#### Intent
- v1 portfolio tag 적용/해제/조회 라우트 신설 + dcy-002 tag 정책 v1 일관 적용.
#### Acceptance Criteria Delta
- Added:
- AC-1~11 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md (REQ-032)
- Modules: app/api/v1_portfolios.py, app/schemas/v1.py, app/services/portfolio_service.py, app/repositories/* — 최초 지정
- Tests: tests/test_v1_portfolios_tags_comments.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; dcy-002 REQ-001/003/005 선행 의존성 + 본 REQ와의 범위 경계 확정
#### Validation Plan
- pytest TestClient로 AC-1~11 검증; ownership + dcy-002 tag 정책 위반 + disabled 차단 시나리오
@@ -0,0 +1,113 @@
# REQ-031: portfolio comment CRUD v1 라우트
- Status: Approved
- Type: Interface
- Priority: Medium
- Owner: Justin Ko
## Intent
- v1 API에 portfolio comment CRUD 경로(`/v1/portfolios/{portfolio_id}/comments`)를 신설해 user/admin이 HTTP로 portfolio에 코멘트를 작성·관리할 수 있도록 한다.
- ownership 규칙과 disabled 차단을 REQ-026/027과 동일 패턴으로 적용해 v1 portfolio surface 인가 일관성을 확보한다.
## Requirement
- 신규 라우트 (Bearer 인증 필수):
- `POST /v1/portfolios/{portfolio_id}/comments` — body: `{"content": "<text>"}`. 응답 HTTP 201 + 생성된 comment.
- `GET /v1/portfolios/{portfolio_id}/comments` — 응답 HTTP 200 + comment 목록(created_at desc 순).
- `PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}` — body: `{"content": "<updated>"}`. 응답 HTTP 200 + 갱신된 comment.
- `DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}` — 응답 HTTP 200 또는 204.
- **ownership 규칙** (REQ-026/030과 동일):
- `token_type ∈ {service, client}` → HTTP 403 fail-closed.
- `permission='admin'` → 모든 portfolio.
- `permission ∈ {read, trade-write}` → 자기 소유 portfolio만, 불일치 시 HTTP 403.
- **disabled portfolio 차단** (REQ-027 정합): `status='disabled'`인 portfolio에 대해 POST/PATCH/DELETE는 HTTP 409 또는 422로 거부. GET은 허용.
- 비존재 `portfolio_id` 또는 `comment_id`는 HTTP 404.
- 기존 `CommentCreateRequest`/`CommentUpdateRequest`/`CommentResponse` schema 재사용 또는 v1 전용 래핑(`PortfolioCommentRequest`, `PortfolioCommentResponse`).
- 기존 service 메서드(`create_comment`, `list_comments`, `update_comment`, `delete_comment`) 재사용.
## Acceptance Criteria
1. Given session token + 본인 active portfolio, When `POST /v1/portfolios/{id}/comments` body `{"content": "..."}`, Then HTTP 201 + 생성된 comment.
2. Given session token + 본인 portfolio + 적용된 comments, When `GET /v1/portfolios/{id}/comments`, Then HTTP 200 + 코멘트 목록(created_at desc).
3. Given session token + 본인 portfolio + 본인 comment, When `PATCH /v1/portfolios/{id}/comments/{comment_id}` body `{"content": "..."}`, Then HTTP 200 + 갱신된 comment.
4. Given session token + 본인 portfolio + 본인 comment, When `DELETE /v1/portfolios/{id}/comments/{comment_id}`, Then HTTP 200/204.
5. Given session token + 타 user portfolio, When 임의 동사 호출, Then HTTP 403 (ownership 불일치).
6. Given service/client token, When 임의 동사 호출, Then HTTP 403 fail-closed.
7. Given session token + disabled portfolio, When `POST`/`PATCH`/`DELETE`, Then HTTP 409 또는 422.
8. Given session token + disabled portfolio, When `GET /v1/portfolios/{id}/comments`, Then HTTP 200 + 기존 comments 목록 반환(조회는 허용).
9. Given admin token + 타 user portfolio, When 임의 동사 호출, Then HTTP 200/201/204 (admin은 모든 portfolio 허용).
10. Given Bearer token 없음, When 호출, Then HTTP 401.
11. Given 비존재 `portfolio_id` 또는 `comment_id`, When 호출, Then HTTP 404.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — `/v1/portfolios/{id}/comments` 명세 (REQ-032)
- Modules:
- `app/api/v1_portfolios.py` — comment 서브 라우트 추가
- `app/schemas/v1.py` — comment 관련 스키마 (기존 `app/schemas/portfolio.py:CommentCreateRequest`/`CommentUpdateRequest`/`CommentResponse` 재사용 또는 v1 래핑)
- `app/services/portfolio_service.py:create_comment`/`list_comments`/`update_comment`/`delete_comment` — 기존 메서드 재사용 + disabled 차단 검증 추가
- Tests:
- 신규/갱신 `tests/test_v1_portfolios_tags_comments.py` — AC-1~11 (comment 부분)
## Notes
- comment 작성자 ownership(본인 comment만 수정/삭제 가능)은 기존 portfolio_comments 테이블 구조에 따라 처리. dcy-003 본문은 portfolio ownership만 명시하고 comment 작성자 ownership은 별도 결정 사항 — 본 REQ에서는 portfolio ownership을 일관 적용하고 comment 작성자 ownership은 batch design 단계에서 확정.
- 본 REQ는 dcy-003 FR-8 산출물. tag(REQ-030)와 별도 REQ로 분리해 동사 surface를 명확히 함.
- Source: dcy-003 FR-8, NFR-1, NFR-3, NFR-4.
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-003 confirm-discovery 후 draft-req 실행. dcy-003 FR-8 산출물.
#### Intent
- v1 portfolio comment CRUD 라우트 신설 + ownership/disabled 검증 일관 적용.
#### Acceptance Criteria Delta
- Added:
- AC-1~11 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md (REQ-032)
- Modules: app/api/v1_portfolios.py, app/schemas/v1.py, app/services/portfolio_service.py — 최초 지정
- Tests: tests/test_v1_portfolios_tags_comments.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; comment 작성자 ownership 정책은 batch design 단계에서 확정
#### Validation Plan
- pytest TestClient로 AC-1~11 검증; ownership + disabled 차단 + admin 분기 시나리오
@@ -0,0 +1,100 @@
# REQ-017: service-token rotate unique index 보강
- Status: Approved
- Type: Migration
- Priority: High
- Owner: Justin Ko
## Intent
- 현행 `idx_auth_tokens_service_name_unique` partial index가 `WHERE token_type='service'` 조건만 적용해 revoked row와 새 row가 같은 name으로 unique 충돌을 일으켜 `pfhub service-token rotate`가 실 DB에서 insert 실패하는 결함을 정상화한다(REL-006 verification 실패 기록).
- index 조건에 `AND revoked_at IS NULL`을 추가해 active service token name만 unique 유지하도록 보강한다.
## Requirement
- Alembic migration 신규 작성: 기존 `idx_auth_tokens_service_name_unique`를 drop하고 `(name)` partial index를 `WHERE token_type='service' AND revoked_at IS NULL` 조건으로 재생성한다.
- migration은 `CREATE INDEX CONCURRENTLY` 적용으로 운영 lock 영향을 최소화한다(autocommit 외부 트랜잭션 필요 — Alembic op.execute로 직접 SQL 실행).
- `app/repositories/postgres.py:161-167` `ensure_schema()`의 `idx_auth_tokens_service_name_unique` 정의도 동일 조건으로 갱신한다.
- 기존 active service token 행 데이터에는 변경 없음. 데이터 backfill 불요.
- rotate 동작은 기존 코드(`app/services/auth_service.py:rotate_service_token`, `app/repositories/postgres.py:1502-1549`)를 변경하지 않으며, index 보강만으로 정상 작동해야 한다.
## Acceptance Criteria
1. Given 신규 Alembic revision 적용 후, When `pfhub service-token issue NAME --user U --no-expiry` → `pfhub service-token rotate NAME --force` 시퀀스 실행, Then 두 명령 모두 unique 위반 없이 성공한다(실 DB `docker-compose.local-test.yml` 환경).
2. Given AC-1 시퀀스 실행 후, When `SELECT name, token_type, revoked_at FROM auth_tokens WHERE name = '<NAME>'`, Then revoked row 1건 + active row 1건이 존재한다.
3. Given AC-1 rotate 결과, When 새 token의 `runtime_id`와 `daemon_id`를 확인, Then runtime_id는 갱신, daemon_id는 유지(rotate invariant 보존).
4. Given `ensure_schema()` 단독 실행, When 새 partial index 조건이 적용, Then 동일 조건(`WHERE token_type='service' AND revoked_at IS NULL`)이 PostgreSQL에 반영된다.
5. Given migration이 운영 환경에서 적용, When CREATE INDEX CONCURRENTLY가 실행, Then auth_tokens write 작업이 long-running lock 없이 진행된다(staging 환경 사전 검증).
## Impacted Area
- Docs:
- `docs/data-model.md` — auth_tokens 인덱스 변경 반영 (REQ-019에서 처리)
- Modules:
- `migrations/versions/` — 신규 Alembic revision (예: `20260520_NNNN_service_token_unique_index_fix.py`). `op.execute("DROP INDEX IF EXISTS ...")` + `op.execute("CREATE UNIQUE INDEX CONCURRENTLY ... WHERE token_type='service' AND revoked_at IS NULL")` 패턴
- `app/repositories/postgres.py:161-167` — `ensure_schema()`의 idx 정의 조건 갱신
- Tests:
- 신규 `tests/test_service_token_rotate_integration.py` (실 DB 의존 마킹) — AC-1~3 검증
- 기존 `tests/test_daemon_registry.py::test_rotate_*` — 신규 index 조건에서 통과 확인
## Notes
- 이 결함은 REL-006 verification에 "rotate insert 막힘" 검증 실패로 이미 기록됨([temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md:74](temp/20260519_old_sdlc/releases/rel-006_20260513_daemon-model-implementation.md#L74)).
- CREATE INDEX CONCURRENTLY는 transactional block 밖에서 실행 필요. Alembic revision에서 `autocommit_block`을 사용하거나 `op.execute()`로 직접 SQL 실행해야 한다.
- 기존 운영 환경에 revoked service token이 있다면 새 partial index 생성 시점에 자동 적용되며, 별도 데이터 정리 불요.
- Source: dcy-004 FR-2, dcy-004 NFR-2/NFR-3/NFR-6, dcy-004 OQ-2 결정(partial 조건 추가).
## Change Log
### CHG-20260520-02
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- Status: Proposed → Approved
#### Intent
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료, blocker 없음
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-approved
- Follow-up Action: batch 계획 편입
### CHG-20260520-01
- Date: 2026-05-20
- Author: Justin Ko
#### Change Summary
- 최초 생성 — dcy-004 confirm-discovery 후 draft-req 실행. dcy-004 FR-2 (rotate unique index 보강) 산출물.
#### Intent
- rotate 실 DB 실패 결함 해소 + R-2(migration lock) 완화 적용.
#### Acceptance Criteria Delta
- Added:
- AC-1~5 최초 작성
#### Impacted Area Delta
- Docs: docs/data-model.md (REQ-019에서 처리)
- Modules: migrations/versions/, app/repositories/postgres.py — 최초 지정
- Tests: tests/test_service_token_rotate_integration.py (신규), tests/test_daemon_registry.py — 최초 지정
#### Delivery Trace Delta
- Batches: none (미정)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 계획 편입; staging 환경에서 CONCURRENTLY migration dry-run으로 lock 시간 측정
#### Validation Plan
- 실 DB(`docker-compose.local-test.yml`) 환경에서 AC-1~3 시퀀스 검증; staging 환경에서 AC-5 lock 측정
+23 -2
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-016`
- Current: `REQ-033`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -93,12 +93,33 @@
| REQ-010 | admin asset catalog CRUD | Interface | Approved | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) |
| REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Approved | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) |
| REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Approved | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) |
| REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Approved | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) |
| REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Deprecated | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) |
| REQ-014 | portfolios 데이터 migration | Migration | Approved | High | Justin Ko | [req-014](./Migration/req-014_portfolios-data-migration.md) |
| REQ-015 | asset catalog 도입 baseline 문서 갱신 | Documentation | Approved | Low | Justin Ko | [req-015](./Documentation/req-015_asset-catalog-baseline-docs-update.md) |
| REQ-016 | client token 발급 경로 코드 폐지 | Interface | Approved | High | Justin Ko | [req-016](./Interface/req-016_client-token-issuance-deprecation.md) |
| REQ-017 | service-token rotate unique index 보강 | Migration | Approved | High | Justin Ko | [req-017](./Migration/req-017_service-token-rotate-unique-index-fix.md) |
| REQ-018 | POST /auth/service-tokens body daemon_id forward 보정 | Interface | Approved | High | Justin Ko | [req-018](./Interface/req-018_service-token-http-daemon-id-forward.md) |
| REQ-019 | 토큰 surface baseline 문서 정합성 회복 | Documentation | Approved | Medium | Justin Ko | [req-019](./Documentation/req-019_token-surface-baseline-docs-consistency.md) |
| REQ-020 | 토큰 발급 권한 매트릭스 정의 | Documentation | Approved | Medium | Justin Ko | [req-020](./Documentation/req-020_token-issuance-authorization-matrix.md) |
| REQ-021 | 토큰 만료·회전 정책 정의 | Documentation | Approved | Medium | Justin Ko | [req-021](./Documentation/req-021_token-expiry-and-rotation-policy.md) |
| REQ-022 | daemon deactivation 시 바인딩 service token 자동 revoke | Interface | Implemented | High | Justin Ko | [req-022](./Interface/req-022_daemon-deactivation-auto-revoke.md) |
| REQ-023 | NULL daemon_id legacy token 마이그레이션 CLI | Interface | Implemented | Medium | Justin Ko | [req-023](./Interface/req-023_null-daemon-token-migration-cli.md) |
| REQ-024 | env admin token 운영 SOP 정의 | Documentation | Approved | Medium | Justin Ko | [req-024](./Documentation/req-024_env-admin-token-rotation-sop.md) |
| REQ-025 | POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) | Interface | Approved | High | Justin Ko | [req-025](./Interface/req-025_v1-portfolios-create.md) |
| REQ-026 | PATCH /v1/portfolios/{portfolio_id} 신설 | Interface | Approved | High | Justin Ko | [req-026](./Interface/req-026_v1-portfolios-update.md) |
| REQ-027 | DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) | Interface | Approved | High | Justin Ko | [req-027](./Interface/req-027_v1-portfolios-soft-delete.md) |
| REQ-028 | GET /v1/portfolios 신설 (토큰 분기 조회) | Interface | Approved | High | Justin Ko | [req-028](./Interface/req-028_v1-portfolios-list.md) |
| REQ-029 | POST /v1/portfolios/{portfolio_id}/ledger/trades 이전 | Interface | Approved | High | Justin Ko | [req-029](./Interface/req-029_v1-portfolios-ledger-trades-migration.md) |
| REQ-030 | portfolio tag 적용/해제/조회 v1 라우트 | Interface | Approved | Medium | Justin Ko | [req-030](./Interface/req-030_v1-portfolios-tags-crud.md) |
| REQ-031 | portfolio comment CRUD v1 라우트 | Interface | Approved | Medium | Justin Ko | [req-031](./Interface/req-031_v1-portfolios-comments-crud.md) |
| REQ-032 | portfolio v1 baseline 문서 갱신 | Documentation | Approved | Medium | Justin Ko | [req-032](./Documentation/req-032_portfolio-v1-baseline-docs-update.md) |
## Recent Change Log Summary
- 2026-05-20: REQ-016~032 (17건) 일괄 Proposed → Approved 전환 — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. 각 REQ에 CHG-20260520-02 entry 추가
- 2026-05-20: REQ-025~032 최초 생성 — dcy-003 (User Portfolio Mutation v1 API 이전, confirmed 2026-05-20) 후 draft-req 실행. POST/PATCH/DELETE/GET /v1/portfolios + ledger path 이전 + tag/comment v1 + baseline 정합성 (FR-1~10 분해, FR-9는 REQ-013 Deprecated로 이미 처리됨)
- 2026-05-20: REQ-016~024 최초 생성 — dcy-004 (토큰 발급 및 관리 전면 정비, confirmed 2026-05-20) 후 draft-req 실행. client token 폐지 / rotate index 보강 / HTTP daemon_id forward / baseline 정합성 / 발급 권한 매트릭스 / TTL·회전 정책 / daemon deactivation 자동 revoke / NULL daemon 마이그레이션 CLI / env admin SOP
- 2026-05-20: REQ-013 Approved → Deprecated — dcy-003(confirmed 2026-05-20) 옵션 B(path 명명 분리) 결정으로 통합 endpoint 정의 무효화. 후속: `GET /v1/assets`는 REQ-010 보강으로 catalog 전용, `GET /v1/portfolios`는 dcy-003 draft-req 단계에서 신규 REQ로 작성
- 2026-05-19: REQ-001~015 전체 Proposed → Approved 전환 — confirm-req 일괄 승인; 승인 게이트(필수 필드, AC 구체성, Change Log) 모두 충족
- 2026-05-19: REQ-009~015 최초 생성 — dcy-001 (자산 관리 구조 재설계) confirm-discovery 후 draft-req 실행
- 2026-05-19: REQ-001~008 최초 생성 — dcy-002 (태그 소유권 및 적용 대상 정책) confirm-discovery 후 draft-req 실행
@@ -0,0 +1,66 @@
"""service_token rotate unique index 보강: revoked_at IS NULL 조건 추가 (REQ-017)
Revision ID: 20260520_0022
Revises: 20260519_0021
Create Date: 2026-05-20 00:00:00
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import inspect
revision = "20260520_0022"
down_revision = "20260519_0021"
branch_labels = None
depends_on = None
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
bind.execute(
__import__("sqlalchemy").text(
"DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique"
)
)
autocommit_bind = bind.execution_options(isolation_level="AUTOCOMMIT")
autocommit_bind.execute(
__import__("sqlalchemy").text(
"""
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service' AND revoked_at IS NULL
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
bind.execute(
__import__("sqlalchemy").text(
"DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique"
)
)
bind.execute(
__import__("sqlalchemy").text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service'
"""
)
)
+75
View File
@@ -77,11 +77,15 @@ class _AuthRepo:
def __post_init__(self) -> None:
self.users: dict[str, object] = {}
self.tokens: dict[str, AuthToken] = {}
self._daemons: dict[str, object] = {}
self._seq = 1
def get_user_by_username(self, username: str):
return self.users.get(username)
def get_daemon_by_id(self, daemon_id: str):
return self._daemons.get(daemon_id)
def get_service_token_by_name(self, name: str) -> AuthToken | None:
for token in self.tokens.values():
if token.name == name and token.token_type == "service":
@@ -208,3 +212,74 @@ def test_revoke_service_token_not_found_raises() -> None:
assert "nonexistent" in str(exc)
else:
raise AssertionError("Expected ValueError for missing service token")
# REQ-018: daemon_id forward 보정 (service layer)
def test_issue_service_token_with_daemon_id_stores_it() -> None:
from app.domain.models import Daemon, User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
daemon = Daemon(
id="daemon-1",
owner_user_id="user-1",
daemon_slug="my-daemon",
is_active=True,
created_at=datetime.now(timezone.utc),
)
repo._daemons = {"daemon-1": daemon}
service = _service(repo)
result = service.issue_service_token("tok", "alice", no_expiry=True, daemon_id="daemon-1")
assert result.auth_token.daemon_id == "daemon-1"
def test_issue_service_token_with_nonexistent_daemon_raises() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
repo._daemons = {}
service = _service(repo)
try:
service.issue_service_token("tok", "alice", no_expiry=True, daemon_id="no-such-daemon")
except ValueError as exc:
assert "no-such-daemon" in str(exc)
else:
raise AssertionError("Expected ValueError for nonexistent daemon")
def test_issue_service_token_without_daemon_id_is_unbound() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
result = service.issue_service_token("tok", "alice", no_expiry=True)
assert result.auth_token.daemon_id is None
# REQ-016: POST /auth/tokens 제거 — HTTP AC-2
def test_post_auth_tokens_returns_404(monkeypatch) -> None:
import os
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
from app.core.config import get_settings as _get_settings
_get_settings.cache_clear()
from app.main import app as _app
client = TestClient(_app)
response = client.post(
"/auth/tokens",
headers={"Authorization": "Bearer read-token"},
json={"name": "test", "permission": "read"},
)
_app.dependency_overrides.clear()
assert response.status_code in (404, 405)
@@ -0,0 +1,58 @@
"""REQ-016: client token 발급 경로 폐지 후 표면 검증 (AC-1, AC-4)."""
from __future__ import annotations
import subprocess
import sys
# REQ-016 AC-1: codebase에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
def test_no_issue_client_token_symbol_in_codebase() -> None:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "issue_client_token"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
assert matches == [], f"issue_client_token 잔여 참조 발견: {matches}"
def test_no_create_client_token_request_symbol_in_codebase() -> None:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "CreateClientTokenRequest"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}"
def test_no_issued_client_token_response_symbol_in_codebase() -> None:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "IssuedClientTokenResponse"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}"
# REQ-016 AC-4: dcy-003 token_type 게이트 — client token 발급 불가에 따른 회귀 없음
# (token_type='client' 게이트 자체는 유지: dcy-003 OQ-5)
def test_client_token_type_not_importable_from_auth_service() -> None:
import importlib
module = importlib.import_module("app.services.auth_service")
assert not hasattr(module, "IssuedClientToken"), "IssuedClientToken이 auth_service에 잔존합니다"
assert not hasattr(module.AuthService, "issue_client_token"), "issue_client_token 메서드가 잔존합니다"
def test_client_token_request_not_importable_from_schemas() -> None:
import importlib
module = importlib.import_module("app.schemas.auth")
assert not hasattr(module, "CreateClientTokenRequest"), "CreateClientTokenRequest가 schemas/auth에 잔존합니다"
assert not hasattr(module, "IssuedClientTokenResponse"), "IssuedClientTokenResponse가 schemas/auth에 잔존합니다"
+84
View File
@@ -160,6 +160,20 @@ class _DaemonRepo:
existing.daemon_id = daemon_id
return existing
def revoke_tokens_by_daemon_id(self, daemon_id: str) -> int:
count = 0
for token in self._tokens.values():
if token.daemon_id == daemon_id and token.revoked_at is None:
token.revoked_at = datetime.now(timezone.utc)
count += 1
return count
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
return [
t for t in self._tokens.values()
if t.token_type == "service" and t.daemon_id is None and t.revoked_at is None
]
def _repo_with_user(username: str = "alice") -> _DaemonRepo:
repo = _DaemonRepo()
@@ -329,3 +343,73 @@ def test_rebind_service_token_rejects_daemon_from_other_user() -> None:
with pytest.raises(ValueError, match="해당 토큰 사용자에 속하지 않습니다"):
service.rebind_service_token("my-token", foreign_daemon.id)
# ---------------------------------------------------------------------------
# REQ-022: daemon deactivation 시 바인딩 service token 자동 revoke
# ---------------------------------------------------------------------------
def test_deactivate_daemon_revokes_bound_tokens() -> None:
repo = _repo_with_user()
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
service = _service(repo)
issued = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
repo.update_daemon(daemon.id, is_active=False)
revoked_count = repo.revoke_tokens_by_daemon_id(daemon.id)
assert revoked_count == 1
assert repo._tokens[issued.auth_token.id].revoked_at is not None
assert not repo._daemons[daemon.id].is_active
def test_deactivate_daemon_revokes_multiple_bound_tokens() -> None:
repo = _repo_with_user()
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
service = _service(repo)
issued1 = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
issued2 = service.issue_service_token("tok2", "alice", daemon_id=daemon.id, no_expiry=True)
repo.update_daemon(daemon.id, is_active=False)
revoked_count = repo.revoke_tokens_by_daemon_id(daemon.id)
assert revoked_count == 2
assert repo._tokens[issued1.auth_token.id].revoked_at is not None
assert repo._tokens[issued2.auth_token.id].revoked_at is not None
def test_deactivate_daemon_with_no_tokens_is_noop() -> None:
repo = _repo_with_user()
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
repo.update_daemon(daemon.id, is_active=False)
revoked_count = repo.revoke_tokens_by_daemon_id(daemon.id)
assert revoked_count == 0
assert not repo._daemons[daemon.id].is_active
def test_reactivate_daemon_does_not_unrevoke_tokens() -> None:
repo = _repo_with_user()
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
service = _service(repo)
issued = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
repo.update_daemon(daemon.id, is_active=False)
repo.revoke_tokens_by_daemon_id(daemon.id)
repo.update_daemon(daemon.id, is_active=True)
assert repo._daemons[daemon.id].is_active
assert repo._tokens[issued.auth_token.id].revoked_at is not None
def test_revoke_tokens_for_daemon_via_service() -> None:
repo = _repo_with_user()
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
service = _service(repo)
issued = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
count = service.revoke_tokens_for_daemon(daemon.id)
assert count == 1
assert repo._tokens[issued.auth_token.id].revoked_at is not None
+254
View File
@@ -0,0 +1,254 @@
"""REQ-023: NULL daemon_id legacy service token 마이그레이션 CLI 검증 (AC-1~5)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import pytest
from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.domain.models import AuthToken, Daemon, User
from app.services.auth_service import AuthService
# ---------------------------------------------------------------------------
# Fake repository
# ---------------------------------------------------------------------------
@dataclass
class _FakeRepo:
def __post_init__(self) -> None:
self._users: dict[str, User] = {}
self._tokens: dict[str, AuthToken] = {}
self._daemons: dict[str, Daemon] = {}
self._seq = 1
def ping(self) -> None:
pass
def ensure_schema(self) -> None:
pass
def close(self) -> None:
pass
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" and t.revoked_at is None),
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 list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
return [
t for t in self._tokens.values()
if t.token_type == "service" and t.daemon_id is None and t.revoked_at is None
]
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 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}")
daemon = self._daemons.get(daemon_id)
if daemon is None or not daemon.is_active:
raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}")
if daemon.owner_user_id != token.user_id:
raise ValueError("데몬이 해당 토큰 사용자에 속하지 않습니다")
token.daemon_id = daemon_id
return token
def revoke_tokens_by_daemon_id(self, daemon_id: str) -> int:
count = 0
for t in self._tokens.values():
if t.daemon_id == daemon_id and t.revoked_at is None:
t.revoked_at = datetime.now(timezone.utc)
count += 1
return count
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
return self._daemons.get(daemon_id)
def _make_runner(repo: _FakeRepo) -> CliRunner:
app = create_app(
settings_factory=lambda: Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"),
repository_factory=lambda _: repo,
)
runner = CliRunner()
runner.app = app
return runner
def _add_null_daemon_token(repo: _FakeRepo, name: str = "orphan-tok", user_id: str = "user-1") -> AuthToken:
from app.core.security import generate_token, hash_token, token_prefix
raw = generate_token()
return repo.create_auth_token(
user_id,
name=name,
token_hash=hash_token(raw),
token_prefix=token_prefix(raw),
permission="read",
token_type="service",
daemon_id=None,
runtime_id="rt-1",
)
# ---------------------------------------------------------------------------
# REQ-023 AC-1: dry-run with NULL daemon tokens → inventory 출력
# ---------------------------------------------------------------------------
def test_migrate_null_daemon_dry_run_shows_inventory() -> None:
repo = _FakeRepo()
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
token = _add_null_daemon_token(repo, name="orphan-tok", user_id="user-1")
runner = _make_runner(repo)
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon", "--dry-run"])
assert result.exit_code == 0
assert "orphan-tok" in result.output
assert token.id in result.output or "user-1" in result.output
# ---------------------------------------------------------------------------
# REQ-023 AC-2: dry-run with 0 NULL daemon tokens → "no NULL daemon tokens"
# ---------------------------------------------------------------------------
def test_migrate_null_daemon_dry_run_no_tokens_prints_empty_message() -> None:
repo = _FakeRepo()
runner = _make_runner(repo)
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon", "--dry-run"])
assert result.exit_code == 0
assert "no NULL daemon tokens" in result.output
# ---------------------------------------------------------------------------
# REQ-023 AC-3: --dry-run 없이 실행 → non-zero exit + 수동 안내
# ---------------------------------------------------------------------------
def test_migrate_null_daemon_without_dry_run_exits_nonzero() -> None:
repo = _FakeRepo()
runner = _make_runner(repo)
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon"])
assert result.exit_code != 0
assert "rebind" in result.output or "revoke" in result.output or "dry-run" in result.output
# ---------------------------------------------------------------------------
# REQ-023 AC-4: rebind 후 dry-run에서 해당 token 제외
# ---------------------------------------------------------------------------
def test_migrate_null_daemon_after_rebind_token_excluded_from_inventory() -> None:
repo = _FakeRepo()
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
daemon = Daemon(
id="daemon-1",
owner_user_id="user-1",
daemon_slug="alpha",
is_active=True,
created_at=datetime.now(timezone.utc),
)
repo._daemons["daemon-1"] = daemon
_add_null_daemon_token(repo, name="orphan-tok", user_id="user-1")
service = AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
service.rebind_service_token("orphan-tok", "daemon-1")
null_tokens = repo.list_service_tokens_with_null_daemon()
assert null_tokens == []
# ---------------------------------------------------------------------------
# REQ-023 AC-5: revoke 후 dry-run에서 해당 token 제외
# ---------------------------------------------------------------------------
def test_migrate_null_daemon_after_revoke_token_excluded_from_inventory() -> None:
repo = _FakeRepo()
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
token = _add_null_daemon_token(repo, name="admin-fix", user_id="user-1")
service = AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
service.revoke_service_token("admin-fix")
null_tokens = repo.list_service_tokens_with_null_daemon()
assert null_tokens == []
assert repo._tokens[token.id].revoked_at is not None
# ---------------------------------------------------------------------------
# REQ-023: dry-run 출력에 매핑 후보 daemon 포함 여부
# ---------------------------------------------------------------------------
def test_migrate_null_daemon_dry_run_shows_candidate_daemon() -> None:
repo = _FakeRepo()
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
daemon = Daemon(
id="daemon-1",
owner_user_id="user-1",
daemon_slug="alpha",
is_active=True,
created_at=datetime.now(timezone.utc),
)
repo._daemons["daemon-1"] = daemon
_add_null_daemon_token(repo, name="orphan-tok", user_id="user-1")
runner = _make_runner(repo)
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon", "--dry-run"])
assert result.exit_code == 0
assert "daemon-1" in result.output
-29
View File
@@ -236,32 +236,3 @@ def test_get_update_and_delete_user_flow(monkeypatch) -> None:
assert delete_response.status_code == 204
def test_login_and_issue_client_token_flow(monkeypatch) -> None:
repo = FakeUserRepo()
repo.create_user(
"alice",
email="alice@example.com",
password_hash=hash_password("password123"),
permission="trade-write",
)
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "alice", "password": "password123"})
assert login_response.status_code == 200
login_payload = login_response.json()
session_token = login_payload["access_token"]
token_response = client.post(
"/auth/tokens",
headers={"Authorization": f"Bearer {session_token}"},
json={"name": "mobile-app", "permission": "read", "expires_in_days": 30},
)
app.dependency_overrides.clear()
assert token_response.status_code == 201
token_payload = token_response.json()
assert token_payload["auth_token"]["name"] == "mobile-app"
assert token_payload["auth_token"]["token_type"] == "client"
assert token_payload["token"]