작업 내용 저장

This commit is contained in:
2026-04-23 09:56:08 +00:00
parent 514b49cb98
commit d383c7086f
29 changed files with 1654 additions and 1372 deletions
@@ -0,0 +1,468 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다.
- 상태: confirmed
- 문서 ID: sdlc-002_20260423_service-token-user-based
- 이슈명: service-token user-based 소유권
- 참조 Planning: docs/sdlc/sdlc-002_20260423_service-token-user-based/2_planning.md
- 작성 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23 (confirm-design 실행 — Design 최종 승인, Implementation 진입 허가)
# 1. Planning 인계 요약
Planning에서 확정된 핵심 내용을 요약한다. 원본은 2_planning.md를 참조하며, 이 섹션은 Design 작업의 입력 기준선이 된다.
## 이슈 목적
`bot_principals` 테이블과 `auth_audit_logs` 테이블을 제거하고, service token을 `auth_tokens` 단일 엔티티로 직접 발급하도록 재구현한다. CLI 명령 `pfhub service-token issue <name> --user <username>`으로 사용자 기반 소유권을 명확히 한다.
## 확정 태스크 목록
| ID | 태스크 제목 | 참조 요구사항 | 담당자 |
|----|-----------|------------|--------|
| T-1 | DB 마이그레이션 파일 작성 | FR-3, NFR-2 | copilot |
| T-2 | 도메인/스키마/인터페이스 정리 | FR-1 | copilot |
| T-3 | postgres.py 구현체 정리 | FR-1, FR-4 | copilot |
| T-4 | AuthContext/security.py 변경 | FR-1 (OQ-4) | copilot |
| T-5 | AuthService 재구현 | FR-2, FR-5, FR-6, FR-7 | copilot |
| T-6 | API 경로 정리 | FR-1 | copilot |
| T-7 | CLI 재구현 | FR-5, FR-6, FR-7, NFR-3 | copilot |
| T-8 | 테스트 업데이트 | NFR-1 | copilot |
| T-9 | 기술 문서 업데이트 | - | copilot |
## 이월된 오픈 질문 / 리스크
없음. Planning 단계에서 모든 OQ 해소 완료.
# 2. 아키텍처 및 구조 설계
변경이 미치는 전체 구조와 주요 컴포넌트 관계를 설명한다.
## 전체 구조 개요
시스템은 아래 5개 레이어로 구성되며, 이번 변경은 모든 레이어에 걸친 `bot_principal` 엔티티 제거와 service token 직접 발급 모델로의 전환이다.
```
CLI (app/cli/)
↓ service_tokens.py (bot_tokens.py 대체)
Service (app/services/auth_service.py)
↓ issue_service_token() / revoke_service_token()
Repository (app/repositories/postgres.py)
↓ auth_tokens 직접 INSERT, bot_principals/auth_audit_logs 테이블 미사용
DB (migrations/versions/)
auth_tokens (user_id = 실제 user.id, token_type = 'service')
[제거] bot_principals, auth_audit_logs
```
변경 전: `auth_tokens.user_id` ← `bot_principal.id` (봇 자체 UUID, JOIN으로 소유자 추적)
변경 후: `auth_tokens.user_id` ← `users.id` (실제 소유 사용자 직접 참조)
## 주요 컴포넌트
| 컴포넌트 | 역할 | 영향 범위 |
|---------|------|---------|
| `app/domain/models.py` | 도메인 모델 정의 | `BotPrincipal` 삭제, `AuthToken.bot_principal_id` 삭제, `TokenPrincipal.bot_principal_id` 삭제, `token_type` Literal 변경 |
| `app/schemas/auth.py` | API/CLI 요청·응답 스키마 | bot_principal 관련 스키마 삭제, `AuthTokenResponse.token_type` 갱신, `ServiceTokenIssueCliResponse` / `ServiceTokenRevokeCliResponse` 추가 |
| `app/repositories/portfolio_repository.py` | Repository Protocol 인터페이스 | bot_principal 관련 메서드 삭제, `get_service_token_by_name()` 추가 |
| `app/repositories/postgres.py` | DB 접근 구현체 | `ensure_schema()` bot 관련 DDL 제거, `get_token_principal()` JOIN 단순화, bot/audit 메서드 삭제, `get_service_token_by_name()` 추가 |
| `app/core/auth.py` | 인증 컨텍스트 해석 | `AuthContext.bot_principal_id` 삭제, `AuthContext.token_type` 추가 |
| `app/core/security.py` | 암호화·토큰 유틸 | `build_audit_event()` 삭제 |
| `app/services/auth_service.py` | 인증 비즈니스 로직 | bot 관련 메서드 삭제, `issue_service_token()` / `revoke_service_token()` 추가, 가드 교체 |
| `app/api/auth_routes.py` | FastAPI 라우터 | `/auth/bots`, `/auth/me/bots` 경로 삭제 |
| `app/cli/commands/bot_tokens.py` | CLI 명령 (구 파일) | `app/cli/commands/service_tokens.py`로 교체 |
| `app/cli/commands/service_tokens.py` | CLI 명령 (신규 파일) | `pfhub service-token issue <name> --user <username>` / `revoke <name>` |
| `app/cli/core/output.py` | CLI 출력 헬퍼 | `render_bot_result()` → `render_service_token_result()`로 rename |
| `app/cli/main.py` | CLI 진입점 | import 경로 변경 (`bot_tokens` → `service_tokens`) |
| `migrations/versions/` | DB 마이그레이션 | `20260423_0013_remove_bot_principals_service_tokens.py` 신규 생성 |
## 데이터 흐름
### service token 발급 흐름 (변경 후)
1. CLI: `pfhub service-token issue <name> --user <username>` 실행
2. CLI: `AuthService.issue_service_token(name=name, username=username, ...)` 호출
3. Service: `repository.get_user_by_username(username)` → user 조회 (없으면 ValueError)
4. Service: `repository.create_auth_token(user.id, name=name, token_type="service", ...)` 실행
- name uniqueness는 DB `partial unique index ON auth_tokens(name) WHERE token_type='service'`로 보장
5. Service: `IssuedServiceToken(token=raw_token, auth_token=auth_token)` 반환
6. CLI: 결과 출력 (ServiceTokenIssueCliResponse)
### service token 인증 흐름 (변경 후)
1. HTTP 요청: `Authorization: Bearer <service_token>` 헤더 포함
2. `_resolve_auth_context()`: `repository.get_token_principal(hash_token(token))` 호출
3. `get_token_principal()`: 단순 `SELECT id, user_id, permission, token_type FROM auth_tokens WHERE token_hash = %s AND ...` (JOIN 없음)
4. `AuthContext(token_type="service", user_id=<실제 user_id>, ...)` 생성
5. service token 가드: `auth_context.token_type == "service"` → 토큰 발급/목록/폐기 조작 차단
# 3. 상세 설계
태스크(T-N)별로 구체적인 구현/변경 방법을 기술한다.
## T-1: DB 마이그레이션 파일 작성
- 설계 방향: Alembic 마이그레이션으로 DB 구조를 변경한다. upgrade 전 사전 검증 쿼리를 포함하여 NULL owner_user_id로 인한 FK 위반을 사전 차단한다.
- 변경 대상: `migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py` (신규 생성)
- 입력/출력:
- upgrade() 실행 순서:
1. `SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL` → 0이 아니면 `RuntimeError`로 중단
2. `UPDATE auth_tokens t SET user_id = b.owner_user_id FROM bot_principals b WHERE t.bot_principal_id = b.id AND t.bot_principal_id IS NOT NULL` — user_id 보정
3. `UPDATE auth_tokens SET token_type = 'service' WHERE token_type = 'bot'` — token_type rename
4. `CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique ON auth_tokens(name) WHERE token_type = 'service'` — service token name 유니크 제약
5. `ALTER TABLE auth_tokens DROP COLUMN IF EXISTS bot_principal_id`
6. `DROP TABLE IF EXISTS auth_audit_logs`
7. `DROP TABLE IF EXISTS bot_principals`
- downgrade() 실행 순서 (역방향 복원):
1. `CREATE TABLE bot_principals (id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_user_id TEXT, permission TEXT NOT NULL, description TEXT, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ, deactivated_at TIMESTAMPTZ)`
2. `CREATE TABLE auth_audit_logs (id TEXT PRIMARY KEY, event_type TEXT NOT NULL, action TEXT NOT NULL, actor_id TEXT, bot_id TEXT NOT NULL, token_id_masked TEXT, token_ref_masked TEXT, result TEXT, detail TEXT, payload JSONB, occurred_at TIMESTAMPTZ NOT NULL)`
3. `ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT`
4. `UPDATE auth_tokens SET token_type = 'bot' WHERE token_type = 'service'`
5. `DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique`
6. (user_id 역방향 보정 불가 — bot_principals 데이터 없음. downgrade는 구조 복원만 수행하며 데이터 보정은 생략)
- 예외/엣지 케이스: upgrade 전 bot_principals.owner_user_id NULL 검사가 실패하면 마이그레이션 중단. 이 경우 운영자가 수동으로 NULL 행을 보정해야 한다.
## T-2: 도메인/스키마/인터페이스 정리
- 설계 방향: bot_principal 관련 클래스·필드를 일괄 삭제하고 service token용 CLI 응답 스키마를 추가한다.
- 변경 대상:
- `app/domain/models.py`:
- `BotPrincipal` 데이터클래스 전체 삭제
- `AuthToken.bot_principal_id: str | None` 필드 삭제
- `TokenPrincipal.bot_principal_id: str | None` 필드 삭제
- `AuthToken.token_type` 타입 힌트: `str` 유지 (Literal 미사용 — 기존과 동일)
- `app/schemas/auth.py`:
- 삭제 대상: `CreateBotPrincipalRequest`, `CreateSelfBotPrincipalRequest`, `BotPrincipalResponse`, `CreateBotTokenRequest`, `IssuedBotTokenResponse`, `BotTokenIssueCliResponse`, `BotTokenRevokeCliResponse`
- 변경 대상: `AuthTokenResponse.token_type` Literal — `"bot"` 제거, `"service"` 추가 (`Literal["session", "client", "service"]`)
- 변경 대상: `AuthTokenResponse.bot_principal_id: str | None` 필드 삭제
- 추가 대상: `ServiceTokenIssueCliResponse(token_id: str, name: str, expires_at: datetime | None, revoked: bool, token: str | None)`, `ServiceTokenRevokeCliResponse(token_id: str, name: str, revoked: bool, revoked_at: datetime | None)`
- `app/repositories/portfolio_repository.py`:
- 삭제 대상 메서드: `create_bot_principal`, `get_bot_principal`, `list_bot_principals`, `deactivate_bot_principal`, `list_bot_auth_tokens`, `revoke_bot_auth_token`
- `create_auth_token()` 시그니처에서 `bot_principal_id: str | None = None` 파라미터 삭제
- 추가 대상 메서드: `get_service_token_by_name(name: str) -> AuthToken | None`
- `BotPrincipal` import 삭제
- 입력/출력: `python -c "from app.domain.models import AuthToken"` 실행 시 ImportError 없음
## T-3: postgres.py 구현체 정리
- 설계 방향: ensure_schema()에서 bot 관련 DDL을 제거하고, get_token_principal()의 JOIN을 단순화한다. bot/audit 관련 메서드를 삭제하고 get_service_token_by_name()을 추가한다.
- 변경 대상: `app/repositories/postgres.py`
- `_SELECT_BOT_PRINCIPAL` 쿼리 상수 삭제
- `_SELECT_AUTH_TOKEN` 쿼리에서 `bot_principal_id` 컬럼 삭제
- `ensure_schema()` 내 제거 대상:
- `CREATE TABLE IF NOT EXISTS bot_principals (...)` 블록
- `ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT`
- `ALTER TABLE bot_principals ...` 블록 전체
- `idx_auth_tokens_bot_created` 인덱스 생성 구문
- `idx_bot_principals_*` 인덱스 생성 구문
- `CREATE TABLE IF NOT EXISTS auth_audit_logs (...)` 블록
- `idx_auth_audit_logs_bot_occurred` 인덱스 생성 구문
- `get_token_principal()` 변경:
- 변경 전: `SELECT t.id, COALESCE(b.owner_user_id, t.user_id), t.permission, t.token_type, t.bot_principal_id FROM auth_tokens t LEFT JOIN bot_principals b ...`
- 변경 후: `SELECT id, user_id, permission, token_type FROM auth_tokens WHERE token_hash = %s AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())`
- `TokenPrincipal` 생성 시 `bot_principal_id` 인자 제거
- `create_auth_token()` 시그니처에서 `bot_principal_id` 파라미터 및 INSERT 컬럼 제거
- 삭제 대상 메서드: `create_bot_principal`, `get_bot_principal`, `list_bot_principals`, `deactivate_bot_principal`, `list_bot_auth_tokens`, `revoke_bot_auth_token`, `record_audit_event`
- 추가 대상 메서드:
```python
def get_service_token_by_name(self, name: str) -> AuthToken | None:
# SELECT * FROM auth_tokens WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL
# → AuthToken 또는 None 반환
```
- 입력/출력: `ensure_schema()` 재실행 시 `bot_principals`, `auth_audit_logs` 테이블 미생성. `get_token_principal()` 반환값의 `bot_principal_id` 없음.
## T-4: AuthContext/security.py 변경
- 설계 방향: `AuthContext`에 `token_type` 필드를 추가하고 `bot_principal_id`를 제거한다. `security.py`에서 `build_audit_event()` 함수를 삭제한다.
- 변경 대상:
- `app/core/auth.py`:
- `AuthContext` 데이터클래스: `bot_principal_id: str | None = None` 삭제, `token_type: str | None = None` 추가
- `_resolve_auth_context()`: `bot_principal_id=principal.bot_principal_id` 제거, `token_type=principal.token_type` 추가 (env 토큰은 `token_type=None` 유지)
- `app/core/security.py`:
- `build_audit_event()` 함수 삭제
- 입력/출력:
- env 토큰: `AuthContext(token=..., permission=..., token_source="env", token_type=None)`
- DB 세션 토큰: `AuthContext(token=..., permission=..., user_id=..., token_id=..., token_source="db", token_type="session")`
- DB service 토큰: `AuthContext(token=..., permission=..., user_id=..., token_id=..., token_source="db", token_type="service")`
## T-5: AuthService 재구현
- 설계 방향: bot 관련 메서드를 모두 삭제하고 `issue_service_token()` / `revoke_service_token()`을 추가한다. `bot_principal_id` 가드를 `token_type == "service"` 가드로 교체한다.
- 변경 대상: `app/services/auth_service.py`
- 가드 교체 (3개소):
- `issue_client_token()`: `if auth_context.bot_principal_id is not None` → `if auth_context.token_type == "service"`
- `list_tokens()`: 동일
- `revoke_token()`: 동일
- 삭제 대상 메서드: `create_bot_principal`, `create_owned_bot_principal`, `list_bot_principals`, `list_owned_bot_principals`, `get_bot_principal`, `deactivate_bot_principal`, `issue_bot_token`, `issue_owned_bot_token`, `list_bot_tokens`, `list_owned_bot_tokens`, `revoke_bot_token`, `revoke_owned_bot_token`, `_record_audit_event`, `_build_bot_expiration`, `_require_db_user_context`
- `_require_db_user_context()` 노트: 모든 호출자가 삭제되므로 함께 삭제 (review-design에서 가드 교체 대상에서 삭제 대상으로 재분류)
- 삭제 대상 import: `build_audit_event`, `mask_secret`, `BotPrincipal`, `CreateBotPrincipalRequest`, `CreateBotTokenRequest`, `CreateSelfBotPrincipalRequest`
- `IssuedBotToken` 데이터클래스 → `IssuedServiceToken(token: str, auth_token: AuthToken)`으로 교체
- 추가 대상 메서드:
```python
def issue_service_token(
self,
name: str,
username: str,
*,
permission: str | None = None,
expires_in_days: int | None = None,
no_expiry: bool = False,
) -> IssuedServiceToken:
user = self._repository.get_user_by_username(username)
if user is None:
raise ValueError(f"사용자 '{username}'을 찾을 수 없습니다")
requested = Permission(permission or user.permission)
raw_token = generate_token()
expires_at = self._build_expiration(
expires_in_days if not no_expiry else None,
self._settings.client_token_default_ttl_days if not no_expiry else None,
)
auth_token = self._repository.create_auth_token(
user.id,
name=name,
token_hash=hash_token(raw_token),
token_prefix=token_prefix(raw_token),
permission=requested.value,
token_type="service",
expires_at=expires_at,
)
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
def revoke_service_token(self, name: str) -> datetime:
token = self._repository.get_service_token_by_name(name)
if token is None:
raise ValueError(f"service token '{name}'을 찾을 수 없습니다")
self._repository.revoke_auth_token(token.user_id, token.id)
return datetime.now(timezone.utc)
```
- 예외/엣지 케이스:
- `issue_service_token()`: `name` 중복 시 DB `UNIQUE constraint` 위반 → psycopg `UniqueViolation` 예외 → `ValueError`로 래핑
- `revoke_service_token()`: 이미 폐기된 토큰도 `get_service_token_by_name()`에서 `revoked_at IS NOT NULL` 필터 → None 반환 → ValueError 발생 (명시적 오류)
## T-6: API 경로 정리
- 설계 방향: bot_principal 관련 엔드포인트와 핸들러, 헬퍼 함수를 모두 삭제한다.
- 변경 대상: `app/api/auth_routes.py`
- 삭제 대상 라우트:
- `POST /auth/bots` (create_bot_principal)
- `POST /auth/me/bots` (create_owned_bot_principal)
- `GET /auth/me/bots` (list_owned_bot_principals)
- `POST /auth/me/bots/{bot_id}/tokens` (issue_owned_bot_token)
- `GET /auth/me/bots/{bot_id}/tokens` (list_owned_bot_tokens)
- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}` (revoke_owned_bot_token)
- `DELETE /auth/bots/{bot_id}` 또는 관련 bot deactivate 라우트 (존재 시)
- 삭제 대상 헬퍼 함수: `_to_bot_principal_response()`, `_to_issued_bot_token_response()`
- 삭제 대상 import: `BotPrincipal`, `BotPrincipalResponse`, `CreateBotPrincipalRequest`, `CreateSelfBotPrincipalRequest`, `CreateBotTokenRequest`, `IssuedBotTokenResponse`, `IssuedBotToken`
- `_to_auth_token_response()`: `bot_principal_id=token.bot_principal_id` 인자 삭제
- 입력/출력: FastAPI 기동 후 `/openapi.json`에 `/auth/bots`, `/auth/me/bots` 경로 미노출
## T-7: CLI 재구현
- 설계 방향: `bot_tokens.py`를 `service_tokens.py`로 교체하고, CLI 인수를 bot_id 기반에서 name + --user 기반으로 변경한다.
- 변경 대상:
- 신규 파일: `app/cli/commands/service_tokens.py`
```python
token_app = typer.Typer(help="Service token lifecycle commands.")
@token_app.command("issue")
def issue_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Service token name (unique identifier)."),
username: str = typer.Option(..., "--user", help="Username to assign the token to."),
permission: str | None = typer.Option(None, help="Token permission: read or trade-write."),
ttl_days: int | None = typer.Option(None, "--ttl-days", help="Token TTL in days."),
no_expiry: bool = typer.Option(False, "--no-expiry", help="Issue token with no expiry."),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
output_format: str = typer.Option("table", "--format"),
) -> None: ...
@token_app.command("revoke")
def revoke_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Service token name to revoke."),
force: bool = typer.Option(False, "--force", "-f"),
output_format: str = typer.Option("table", "--format"),
) -> None: ...
```
- 구 파일 `app/cli/commands/bot_tokens.py` 삭제
- `app/cli/main.py`: `from app.cli.commands.bot_tokens import token_app` → `from app.cli.commands.service_tokens import token_app`
- `app/cli/core/output.py`: `render_bot_result()` → `render_service_token_result()` rename
- 응답 스키마: `BotTokenIssueCliResponse` → `ServiceTokenIssueCliResponse`, `BotTokenRevokeCliResponse` → `ServiceTokenRevokeCliResponse`
- 입력/출력: `pfhub service-token issue --help`에서 `NAME` positional, `--user` 옵션 노출. help 텍스트에 "bot" 문자열 없음.
## T-8: 테스트 업데이트
- 설계 방향: bot_principal 참조를 제거하고 service token 시나리오 테스트를 추가한다.
- 변경 대상:
- `tests/test_auth.py`:
- `create_bot_principal`, `issue_bot_token`, `revoke_bot_token` 관련 테스트 함수 삭제
- `issue_service_token()` 단위 테스트 추가:
- 정상 발급 (username 존재, name 신규)
- 존재하지 않는 username → ValueError
- 중복 name → UniqueViolation → ValueError
- `revoke_service_token()` 단위 테스트 추가
- service token 가드 테스트: `token_type == "service"`인 AuthContext로 `issue_client_token()` 호출 시 ValueError 확인
- `tests/test_cli.py`:
- `service-token issue <name> --user <username>` 명령 통합 테스트 추가
- `service-token revoke <name>` 명령 통합 테스트 추가
- bot-token CLI 관련 테스트 삭제 또는 service-token으로 업데이트
- `tests/test_users.py`: bot_principal 참조 제거 (있는 경우)
- `tests/test_portfolio_ownership.py`:
- `bot_principal_id` 기반 mock → `token_type="service"` 기반 mock으로 교체
## T-9: 기술 문서 업데이트
- 설계 방향: 4개 기술 문서에서 bot_principal 참조를 제거하고 service token 기반 설명으로 갱신한다.
- 변경 대상:
- `docs/technical/auth-and-token-lifecycle.md`: bot → service 전반 업데이트, `AuthContext.token_type` 설명 추가, bot_principals/auth_audit_logs 삭제 반영
- `docs/technical/api-contracts.md`: `/auth/bots`, `/auth/me/bots` 경로 삭제, 변경 후 token_type 값 갱신
- `docs/technical/management-cli-operations.md`: `pfhub service-token issue <name> --user <username>` 명령 예제로 교체, bot-token 관련 내용 삭제
- `README.md`: CLI 섹션에서 bot-token 예제 삭제, service-token 예제 추가
# 4. 인터페이스 정의
외부 시스템, 모듈 간 계약(API, 이벤트, 파일 포맷 등)을 명시한다.
## API / 함수 시그니처
**AuthService 신규 메서드:**
```python
def issue_service_token(
self,
name: str, # auth_tokens.name으로 저장되는 서비스 식별자 (service token 범위 내 유니크)
username: str, # users.username → user_id 조회 후 auth_tokens.user_id로 저장
*,
permission: str | None = None, # None이면 user.permission 상속
expires_in_days: int | None = None,
no_expiry: bool = False,
) -> IssuedServiceToken: ...
def revoke_service_token(
self,
name: str, # service token 이름으로 조회 후 폐기
) -> datetime: ... # revoked_at 반환
```
**PortfolioRepository Protocol 신규 메서드:**
```python
def get_service_token_by_name(self, name: str) -> AuthToken | None: ...
# SELECT * FROM auth_tokens WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL
```
**AuthContext 변경 후 필드:**
```python
@dataclass(frozen=True)
class AuthContext:
token: str
permission: Permission
user_id: str | None = None
token_id: str | None = None
token_type: str | None = None # "session" | "client" | "service" | None (env 토큰)
token_source: str = "env" # "env" | "db"
```
## 데이터 스키마 / 포맷
**ServiceTokenIssueCliResponse** (새 스키마):
```python
class ServiceTokenIssueCliResponse(BaseModel):
token_id: str
name: str
expires_at: datetime | None = None
revoked: bool
token: str | None = None # 발급 시 일회 노출, 이후 마스킹
```
**ServiceTokenRevokeCliResponse** (새 스키마):
```python
class ServiceTokenRevokeCliResponse(BaseModel):
token_id: str
name: str
revoked: bool
revoked_at: datetime | None = None
```
**AuthTokenResponse** 변경:
- `token_type`: `Literal["session", "client", "bot"]` → `Literal["session", "client", "service"]`
- `bot_principal_id: str | None` 필드 삭제
## 이벤트 / 메시지
해당 없음. audit log 테이블 자체 삭제로 이벤트 기록 없음.
# 5. 보안 고려사항
OWASP Top 10 기준으로 이번 변경에서 주의해야 할 보안 항목을 점검한다.
| 항목 | 해당 여부 | 대응 방안 |
|------|---------|---------|
| 입력값 검증 (Injection) | 해당 | `--user <username>` 값은 `get_user_by_username()`을 통해 파라미터 바인딩으로 DB 조회. SQL injection 없음. `name` 값도 파라미터 바인딩 사용. |
| 인증/인가 | 해당 | service token은 `token_type == "service"` 가드로 토큰 발급/목록/폐기 조작 차단. 인가 단계에서 `AuthContext.token_type` 필드를 명시적으로 확인. env 토큰(token_type=None)은 가드 미적용 — admin env 토큰은 해당 조작 허용 (기존 설계 유지). |
| 민감 데이터 노출 | 해당 | 발급된 raw token은 CLI에서 발급 시 1회만 출력. 저장 시 SHA-256 해시(`token_hash`)만 DB에 저장. `mask_secret()` 함수로 출력 시 마스킹 유지. audit log 삭제로 raw token_ref 잔류 위험 제거. |
| 의존성 취약점 | 비해당 | 이번 변경은 새 의존성 추가 없음. 기존 psycopg, fastapi, typer 의존성 유지. |
| 기타 (권한 상승) | 해당 | `issue_service_token()` 시 `permission`은 user.permission 상한으로 제한 (`_has_permission()` 체크). 지정한 user의 권한을 초과하는 service token 발급 차단. |
# 6. 리스크
Design 단계에서 새로 식별된 리스크를 기록한다.
## 리스크
- R-1 (High, 이월): 기존 `auth_tokens`에 `user_id = bot_principal.id`인 행의 `bot_principal.owner_user_id`가 NULL이거나 `users` 테이블에 없으면 마이그레이션 FK 위반 발생.
- 완화 방안: T-1 upgrade() 첫 번째 스텝에서 NULL 검사 쿼리 실행 후 0이 아니면 `RuntimeError`로 중단. 운영 전 사전 검증 권장.
- R-2 (Medium, 이월): 약 15개 파일 rename 중 일부 누락 시 런타임 `AttributeError`/`NameError`.
- 완화 방안: T-8 완료 후 `grep -r "bot_principal\|BotPrincipal\|bot_token\|token_type.*bot\|render_bot_result"` 실행으로 잔류 참조 검사.
- R-3 (Medium): `revoke_service_token(name)` 구현 시 `get_service_token_by_name()`이 `revoked_at IS NOT NULL` 필터를 포함하면 이미 폐기된 토큰 revoke 시도 시 "찾을 수 없음" 오류 발생.
- 완화 방안: CLI에서 적절한 오류 메시지 출력. 설계상 의도된 동작.
- R-4 (Low): `ensure_schema()` DDL 제거 후 기존 테스트 환경이 이전 스키마를 캐시하는 경우 bot_principals 테이블 잔류 가능.
- 완화 방안: T-3 DoD에서 `ensure_schema()` 재실행 후 테이블 미존재 확인 포함.
## 가정
- DA-1: `app/cli/main.py`에서 `name="service-token"` 등록이 이미 완료되어 있으므로 T-7에서 import 경로(`bot_tokens` → `service_tokens`)만 변경하면 된다.
- DA-2: `get_user_by_username()` 메서드가 postgres.py에 이미 구현되어 있다 (`auth_service.py`의 `login()` 메서드에서 사용 중으로 확인됨).
- DA-3: `revoke_auth_token(user_id, token_id)` 메서드가 postgres.py에 구현되어 있어 `revoke_service_token()` 내부에서 재사용 가능하다.
- DA-4: auth_routes.py에서 삭제 대상 이외의 라우트(`/auth/login`, `/auth/tokens`, `/auth/tokens/{id}`)는 변경 없이 유지된다.
## 오픈 질문
없음. 모든 설계 결정 완료.
# 7. Implementation으로 넘기기 전 확인 체크
Implementation 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다.
- [ ] 전체 컴포넌트 및 태스크별 설계 완성
- 기준: T-1 ~ T-9 모든 태스크에 설계 방향, 변경 대상, 입출력이 명시되어야 한다
- [ ] 인터페이스 계약 확정
- 기준: `issue_service_token()`, `revoke_service_token()`, `get_service_token_by_name()`, `AuthContext.token_type` 시그니처가 명시되어야 한다
- [ ] 보안 점검 완료
- 기준: OWASP 점검 항목 중 해당 항목(입력값 검증, 인증/인가, 민감 데이터 노출, 권한 상승)에 대응 방안이 있어야 한다
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개 — 확인 완료
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 문서 승인 또는 인계 결정을 내릴 담당자가 명시되어야 한다
# 8. 파일 처리 결과
이 Design 문서 생성/갱신 결과와 참조 문서를 기록한다.
- 처리 결과: 생성
- 생성 경로: docs/sdlc/sdlc-002_20260423_service-token-user-based/3_design.md
- 참조 문서:
- docs/sdlc/sdlc-002_20260423_service-token-user-based/2_planning.md
- docs/sdlc/sdlc-002_20260423_service-token-user-based/1_discovery.md
- app/domain/models.py
- app/core/auth.py
- app/core/security.py
- app/services/auth_service.py
- app/repositories/postgres.py (line 821: get_token_principal, line 124: ensure_schema)
- app/repositories/portfolio_repository.py
- app/schemas/auth.py
- app/api/auth_routes.py
- app/cli/commands/bot_tokens.py
- app/cli/core/output.py
- app/cli/main.py
- migrations/versions/20260414_0011_bot_principal_owner_user_id.py (최신 마이그레이션)
# 9. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다.
## DECIDE
없음. 모든 설계 결정이 Discovery/Planning에서 완료되었다.
## CONFIRM
- Freeze 확정 담당자(`CONFIRMED_BY`): Justin — confirm-design 단계에서 처리
## DATA
- 마이그레이션 실행 전 운영 DB `bot_principals` 행 수 및 `owner_user_id NULL` 행 수 확인 필요 (R-1 완화). T-1 upgrade() 내 사전 검증 쿼리가 이를 자동으로 수행하지만, 사전 확인 권장.
# 10. Design Freeze
Implementation 인계 시점의 확정 플래그와 섹션 참조만 기록한다.
## 섹션 참조
- 아키텍처/구조: [# 2. 아키텍처 및 구조 설계](#2-아키텍처-및-구조-설계)
- 상세 설계: [# 3. 상세 설계](#3-상세-설계)
- 인터페이스: [# 4. 인터페이스 정의](#4-인터페이스-정의)
- 보안: [# 5. 보안 고려사항](#5-보안-고려사항)
- 리스크/오픈 질문: [# 6. 리스크](#6-리스크)
- 인계 전 확인: [# 7. Implementation으로 넘기기 전 확인 체크](#7-implementation으로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 9. 사용자 결정 필요 항목 요약](#9-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: T-1~T-9 전체 상세 설계 완성, 주요 인터페이스(`issue_service_token`, `revoke_service_token`, `get_service_token_by_name`, `AuthContext.token_type`) 확정, OWASP 해당 4개 항목 대응 방안 명시, OQ 없음, 저장소 현황 대조 이상 없음.
- Ready for Implementation: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-04-23