Files
portfolio-hub/docs/sdlc/sdlc-002_20260423_service-token-user-based/3_design.md
T
2026-04-23 09:56:08 +00:00

29 KiB

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
    • 추가 대상 메서드:
      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)으로 교체
    • 추가 대상 메서드:
      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
      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 신규 메서드:

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 신규 메서드:

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 변경 후 필드:

@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 (새 스키마):

class ServiceTokenIssueCliResponse(BaseModel):
    token_id: str
    name: str
    expires_at: datetime | None = None
    revoked: bool
    token: str | None = None   # 발급 시 일회 노출, 이후 마스킹

ServiceTokenRevokeCliResponse (새 스키마):

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 인계 시점의 확정 플래그와 섹션 참조만 기록한다.

섹션 참조

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