Merge pull request #19 from fourblocks2020/feat/dcy-011-daemon-crud-self-service-credential-api

dcy-011: 데몬 CRUD + 사용자 self-service 인증정보 관리 API — BAT-022 (REQ-052/053/054)
This commit is contained in:
2026-06-28 14:54:14 +09:00
committed by GitHub
23 changed files with 3065 additions and 6 deletions
+191
View File
@@ -0,0 +1,191 @@
"""POST/PATCH /v1/admin/daemons — admin daemon CRUD (REQ-053)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.auth import AuthContext, require_admin
from app.core.config import Settings, get_settings
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import DaemonAdminResponse, DaemonCreateRequest, DaemonUpdateRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/admin", tags=["v1-admin"])
def _get_repo(settings=None):
from app.core.config import get_settings as _gs
return get_portfolio_repository_from_settings(settings or _gs())
def _to_response(daemon) -> DaemonAdminResponse:
return DaemonAdminResponse(
id=daemon.id,
daemon_slug=daemon.daemon_slug,
display_name=daemon.display_name,
owner_user_id=daemon.owner_user_id,
is_active=daemon.is_active,
)
def _daemon_error_to_http(exc: ValueError) -> HTTPException:
detail = str(exc)
if "찾을 수 없습니다" in detail:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
if "이미 존재하는 daemon slug" in detail:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
if "사용자를 찾을 수 없습니다" in detail:
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
# ---------------------------------------------------------------------------
# POST /v1/admin/daemons — 데몬 생성 (REQ-053 AC-1/2)
# ---------------------------------------------------------------------------
@router.post("/daemons", response_model=DaemonAdminResponse, status_code=status.HTTP_201_CREATED)
def create_daemon(
body: DaemonCreateRequest,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> DaemonAdminResponse:
"""Create a new daemon and assign owner (admin only). REQ-053 AC-1/2."""
repo = _get_repo(settings)
try:
daemon = repo.create_daemon(
body.owner_user_id,
daemon_slug=body.daemon_slug,
display_name=body.display_name,
)
return _to_response(daemon)
except ValueError as exc:
raise _daemon_error_to_http(exc) from exc
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
# ---------------------------------------------------------------------------
# PATCH /v1/admin/daemons/{daemon_id} — display_name/owner 변경 (REQ-053 AC-3/4)
# ---------------------------------------------------------------------------
@router.patch("/daemons/{daemon_id}", response_model=DaemonAdminResponse)
def update_daemon(
daemon_id: str,
body: DaemonUpdateRequest,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> DaemonAdminResponse:
"""Update daemon display_name and/or owner.
Owner change auto-invalidates all broker credentials for this daemon (REQ-053 AC-4).
"""
repo = _get_repo(settings)
try:
existing = repo.get_daemon_by_id(daemon_id)
if existing is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
if body.owner_user_id is not None and body.owner_user_id != existing.owner_user_id:
# Owner change: update owner + invalidate credentials
try:
daemon = repo.update_daemon_owner(daemon_id, owner_user_id=body.owner_user_id)
except ValueError as exc:
raise _daemon_error_to_http(exc) from exc
# Apply display_name change if also requested
if body.display_name is not None:
try:
daemon = repo.update_daemon(daemon_id, display_name=body.display_name)
except ValueError as exc:
raise _daemon_error_to_http(exc) from exc
else:
# No owner change — update display_name only if provided
if body.display_name is not None:
try:
daemon = repo.update_daemon(daemon_id, display_name=body.display_name)
except ValueError as exc:
raise _daemon_error_to_http(exc) from exc
else:
daemon = existing
return _to_response(daemon)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
# ---------------------------------------------------------------------------
# POST /v1/admin/daemons/{daemon_id}/deactivate (REQ-053 AC-6)
# ---------------------------------------------------------------------------
@router.post("/daemons/{daemon_id}/deactivate", response_model=DaemonAdminResponse)
def deactivate_daemon(
daemon_id: str,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> DaemonAdminResponse:
"""Deactivate daemon and auto-revoke bound service tokens (REQ-053 AC-6, REQ-022)."""
repo = _get_repo(settings)
try:
existing = repo.get_daemon_by_id(daemon_id)
if existing is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
daemon = repo.update_daemon(daemon_id, is_active=False)
# REQ-022: auto-revoke service tokens bound to this daemon
revoked = repo.revoke_tokens_by_daemon_id(daemon_id)
logger.info(
"daemon %s deactivated; revoked %d service token(s)",
daemon_id,
revoked,
)
return _to_response(daemon)
except ValueError as exc:
raise _daemon_error_to_http(exc) from exc
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
# ---------------------------------------------------------------------------
# POST /v1/admin/daemons/{daemon_id}/activate (REQ-053 AC-7)
# ---------------------------------------------------------------------------
@router.post("/daemons/{daemon_id}/activate", response_model=DaemonAdminResponse)
def activate_daemon(
daemon_id: str,
_auth: AuthContext = Depends(require_admin),
settings: Settings = Depends(get_settings),
) -> DaemonAdminResponse:
"""Re-activate a deactivated daemon. REQ-053 AC-7."""
repo = _get_repo(settings)
try:
existing = repo.get_daemon_by_id(daemon_id)
if existing is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
daemon = repo.update_daemon(daemon_id, is_active=True)
return _to_response(daemon)
except ValueError as exc:
raise _daemon_error_to_http(exc) from exc
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
# ---------------------------------------------------------------------------
# DELETE /v1/admin/daemons/{daemon_id} — 하드 삭제 미제공 → 405 (REQ-053 AC-8)
# ---------------------------------------------------------------------------
@router.delete("/daemons/{daemon_id}", status_code=status.HTTP_405_METHOD_NOT_ALLOWED)
def delete_daemon_not_allowed(
daemon_id: str,
_auth: AuthContext = Depends(require_admin),
) -> Response:
"""Hard delete is not supported — deactivate instead. REQ-053 AC-8."""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Hard delete not supported. Use POST /v1/admin/daemons/{daemon_id}/deactivate instead.",
)
+191
View File
@@ -0,0 +1,191 @@
"""GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned credential management (REQ-052)."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.auth import AuthContext, require_read
from app.core.config import Settings, get_settings
from app.core.encryption import encrypt_payload
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import CredentialMetaResponse, CredentialSetRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/daemons", tags=["v1"])
def _get_repo(settings=None):
from app.core.config import get_settings as _gs
return get_portfolio_repository_from_settings(settings or _gs())
def _require_user_token(auth: AuthContext) -> None:
"""Reject service-token access to credential management endpoints.
REQ-052 AC-10: service-token → 403 (service-token is read-only /v1/user_info only).
"""
if auth.token_source == "db" and auth.token_type == "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token cannot access credential management endpoints",
)
def _require_daemon_ownership(auth: AuthContext, daemon, daemon_id: str) -> None:
"""Check caller owns the daemon.
REQ-052 AC-6/AC-9: user_id != owner_user_id → 403.
admin role also subject to ownership check (AC-9).
"""
if auth.user_id is None or daemon.owner_user_id != auth.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Ownership scope violation: you do not own this daemon",
)
def _get_daemon_or_404(repo, daemon_id: str):
daemon = repo.get_daemon_by_id(daemon_id)
if daemon is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
return daemon
# ---------------------------------------------------------------------------
# GET /v1/daemons/{daemon_id}/credential — 메타데이터 조회 (REQ-052 AC-4)
# ---------------------------------------------------------------------------
@router.get("/{daemon_id}/credential", response_model=CredentialMetaResponse)
def get_credential_meta(
daemon_id: str,
auth: AuthContext = Depends(require_read),
settings: Settings = Depends(get_settings),
) -> CredentialMetaResponse:
"""Return credential metadata for caller-owned daemon. Secret never returned (REQ-052, REQ-054).
REQ-052 AC-4: 200 + metadata (broker, account_ref, credential_version, last_rotated_at, has_credential).
REQ-052 AC-7: 401 if no token.
REQ-052 AC-10: 403 if service-token.
REQ-052 AC-6/9: 403 if caller does not own daemon.
REQ-052 AC-8: 404 if daemon does not exist.
"""
_require_user_token(auth)
repo = _get_repo(settings)
try:
daemon = _get_daemon_or_404(repo, daemon_id)
_require_daemon_ownership(auth, daemon, daemon_id)
# Default broker is "kis" (single-broker assumption per design)
broker = "kis"
cred = repo.get_broker_credential_by_daemon(daemon_id, broker)
if cred is None:
return CredentialMetaResponse(
daemon_id=daemon_id,
broker=broker,
account_ref="",
has_credential=False,
credential_version=0,
last_rotated_at=None,
)
return CredentialMetaResponse(
daemon_id=daemon_id,
broker=cred.broker,
account_ref=cred.account_ref,
has_credential=True,
credential_version=cred.credential_version,
last_rotated_at=cred.last_rotated_at,
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
# ---------------------------------------------------------------------------
# PUT /v1/daemons/{daemon_id}/credential — 등록/회전 (REQ-052 AC-1/2)
# ---------------------------------------------------------------------------
@router.put("/{daemon_id}/credential", response_model=CredentialMetaResponse)
def set_credential(
daemon_id: str,
body: CredentialSetRequest,
auth: AuthContext = Depends(require_read),
settings: Settings = Depends(get_settings),
) -> CredentialMetaResponse:
"""Set or rotate broker credentials for caller-owned daemon.
Fernet-encrypts app_key/app_secret before storing.
Response contains metadata only — secret never returned (REQ-052 AC-1, REQ-054 AC-1).
REQ-052 AC-2: rotation increments credential_version.
"""
_require_user_token(auth)
repo = _get_repo(settings)
try:
daemon = _get_daemon_or_404(repo, daemon_id)
_require_daemon_ownership(auth, daemon, daemon_id)
# Fernet encrypt payload — REQ-054 AC-5: only encrypted stored in DB
enc_key = settings.require_credential_encryption_key()
# REQ-054 AC-4: log must not contain app_key/app_secret values
logger.info(
"credential set/rotate for daemon %s broker %s",
daemon_id,
body.broker,
)
payload_json = json.dumps({"app_key": body.app_key, "app_secret": body.app_secret})
encrypted = encrypt_payload(enc_key, payload_json)
cred = repo.upsert_broker_credential(
auth.user_id, # type: ignore[arg-type]
body.broker,
account_ref=body.account_ref,
encrypted_payload=encrypted,
daemon_id=daemon_id,
)
return CredentialMetaResponse(
daemon_id=daemon_id,
broker=cred.broker,
account_ref=cred.account_ref,
has_credential=True,
credential_version=cred.credential_version,
last_rotated_at=cred.last_rotated_at,
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
# ---------------------------------------------------------------------------
# DELETE /v1/daemons/{daemon_id}/credential — 삭제 (REQ-052 AC-5)
# ---------------------------------------------------------------------------
@router.delete("/{daemon_id}/credential", status_code=status.HTTP_204_NO_CONTENT)
def delete_credential(
daemon_id: str,
auth: AuthContext = Depends(require_read),
settings: Settings = Depends(get_settings),
) -> Response:
"""Delete broker credential for caller-owned daemon.
REQ-052 AC-5: 204 after deletion; 204 also if credential did not exist (idempotent).
"""
_require_user_token(auth)
repo = _get_repo(settings)
try:
daemon = _get_daemon_or_404(repo, daemon_id)
_require_daemon_ownership(auth, daemon, daemon_id)
# Default broker is "kis"
broker = "kis"
repo.delete_broker_credential(
auth.user_id, # type: ignore[arg-type]
broker,
daemon_id=daemon_id,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
+4
View File
@@ -11,7 +11,9 @@ from app.api.auth_routes import router as auth_router
from app.api.portfolios import router as portfolio_router
from app.api.strategy_sets import router as strategy_sets_router
from app.api.users import router as users_router
from app.api.v1_admin_daemons import router as v1_admin_daemons_router
from app.api.v1_assets import router as v1_assets_router
from app.api.v1_daemon_credential import router as v1_daemon_credential_router
from app.api.v1_daemons import router as v1_daemons_router
from app.api.v1_portfolios import router as v1_portfolios_router
from app.api.v1_tags import router as v1_tags_router
@@ -81,7 +83,9 @@ def create_app(
app.include_router(portfolio_router)
app.include_router(strategy_sets_router)
app.include_router(users_router)
app.include_router(v1_admin_daemons_router)
app.include_router(v1_assets_router)
app.include_router(v1_daemon_credential_router)
app.include_router(v1_daemons_router)
app.include_router(v1_portfolios_router)
app.include_router(v1_tags_router)
+15
View File
@@ -114,6 +114,21 @@ class PortfolioRepository(Protocol):
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]: ...
def get_broker_credential_by_daemon(
self,
daemon_id: str,
broker: str,
) -> BrokerCredential | None: ...
def delete_credentials_by_daemon_id(self, daemon_id: str) -> int: ...
def update_daemon_owner(
self,
daemon_id: str,
*,
owner_user_id: str,
) -> Daemon: ...
def get(self, portfolio_id: str) -> Portfolio: ...
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: ...
+85
View File
@@ -2343,6 +2343,91 @@ class PostgresPortfolioRepository:
)
return cur.rowcount
def get_broker_credential_by_daemon(
self,
daemon_id: str,
broker: str,
) -> BrokerCredential | None:
"""Return a single BrokerCredential by (daemon_id, broker) PK, or None if absent (REQ-052)."""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE daemon_id = %s AND broker = %s
""",
(daemon_id, broker),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_broker_credential(row)
def delete_credentials_by_daemon_id(self, daemon_id: str) -> int:
"""Delete ALL broker credentials for daemon_id (owner re-assignment invalidation — REQ-053 AC-4).
Returns number of rows deleted.
"""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s",
(daemon_id,),
)
return cur.rowcount
def update_daemon_owner(
self,
daemon_id: str,
*,
owner_user_id: str,
) -> Daemon:
"""Change daemon owner and invalidate all existing BrokerCredentials (REQ-053 AC-4).
Owner change and credential invalidation are executed atomically in a single
transaction (BAT-022 QC Major fix). If either statement fails the entire
transaction is rolled back, preventing the state where the owner has changed but
the previous owner's credentials remain (credential leak — dcy-011 OQ-3).
Execution order inside the transaction:
1. DELETE FROM broker_credentials WHERE daemon_id = %s (credential first)
2. UPDATE daemons SET owner_user_id = %s ... (owner second)
This order is also safe if the transaction machinery is somehow bypassed: if
only step 1 completes the owner is unchanged and credentials are gone (no leak);
if only step 2 completes the old credentials are still present but the caller
still owns them (no cross-owner leak).
"""
self.ensure_schema()
conn = self._connect()
# Use conn.transaction() (not `with conn:`) so the shared connection stays open
# after the block (psycopg3 closes the connection when used as a context manager).
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM daemons WHERE id = %s",
(daemon_id,),
)
if cur.fetchone() is None:
raise ValueError("데몬을 찾을 수 없습니다")
# Step 1: delete credentials first (safe-order)
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s",
(daemon_id,),
)
# Step 2: update owner
cur.execute(
"""
UPDATE daemons
SET owner_user_id = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
""",
(owner_user_id, daemon_id),
)
row = cur.fetchone()
return self._row_to_daemon(row)
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
self.ensure_schema()
with self._connect().cursor() as cur:
+49
View File
@@ -326,3 +326,52 @@ class DaemonResponse(BaseModel):
class DaemonListResponse(BaseModel):
daemons: list[DaemonResponse]
total: int
# ---------------------------------------------------------------------------
# GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (REQ-052)
# ---------------------------------------------------------------------------
class CredentialSetRequest(BaseModel):
"""Credential write request — app_key/app_secret/account_ref inputs only.
Fernet-encrypted server-side; secret never returned in any response.
"""
app_key: str = Field(..., min_length=1)
app_secret: str = Field(..., min_length=1)
account_ref: str = Field(..., min_length=1)
broker: str = Field(default="kis", min_length=1)
class CredentialMetaResponse(BaseModel):
"""Credential metadata response — secret fields intentionally absent (REQ-052, REQ-054)."""
daemon_id: str
broker: str
account_ref: str
has_credential: bool
credential_version: int
last_rotated_at: datetime | None = None
# ---------------------------------------------------------------------------
# POST/PATCH /v1/admin/daemons (REQ-053)
# ---------------------------------------------------------------------------
class DaemonCreateRequest(BaseModel):
daemon_slug: str = Field(..., min_length=1)
display_name: str | None = None
owner_user_id: str = Field(..., min_length=1)
class DaemonUpdateRequest(BaseModel):
display_name: str | None = None
owner_user_id: str | None = None
class DaemonAdminResponse(BaseModel):
"""Admin daemon response — no credential fields (REQ-054)."""
id: str
daemon_slug: str
display_name: str | None = None
owner_user_id: str
is_active: bool
@@ -1,7 +1,7 @@
# BAT-021 Index
- Profile: standard
- Status: in-progress
- Status: release-candidate
- Included REQ: REQ-047
- Discovery: dcy-010
- Created: 2026-06-28
@@ -20,6 +20,11 @@ BAT-020(PR #17, d4acfd4)에서 Step1/Step2 마이그레이션 revision 코드가
4. 운영 적용 런북 작성
5. 신규/갱신 단위 테스트 추가
## Notes
- PR #18 머지(mergeCommit 7164f9e), 실 dev PG 통합검증 AC-1~7 PASS, QC(opus) PASS-WITH-NOTES(blocker 0). REQ-047 confirm-req-implemented 완료.
- **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트 잔여.** 런북: `docs/runbooks/bat-021-req047-step2-migration-runbook.md`, 배포순서 Step1→backfill→Step2→코드배포 엄수, alembic stamp 사전확인.
## Documents
- [planning.md](./planning.md)
@@ -0,0 +1,93 @@
# BAT-022 Design
- Status: confirmed
- Batch: BAT-022
## OQ-1 해소: 인증정보 관리 엔드포인트 경로 (REQ-052)
**확정: `/v1/daemons/{daemon_id}/credential`**
근거:
- v1 컨벤션: `/v1/daemons/{daemon_id}`가 daemon 리소스 계층 루트. credential은 daemon의 하위 리소스이므로 중첩 경로가 REST 의미에 맞는다.
- 소유 스코프 자연성: daemon_id가 경로에 있어 소유 검증(`daemon.owner_user_id == auth.user_id`) 경로 파라미터에서 바로 가능.
- dcy-009 UI 흐름: 사용자가 자기 데몬을 선택한 뒤 인증정보를 관리하는 흐름과 일치.
- 기존 `/v1/daemons` GET이 daemon 계층을 쓰므로 일관성 유지.
메서드:
- `GET /v1/daemons/{daemon_id}/credential` — 메타데이터 조회 (has_credential, version, last_rotated_at)
- `PUT /v1/daemons/{daemon_id}/credential` — 등록/회전 (upsert, 응답 메타데이터만)
- `DELETE /v1/daemons/{daemon_id}/credential` — 삭제 (204)
브로커 지정: PUT 요청 body에 `broker` 필드(기본값 `"kis"`). 현재 단일 broker 가정이므로 경로에 broker를 두지 않는다. 다중 broker 지원 필요 시 `/v1/daemons/{daemon_id}/credentials/{broker}` 로 확장 경로로 분리 가능(하위 호환).
## OQ-2 해소: admin 데몬 CRUD 경로 (REQ-053)
**확정: `/v1/admin/daemons`**
근거:
- dcy-006 패턴: `/v1/admin/...` prefix를 admin 전용 관리 API의 네임스페이스로 일관되게 사용한다. dcy-006에서 ledger 회계 통합 경로는 `/v1/portfolios/...`를 사용했으나, admin 전용 mutate는 다른 서비스(users.py 등)도 `/users`에 admin 게이트를 두는 방식. 여기서 `/v1/daemons` 기존 경로에 POST/PATCH를 추가하면 GET(caller-scoped, REQ-045)과 POST(admin-only, REQ-053)가 같은 경로에 혼재되어 권한 경계가 불명확해진다.
- `/v1/admin/daemons`는 admin 전용 write 작업임을 경로 자체로 명시.
- trex-web dcy-009 소비자가 admin 경로를 별도 컨텍스트에서 관리하기 쉽다.
메서드:
- `POST /v1/admin/daemons` → 201 DaemonAdminResponse (생성)
- `PATCH /v1/admin/daemons/{daemon_id}` → 200 DaemonAdminResponse (display_name 변경, owner 재할당)
- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 DaemonAdminResponse
- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 DaemonAdminResponse
- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (명시적 미제공)
## Architecture Summary
### 신규 파일
| 파일 | 역할 |
| --- | --- |
| `app/api/v1_admin_daemons.py` | REQ-053 admin daemon CRUD 라우터 |
| `app/api/v1_daemon_credential.py` | REQ-052 소유 스코프 인증정보 관리 라우터 |
### 변경 파일
| 파일 | 변경 내용 |
| --- | --- |
| `app/schemas/v1.py` | REQ-052 응답/요청 스키마 + REQ-053 응답/요청 스키마 추가 |
| `app/repositories/postgres.py` | `get_broker_credential_by_daemon`, `delete_credentials_by_daemon_id`, `update_daemon_owner` 추가 |
| `app/repositories/portfolio_repository.py` | Protocol에 신규 메서드 추가 |
| `app/main.py` | 신규 라우터 include |
### 신규 테스트 파일
| 파일 | 커버 |
| --- | --- |
| `tests/test_bat022_req052_credential_api.py` | REQ-052 AC-1~10 |
| `tests/test_bat022_req053_admin_daemon_crud.py` | REQ-053 AC-1~12 |
| `tests/test_bat022_req054_security.py` | REQ-054 AC-1~7 |
## Key Decisions
1. **소유 스코프 게이트 위치**: 라우터 내에서 daemon 조회 후 `daemon.owner_user_id != auth.user_id` 즉시 403. 서비스 레이어 분리 없이 라우터에서 직접 처리(기존 v1_portfolios.py 패턴 동일).
2. **service-token 403 (REQ-052 AC-10)**: `auth.token_source == "db" and auth.token_type == "service"` 이면 403. env-token(token_source="env")은 token_type이 None이므로 이 조건에 해당하지 않는다. env admin token으로 credential 관리는 허용하지 않는다(user_id가 없으므로 소유 스코프 검사에서 403 발생).
3. **admin도 타인 credential write 403 (REQ-052 AC-9)**: admin role이라도 `auth.user_id != daemon.owner_user_id`이면 403. 인증정보 소유 스코프는 admin role을 우선시하지 않는다. admin은 REQ-053(`/v1/admin/daemons`)으로 데몬 provisioning만 담당.
4. **owner 재할당 + credential 무효화 (REQ-053 AC-4)**: `update_daemon_owner()`에서 먼저 daemon의 owner를 업데이트하고 이후 `DELETE FROM broker_credentials WHERE daemon_id = %s` 호출. autocommit 모드이므로 두 연산이 별도. 인증정보 삭제 실패는 로그 경고 후 무시(데몬 업데이트는 이미 완료).
5. **DELETE 405 (REQ-053 AC-8)**: `@router.delete("/v1/admin/daemons/{daemon_id}")` 핸들러를 명시 등록해 HTTP 405를 반환. FastAPI 기본은 미등록 DELETE → 405 (Method Not Allowed)이나 명시 등록이 더 안전.
6. **slug 중복 → 409 (REQ-053 AC-2)**: postgres.py create_daemon에서 UniqueViolation → ValueError 변환. 라우터에서 ValueError 메시지 패턴 매칭으로 409 반환.
7. **DELETE credential 204 (REQ-052 AC-5)**: 인증정보가 없어도 204 반환(멱등). 있으면 삭제 후 204.
8. **브로커 경로 단순화**: PUT body에 `broker: str = "kis"`. GET/DELETE는 기본 broker(현재 단일 broker 가정).
## Changed Areas
- app/api/v1_admin_daemons.py — 신규
- app/api/v1_daemon_credential.py — 신규
- app/schemas/v1.py — CredentialSetRequest, CredentialMetaResponse, DaemonCreateRequest, DaemonUpdateRequest, DaemonAdminResponse 추가
- app/repositories/postgres.py — get_broker_credential_by_daemon, delete_credentials_by_daemon_id, update_daemon_owner 추가
- app/repositories/portfolio_repository.py — Protocol 동기화
- app/main.py — 라우터 include
- tests/test_bat022_req052_credential_api.py — 신규
- tests/test_bat022_req053_admin_daemon_crud.py — 신규
- tests/test_bat022_req054_security.py — 신규
@@ -0,0 +1,59 @@
# BAT-022 Implementation
- Status: completed
- Commit: 6a0e14d
## 변경 파일 목록
### 신규 파일
| 파일 | 역할 |
| --- | --- |
| `app/api/v1_admin_daemons.py` | REQ-053 admin daemon CRUD 라우터 (`/v1/admin/daemons`) |
| `app/api/v1_daemon_credential.py` | REQ-052 소유 스코프 credential 관리 라우터 (`/v1/daemons/{id}/credential`) |
| `tests/test_bat022_req052_credential_api.py` | REQ-052 AC-1~10 테스트 (17 케이스) |
| `tests/test_bat022_req053_admin_daemon_crud.py` | REQ-053 AC-1~12 테스트 (15 케이스) |
| `tests/test_bat022_req054_security.py` | REQ-054 AC-1~7 + REQ-051 회귀 (12 케이스) |
### 변경 파일
| 파일 | 변경 내용 |
| --- | --- |
| `app/schemas/v1.py` | `CredentialSetRequest`, `CredentialMetaResponse`, `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 추가 |
| `app/repositories/postgres.py` | `get_broker_credential_by_daemon`, `delete_credentials_by_daemon_id`, `update_daemon_owner` 추가 |
| `app/repositories/portfolio_repository.py` | Protocol에 신규 3개 메서드 추가 |
| `app/main.py` | `v1_admin_daemons_router`, `v1_daemon_credential_router` include |
## REQ별 구현 요약
### REQ-052 (소유 스코프 인증정보 관리 API)
- `GET /v1/daemons/{daemon_id}/credential` — 소유 스코프 검증 후 BrokerCredential 메타데이터 반환 (has_credential, version, last_rotated_at). secret 없음.
- `PUT /v1/daemons/{daemon_id}/credential` — `app_key`/`app_secret` Fernet 암호화 후 `upsert_broker_credential(daemon_id=...)` 호출. 응답 메타데이터만.
- `DELETE /v1/daemons/{daemon_id}/credential` — `delete_broker_credential(daemon_id=...)` 호출, 204 반환(멱등).
- 소유 스코프 게이트: `daemon.owner_user_id != auth.user_id` → 403. admin role도 동일 검사(AC-9).
- service-token 거부: `token_source == "db" and token_type == "service"` → 403(AC-10).
- 기존 `upsert_broker_credential`, `delete_broker_credential`(dcy-010 자산) 재사용. CLI와 동일 repo 메서드.
### REQ-053 (admin 데몬 CRUD API)
- `POST /v1/admin/daemons` → 기존 `create_daemon()` 재사용, 201.
- `PATCH /v1/admin/daemons/{id}` → owner 변경 시 `update_daemon_owner()`(새 메서드), display_name만 변경 시 기존 `update_daemon()`.
- `POST .../deactivate` → `update_daemon(is_active=False)` + `revoke_tokens_by_daemon_id()`(REQ-022 재사용).
- `POST .../activate` → `update_daemon(is_active=True)`.
- `DELETE .../` → 명시적 405 핸들러 등록.
- `require_admin` dependency 사용 (기존 `app/core/auth.py`).
### REQ-054 (보안 격리)
- 응답 스키마: `CredentialMetaResponse`/`DaemonAdminResponse` 모두 secret 필드 부재.
- 로그: `PUT credential` 처리 시 broker/daemon_id만 기록 (app_key/app_secret 값 미포함).
- Fernet at-rest: `encrypt_payload()` 호출 후 `upsert_broker_credential()` — 평문 미저장(AC-5).
- REQ-051 decrypt 경로 불변: `/v1/user_info` 코드 무변경, 회귀 4개 테스트 PASS(AC-7).
## 설계 결정 트레이스 (design.md 참조)
- OQ-1 해소: `/v1/daemons/{daemon_id}/credential` — daemon-scoped, REST 정합.
- OQ-2 해소: `/v1/admin/daemons` — admin 전용 prefix, 기존 GET /v1/daemons(caller-scoped)와 분리.
- service-token 403: `token_source == "db" and token_type == "service"` 조건. env-token은 token_type=None이므로 해당 없음.
- admin + 소유 스코프: admin role도 ownership 검사 통과 → 403(REQ-052 AC-9).
@@ -0,0 +1,47 @@
# BAT-022 — daemon CRUD admin API + self-service 인증정보 관리 API
- Batch ID: BAT-022
- Profile: standard
- Status: draft-batch-verification
- Discovery: dcy-011
- Included REQ: REQ-052, REQ-053, REQ-054
- Folder: docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/
- Created: 2026-06-28
## Summary
dcy-011(daemon admin CRUD + 사용자 self-service 브로커 인증정보 관리 API)을 portfolio-hub에 구현한다.
- REQ-052: user-token + 소유 스코프 인증정보 set/rotate/delete + 메타데이터 GET (`/v1/daemons/{daemon_id}/credential`)
- REQ-053: admin 데몬 생성·owner 할당·변경·(비)활성화 (`/v1/admin/daemons`)
- REQ-054: 모든 관리 API 응답/로그에 app_key/app_secret 평문 미포함(보안 격리) + REQ-051 회귀 PASS
dcy-010 자산(BrokerCredential 모델, Fernet 암호화, upsert/delete 메서드) 재사용. CLI와 동일 repo 메서드 공유.
## Included REQ
| REQ | 제목 | Type | 의존 |
| --- | --- | --- | --- |
| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | dcy-010 자산(BrokerCredential, Fernet) |
| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Daemon 모델, REQ-022(service token revoke) |
| REQ-054 | 인증정보 관리 보안 격리 | Non-Functional | REQ-052, REQ-053, REQ-051 |
## DoR (진입 조건)
- [x] REQ-052/053/054 전건 Approved 상태 확인
- [x] dcy-011 confirmed Discovery 확인(OQ-1/OQ-2 Deferred → design 단계 해소)
- [x] 구현 대상 파일 존재 확인: app/repositories/postgres.py, app/domain/models.py, app/core/auth.py, app/core/encryption.py
- [x] dcy-010 자산 존재 확인: BrokerCredential PK (daemon_id, broker), upsert_broker_credential, list_broker_credentials, delete_broker_credential
## DoD (완료 조건)
- [x] REQ-052 AC-1~10 테스트 통과 (17 케이스)
- [x] REQ-053 AC-1~12 테스트 통과 (15 케이스)
- [x] REQ-054 AC-1~7 테스트 통과 (12 케이스, caplog 비노출, 응답 secret 부재, REQ-051 회귀)
- [x] 기존 521 테스트 회귀 없음 (565 passed, 0 failures)
- [x] draft-batch-verification 문서에 AC별 evidence 기록
## 비가역 결재 게이트
- DB 마이그레이션: 신규 DDL 없음. 기존 ensure_schema() + postgres.py 메서드 확장으로 처리 (비가역 없음).
- HTTP API 계약 신설: trex-web dcy-009가 소비 예정. 이 batch는 계약 신설이므로 기존 소비자에 영향 없음(호환 깨짐 없음).
@@ -0,0 +1,85 @@
# BAT-022 Planning
## 구현 의존 순서
1. **REQ-052/053 스키마** — `app/schemas/v1.py`에 `CredentialSetRequest`, `CredentialMetaResponse`, `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 추가
2. **REQ-053 repository 확장** — `postgres.py`에 `update_daemon_owner()` (owner 변경 + 인증정보 자동 삭제) 신규. `update_daemon()` 재사용 가능한 부분은 그대로.
3. **REQ-052 repository 확장** — `postgres.py`에 `get_broker_credential_meta()` (단일 credential 조회, user_id 소유 검증 포함) 추가. 기존 `upsert_broker_credential`, `delete_broker_credential` 재사용.
4. **REQ-053 라우터** — `app/api/v1_admin_daemons.py` 신규. `POST /v1/admin/daemons`, `PATCH /v1/admin/daemons/{daemon_id}`, `POST /v1/admin/daemons/{daemon_id}/activate`, `POST /v1/admin/daemons/{daemon_id}/deactivate`. `require_admin` 사용.
5. **REQ-052 라우터** — `app/api/v1_daemon_credential.py` 신규. `GET /v1/daemons/{daemon_id}/credential`, `PUT /v1/daemons/{daemon_id}/credential`, `DELETE /v1/daemons/{daemon_id}/credential`. `require_read` + 소유 스코프 검사.
6. **REQ-052/053/054 테스트** — `tests/test_bat022_*.py` 신규 (AC별 pytest 케이스)
7. **REQ-054 보안 격리 테스트** — caplog, 응답 JSON 필드 열거, REQ-051 회귀
## Task Breakdown
### T-1: 스키마 추가 (app/schemas/v1.py)
**REQ-052 스키마:**
- `CredentialSetRequest` — `app_key: str`, `app_secret: str`, `account_ref: str`, `broker: str = "kis"`
- `CredentialMetaResponse` — `daemon_id: str`, `broker: str`, `account_ref: str`, `has_credential: bool`, `credential_version: int`, `last_rotated_at: datetime | None` (secret 필드 없음)
**REQ-053 스키마:**
- `DaemonCreateRequest` — `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str`
- `DaemonUpdateRequest` — `display_name: str | None`, `owner_user_id: str | None`
- `DaemonAdminResponse` — `id: str`, `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str`, `is_active: bool` (credential 필드 없음)
### T-2: repository 메서드 추가 (app/repositories/postgres.py + protocol)
- `get_broker_credential_by_daemon` — `(daemon_id, broker)` 로 단일 BrokerCredential 조회(없으면 None). 소유 검증은 서비스/라우터 레이어에서.
- `delete_credentials_by_daemon_id` — daemon_id에 속한 모든 BrokerCredential 삭제(owner 재할당 시 무효화용). REQ-053 AC-4 지원.
- `update_daemon_owner` — owner_user_id 변경 + 해당 daemon의 모든 broker_credentials 삭제 (단일 메서드 atomic). owner 변경 없이 display_name만 변경하면 기존 `update_daemon()` 재사용.
- Protocol(`portfolio_repository.py`) 동기화.
### T-3: admin daemon 라우터 (app/api/v1_admin_daemons.py)
경로: `/v1/admin/daemons`
- `POST /v1/admin/daemons` → `DaemonCreateRequest` → 201 + `DaemonAdminResponse`
- 중복 slug → 409 Conflict
- 비admin/service-token → 403, 비인증 → 401
- `PATCH /v1/admin/daemons/{daemon_id}` → `DaemonUpdateRequest` → 200 + `DaemonAdminResponse`
- owner 변경 시 `update_daemon_owner()` 호출(인증정보 자동 무효화)
- owner 변경 없으면 `update_daemon(display_name=...)` 호출
- 미존재 → 404
- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 + `DaemonAdminResponse`
- `update_daemon(is_active=False)` + `revoke_tokens_by_daemon_id()` (REQ-022 재사용)
- 미존재 → 404
- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 + `DaemonAdminResponse`
- `update_daemon(is_active=True)` 호출
- 미존재 → 404
- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (하드 삭제 미제공)
### T-4: credential 라우터 (app/api/v1_daemon_credential.py)
경로: `/v1/daemons/{daemon_id}/credential`
공통 소유 스코프 게이트:
- 비인증(토큰 없음) → 401 (require_read가 처리)
- service-token(`token_type == "service"`) → 403
- auth.user_id가 daemon.owner_user_id와 불일치 → 403
- 미존재 daemon_id → 404
엔드포인트:
- `GET /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse`
- 인증정보 없으면 `has_credential: false` + 나머지 null
- `PUT /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse`
- `app_key`/`app_secret` Fernet 암호화 → `upsert_broker_credential()` 호출
- 응답에 app_key/app_secret 미포함
- `DELETE /v1/daemons/{daemon_id}/credential` → 204 No Content (인증정보 없어도 204)
### T-5: main.py 라우터 등록
- `v1_admin_daemons_router`, `v1_daemon_credential_router` include
### T-6: 테스트 신규 (tests/test_bat022_*.py)
- `test_bat022_req052_credential_api.py` — REQ-052 AC-1~10 (소유 스코프 음성 포함)
- `test_bat022_req053_admin_daemon_crud.py` — REQ-053 AC-1~12 (owner 재할당 + 인증정보 무효화, DELETE 405 포함)
- `test_bat022_req054_security.py` — REQ-054 AC-1~7 (caplog, secret 필드 부재, REQ-051 회귀)
## Risks / Constraints
- service-token 403 (REQ-052 AC-10): `token_type == "service"` 체크는 `auth.token_source == "db"` 전제. env-token은 `token_type=None`이므로 별도 분기 필요.
- admin role 검증 (REQ-053): `require_admin`은 기존 permission hierarchy(admin > trade-write > read)를 사용. env admin 토큰도 처리.
- owner 변경 + 인증정보 무효화(REQ-053 AC-4): `update_daemon_owner()`는 단일 DB 트랜잭션이 아님(psycopg3 autocommit). partial failure(daemon 업데이트 성공 + 인증정보 삭제 실패)는 드문 케이스이나 로그 경고 추가.
- DELETE 405 (REQ-053 AC-8): FastAPI에서 미등록 경로의 DELETE는 404를 반환. 명시적 405 반환하려면 라우터에 `DELETE` 핸들러를 405 고정으로 등록해야 함.
@@ -0,0 +1,92 @@
# BAT-022 Verification
- Status: draft
- Batch: BAT-022
- Verification Date: 2026-06-28
## 테스트 실행 결과
### 신규 테스트 (44 케이스)
```
tests/test_bat022_req052_credential_api.py — 17 passed
tests/test_bat022_req053_admin_daemon_crud.py — 15 passed
tests/test_bat022_req054_security.py — 12 passed
```
### 전체 테스트 스위트
```
565 passed, 11 warnings in 32.53s
```
기존 521 테스트 회귀: **0건** (521 → 565, +44 신규만 추가)
## REQ-052 AC별 검증 Evidence
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: PUT → 200 + 메타데이터, secret 없음 | `test_put_credential_returns_meta_without_secret` | PASS |
| AC-2: 회전 시 credential_version 증가 | `test_put_credential_rotation_increments_version` | PASS |
| AC-3: CLI·API·service-token 정합 (upsert daemon_id 전달) | `test_put_credential_upsert_called_with_correct_daemon_id` | PASS |
| AC-4: GET → 200 + 메타데이터, secret 없음 | `test_get_credential_meta_returns_metadata_without_secret` | PASS |
| AC-5: DELETE → 204 + 이후 has_credential=False | `test_delete_credential_returns_204`, `test_get_after_delete_returns_has_credential_false` | PASS |
| AC-6 (음성): 타 사용자 토큰 → 403 | `test_put/get/delete_credential_other_user_returns_403` (3개) | PASS |
| AC-7: 비인증 → 401 | `test_credential_no_token_returns_401` | PASS |
| AC-8: 미존재 daemon → 404 | `test_credential_nonexistent_daemon_returns_404`, `test_put_nonexistent_daemon_returns_404` | PASS |
| AC-9 (음성): admin으로 타인 daemon write → 403 | `test_admin_cannot_write/read_other_user_credential` (2개) | PASS |
| AC-10 (음성): service-token → 403 | `test_service_token_credential_get/put/delete_returns_403` (3개) | PASS |
## REQ-053 AC별 검증 Evidence
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: POST 생성 → 201 + DaemonAdminResponse | `test_admin_create_daemon_returns_201` | PASS |
| AC-2: 중복 slug → 409 | `test_admin_create_daemon_duplicate_slug_returns_409` | PASS |
| AC-3: display_name 변경 → BrokerCredential 유지 | `test_admin_patch_display_name_credential_preserved` | PASS |
| AC-4: owner 재할당 → BrokerCredential 자동 삭제 | `test_admin_patch_owner_invalidates_credentials` | PASS |
| AC-5: 재할당 후 이전 owner 인증정보 무효화 | `test_admin_patch_owner_then_old_owner_loses_credential` | PASS |
| AC-6: 비활성화 → is_active=False + service token revoke | `test_admin_deactivate_daemon_revokes_tokens` | PASS |
| AC-7: 재활성화 → is_active=True | `test_admin_activate_daemon_returns_active` | PASS |
| AC-8 (음성): DELETE → 405 | `test_admin_delete_daemon_returns_405` | PASS |
| AC-9 (음성): user-token(비admin) → 403 | `test_non_admin_user_token_create/patch/deactivate` (3개) | PASS |
| AC-10 (음성): service-token → 403 | `test_service_token_admin_crud_returns_403` | PASS |
| AC-11: 비인증 → 401 | `test_admin_crud_no_token_returns_401` | PASS |
| AC-12: 미존재 daemon → 404 | `test_admin_patch/deactivate_nonexistent_daemon_returns_404` (2개) | PASS |
## REQ-054 AC별 검증 Evidence
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: PUT 응답에 app_key/app_secret 없음 | `test_put_credential_response_has_no_secret_fields` | PASS |
| AC-2: GET 응답에 app_key/app_secret 없음 | `test_get_credential_meta_response_has_no_secret_fields` | PASS |
| AC-3: admin CRUD 응답에 credential 필드 없음 | `test_admin_create/patch_response_has_no_credential_fields` (2개) | PASS |
| AC-4: caplog — PUT 처리 중 app_key/app_secret 값 미포함 | `test_put_credential_log_does_not_expose_secret` | PASS |
| AC-5: Fernet 암호화 저장 확인 (gAAAA... 형태) | `test_put_credential_stores_encrypted_payload` | PASS |
| AC-6: PUT 후 service-token user_info → 동일 account_ref | `test_put_then_service_token_user_info_returns_correct_account_ref` | PASS |
| AC-7: REQ-051 회귀 (4개 테스트) | `test_req051_regression_*` (4개) | PASS |
## 음성 테스트 체크리스트
| 음성 시나리오 | 케이스 수 | 결과 |
| --- | --- | --- |
| 소유 스코프 403 (AC-6: 타 사용자 토큰) | 3 | PASS |
| admin 소유 스코프 403 (AC-9) | 2 | PASS |
| service-token 403 (AC-10, REQ-052) | 3 | PASS |
| service-token 403 (AC-10, REQ-053) | 1 | PASS |
| DELETE 405 (AC-8, REQ-053) | 1 | PASS |
| secret 필드 부재 (AC-1/2/3, REQ-054) | 4 | PASS |
| caplog secret 미노출 (AC-4, REQ-054) | 1 | PASS |
## 회귀 테스트
- REQ-051 AC-1~3, AC-5: 신규 API 추가 후 GET /v1/user_info 보안 격리 불변 확인 (4개 테스트 PASS)
- 기존 521 테스트 전건 회귀 없음
## 잔여 위험 / 미해결
1. **AC-3 통합 수준**: REQ-052 AC-3(CLI·API·service-token 3경로 정합)은 이 batch에서 unit-level로 검증(upsert daemon_id 전달 확인). 실 DB 통합 검증은 dev DB 환경 필요(비가역 아님, 선택적 추후 확인 가능).
2. **DELETE credential 기본 broker "kis"**: GET/DELETE 에서 broker 파라미터 없이 "kis" 고정. 다중 broker 지원이 필요해지면 경로 파라미터 추가 필요 — 현재 단일 broker 가정 설계 일치.
3. **update_daemon_owner autocommit 부분 실패**: postgres.py `update_daemon_owner()`는 daemon UPDATE + credential DELETE를 autocommit 2회 연산으로 처리. daemon 업데이트 성공 후 credential 삭제 실패 케이스는 테스트 범위 밖(네트워크 장애 등). 실 운영에서 발생 시 수동 정리 필요.
+1
View File
@@ -29,3 +29,4 @@
| BAT-019 | standard | confirmed | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) |
| BAT-020 | standard | release-candidate | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) |
| BAT-021 | standard | in-progress | REQ-047 | dcy-010 | [bat-021](./bat-021_20260628_req047-step2-integration-and-runbook/) |
| BAT-022 | standard | draft-batch-verification | REQ-052, REQ-053, REQ-054 | dcy-011 | [bat-022](./bat-022_20260628_daemon-crud-self-service-credential-api/) |
@@ -0,0 +1,196 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-011_20260628_daemon-crud-and-self-service-credential-api
- 이슈명: 데몬 admin CRUD API + 사용자 self-service 브로커 인증정보 관리 API — 소유 스코프 write/rotate/메타데이터(복호화 secret 미반환), 데몬 생성·할당·(비)활성화는 admin. trex-web dcy-009 백엔드 (교차 발원)
- 작성 시각(KST): 2026-06-28
- 마지막 갱신 시각(KST): 2026-06-28 (REQ 초안 생성)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조:
- REQ-052: 사용자 소유 스코프 인증정보 관리 HTTP API ([docs/srs/Interface/req-052_user-owned-credential-management-api.md](../srs/Interface/req-052_user-owned-credential-management-api.md))
- REQ-053: admin 데몬 CRUD HTTP API ([docs/srs/Interface/req-053_admin-daemon-crud-api.md](../srs/Interface/req-053_admin-daemon-crud-api.md))
- REQ-054: 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 ([docs/srs/Non-Functional/req-054_credential-management-security-write-only.md](../srs/Non-Functional/req-054_credential-management-security-write-only.md))
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: GitHub org 보드 fourblocks2020/trex #7 — "portfolio-hub 데몬 레지스트리/관리 API", "서비스 토큰/데몬 발급·관리". **교차 발원 = trex-web `dcy-009`**(사용자 self-service 인증정보 관리 + admin 데몬 provisioning UI). 선행 = `dcy-010`(daemon-scoped 브로커 인증정보 모델/보안), `dcy-004`(토큰·권한 모델)
- 해석:
- trex-web dcy-009가 정의한 기능(사용자가 자기 데몬 KIS 인증정보를 self-service 관리, admin이 데몬 provisioning)을 받쳐줄 portfolio-hub 백엔드 API가 없다. 현재 데몬은 read-only(`/v1/daemons`), 인증정보는 CLI(`pfhub credential`) 전용이며 복호화 read는 service-token `/v1/user_info`뿐이다.
- 핵심 판단: ① **사용자 소유 스코프** 인증정보 관리 API(write/rotate/delete + 메타데이터, **복호화 secret 절대 미반환**), ② **admin 데몬 CRUD** API(생성·할당·(비)활성화)를 dcy-004 권한 모델 위에 어떻게 설계하고, service-token decrypt 경로(REQ-051)와 어떻게 분리·불변 유지할지.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 기존 `/v1/daemons`는 read 전용이고, 인증정보 관리는 CLI 전용이다. **HTTP 기반 소유 스코프 인증정보 관리 API**와 **admin 데몬 CRUD API**는 정의된 바 없다.
- dcy-010은 인증정보 *모델/저장*(CLI·service-token read)을 다뤘고, 본 건은 *사용자/admin HTTP 관리 표면*을 신설하므로 신규 주기로 추적한다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다.
- 현재 상태
- `BrokerCredential`(dcy-010): PK `(daemon_id, broker)`, `user_id` 보유, Fernet 암호화 `{app_key, app_secret}`. 관리=CLI(`pfhub credential set/list/remove --daemon-id`). 복호화 read=**service-token 전용 `GET /v1/user_info`**(REQ-051, web/user-token 403).
- `Daemon`(id, owner_user_id, daemon_slug, display_name, is_active): read API `GET /v1/daemons`만. 생성·변경·(비)활성화 HTTP API 없음.
- 권한 모델(dcy-004): user-token(권한 read/write), admin role, service-token(daemon_id 바인딩). 소유 스코프 인가 선례 = dcy-009 trex-web 포트폴리오/REQ-049 `managed_by_daemon_id` 일치.
- 암호화 유틸(`app/core/encryption.py` Fernet, `CREDENTIAL_ENCRYPTION_KEY`) 존재.
- 기대 상태
- **사용자(소유 스코프)**: HTTP로 자기 소유 데몬의 인증정보를 등록·회전·삭제하고 메타데이터(broker, account_ref, version, last_rotated, 보유 여부)를 조회. **자기 것만**.
- **admin**: HTTP로 데몬을 생성·사용자 할당·변경·(비)활성화.
- **복호화된 app_secret은 user-token/admin 어떤 응답에도 포함되지 않는다.** service-token decrypt 경로(REQ-051)는 불변.
- 이해관계자
- 사장님(보안·권한·결재), 팀장(거버넌스), portfolio-hub-dev(구현), 일반 사용자(self-service 소비, trex-web 경유), admin/운영자(데몬 provisioning), trex-web 개발(dcy-009 소비측)
- 제약사항
- **최민감 시크릿(KIS app_secret = 실계좌·실자금)** 복호화 평문이 user-token/admin 응답·로그에 노출 금지. service-token `/v1/user_info`(REQ-051)가 유일 decrypt-return 경로 — 불변.
- **소유 스코프 인가 엄격**: 사용자는 자기 소유 데몬(owner_user_id==요청자)의 인증정보만. 타인 접근 403.
- 데몬은 운영 런타임 식별자 → 생성·(비)활성화 영향 큼. 삭제는 dcy-009 결정대로 제외(비활성화).
- CLI(`pfhub credential`)와 신규 API의 동작 정합(동일 repository·암호화 자산 재사용).
## 주요 사용자 시나리오
- admin이 `POST /v1/daemons`(또는 동등)로 데몬 D를 생성하고 owner=사용자 U로 할당한다.
- 사용자 U가 (trex-web 경유, user-token) 자기 데몬 D의 인증정보를 등록(app_key/app_secret) → 서버 Fernet 암호화 저장. 응답엔 secret 미포함, account_ref·version만.
- 사용자 V가 사용자 U의 데몬 D 인증정보 조회/수정 요청 → 403(소유 스코프).
- 데몬 D 인스턴스는 service-token으로 `/v1/user_info`에서 복호화 인증정보 fetch(기존 경로 불변).
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목.
- (C1) **사용자 소유 스코프 인증정보 관리 API** — 자기 소유 데몬 인증정보 set/rotate/delete + 메타데이터 GET. user-token + ownership 게이트. **복호화 secret 미반환**. 암호화·저장은 dcy-010 자산 재사용.
- (C2) **admin 데몬 CRUD API** — 데몬 생성·owner 할당·변경·(비)활성화. admin role. (삭제 제외 — 비활성화.)
- (C3) **repository 계층 확장** — credential ownership/메타데이터 조회·daemon CRUD 메서드. CLI와 동일 자산 공유.
## 영향 범위
- 코드 영향:
- `app/api/` — 신규 인증정보 관리 라우트(소유 스코프) + `v1_daemons.py` write(admin) 확장
- `app/repositories/*` — credential 메타데이터/ownership 조회, daemon create/update/deactivate
- `app/core/auth.py` — ownership 게이트(요청자==owner) + admin 게이트 재사용
- `app/schemas/*` — 관리 요청/응답 스키마(**secret 미포함** 메타데이터 응답)
- 암호화: `app/core/encryption.py`(dcy-010) 재사용
- 문서 영향:
- `docs/srs/`(신규 REQ), `docs/discovery/index.md`(본 문서), `docs/interface-contract.md`(신규 엔드포인트)
- 교차: trex-web dcy-009(소비자 UI)
- 운영 영향:
- 사용자 self-service 등록으로 CLI 부담 감소(CLI는 fallback 유지). admin 데몬 provisioning HTTP 경로.
# 5. 요구사항 목록
## 기능 요구사항
- FR-1: 사용자가 **자기 소유 데몬**의 인증정보를 등록·회전·삭제한다(user-token). app_key/app_secret 입력 → Fernet 암호화 저장(dcy-010). 회전 시 credential_version++/last_rotated_at 갱신.
- FR-2: 사용자가 자기 인증정보 **메타데이터**(broker, account_ref, credential_version, last_rotated_at, 데몬별 보유 여부)를 조회한다. **복호화된 app_key/app_secret은 응답에 포함되지 않는다.**
- FR-3: 인증정보 관리 경로는 **소유 스코프 인가**를 강제한다 — 요청자 user_id != 대상 데몬 owner_user_id → HTTP 403(타인 데몬·계좌 접근 차단).
- FR-4: admin이 데몬을 생성·owner 할당·변경(display_name 등)·(비)활성화한다(admin role). **하드 삭제는 제공하지 않는다(비활성화로 대체).**
## 비기능 요구사항
- NFR-1 (보안 — 최우선): 복호화된 app_secret은 **user-token/admin 어떤 응답·로그에도 포함되지 않는다**(쓰기 전용 관리 + 메타데이터). service-token `GET /v1/user_info`(REQ-051)가 유일 decrypt-return 경로 — 본 건으로 변경하지 않는다. at-rest Fernet 불변.
- NFR-2 (권한): 인증정보 관리 = **소유 스코프**(user-token, 요청자==데몬 owner). 데몬 CRUD = **admin role**. dcy-004 토큰/권한 모델 재사용.
- NFR-3 (일관성): 신규 API·CLI(`pfhub credential`)·service-token read가 동일 `BrokerCredential`/`Daemon` 모델·암호화 자산에서 정합 동작. daemon_id·owner_user_id 바인딩 정합(dcy-010).
## 범위 경계
- In Scope
- 소유 스코프 인증정보 관리 API(set/rotate/delete + 메타데이터, secret 미반환).
- admin 데몬 CRUD API(생성·할당·변경·(비)활성화, 삭제 제외).
- repository·스키마·권한 게이트.
- Out of Scope
- service-token 복호화 read 경로(`/v1/user_info`, dcy-010/REQ-051) 변경 — **불변**.
- trex-web UI(= dcy-009).
- 서비스 토큰 발급·폐기(별 Discovery, dcy-009 OQ-3).
- 외부 시크릿 매니저 외부화.
- 데몬 하드 삭제.
# 6. 리스크/가정 목록
## 리스크
- R-1 (High): 복호화 app_secret이 user-token/admin 관리 응답·로그로 유출(실계좌 탈취).
- 완화 방안: 관리 API는 set/rotate/delete + 메타데이터만, 복호화 payload 절대 미반환. 응답 스키마에 secret 필드 부재. 로그 secret 미포함(REQ-051 패턴). service-token 경로와 라우트·권한 분리. 음성 테스트.
- R-2 (High): ownership 인가 결함 → 사용자가 타인 데몬 인증정보 조회·수정(횡적 권한 상승).
- 완화 방안: 모든 관리 경로 **엄격 ownership 검사**(요청자==owner_user_id, 불일치 403). dcy-009 trex-web 포트폴리오/REQ-049 daemon-scope 인가 재사용. 음성 테스트(타인 자원→403) 필수.
- R-3 (Medium): 데몬 (비)활성화·변경 blast radius — 운영 인스턴스가 daemon_id로 fetch 중.
- 완화 방안: 삭제 미제공(비활성화), 참조 무결성·활성 데몬 보호, 인증정보 보유 데몬 비활성화 경고.
- R-4 (Medium): CLI·신규 API 이중 관리 경로 불일치.
- 완화 방안: 동일 repository 메서드·암호화 자산 재사용, PK `(daemon_id, broker)` 정합.
## 가정
- A-1: dcy-010 `BrokerCredential`(PK `(daemon_id, broker)`, user_id, Fernet, `CREDENTIAL_ENCRYPTION_KEY`) 완료. (확인됨, Implemented)
- A-2: `Daemon.owner_user_id` 존재 → 소유 스코프 인가가 데이터상 가능. (확인됨)
- A-3: dcy-004 토큰/권한(user-token, admin role, service-token) 재사용.
- A-4: 교차 발원 trex-web `dcy-009`(confirmed)가 소비자. UI는 dcy-009 범위.
- A-5: service-token `/v1/user_info`(REQ-051) decrypt-return 경로 불변(유일 decrypt 경로).
## 오픈 질문
- OQ-1: 인증정보 관리 엔드포인트 경로·형태 — daemon-scoped(`/v1/daemons/{id}/credential`) vs user-credential.
- 상태: **Deferred (2026-06-28)** → draft-batch-design 단계 위임.
- 처리 방안: design에서 REST 형태 확정. 팀장 권고 = daemon-scoped 경로(소유 스코프 자연). dcy-009 UI 흐름·v1 컨벤션 정합.
- 종료 조건: draft-batch-design 전 경로·메서드 확정.
- OQ-2: admin 데몬 CRUD 경로 — `/v1/daemons` 확장 vs `/v1/admin/daemons`.
- 상태: **Deferred (2026-06-28)** → draft-batch-design 단계 위임.
- 처리 방안: design에서 확정. dcy-006 admin 경로 컨벤션 참고.
- 종료 조건: draft-batch-design 전 확정.
- OQ-3: 데몬 owner 재할당 허용 여부 + 재할당 시 인증정보 처리.
- 상태: **Answered (2026-06-28)**
- 결정: **재할당 허용(admin), 단 owner 변경 시 해당 데몬의 기존 인증정보 자동 무효화**(이전 owner의 KIS 계좌 이관 금지 — 새 owner가 재등록). §5 FR-4/범위·R-2 반영.
# 7. 초기 성공 기준
- S-1: 사용자가 자기 데몬 인증정보를 API로 등록하면, 그 데몬이 service-token `/v1/user_info`에서 동일 계좌를 fetch함을 검증할 수 있다.
- S-2: 인증정보 관리/메타데이터·데몬 CRUD 어떤 user-token/admin 응답에도 복호화 app_secret/app_key가 포함되지 않음이 테스트로 확인된다.
- S-3: 사용자 A가 사용자 B 소유 데몬의 인증정보 조회·수정 시 403(소유 스코프)이 테스트로 확인된다.
- S-4: admin이 데몬 생성·owner 할당·(비)활성화를 수행하고, 비-admin은 거부(403)됨이 확인된다.
## 측정 방식 및 데이터 출처
- S-1 측정 방식: API 등록 → service-token `/v1/user_info` fetch 일치 / 데이터 출처: 통합테스트, broker_credentials 행
- S-2 측정 방식: 관리·CRUD 응답 스키마/실응답에서 secret 필드 부재 + 로그 미노출 / 데이터 출처: API·로그 테스트
- S-3 측정 방식: 타 사용자 자원 접근 → 403 음성 테스트 / 데이터 출처: API 테스트
- S-4 측정 방식: admin/비-admin 권한 경로 테스트 / 데이터 출처: API 테스트
# 8. REQ로 넘기기 전 확인 체크
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: 데몬 owner 재할당 정책(OQ-3 재할당+인증정보 무효화) 결정 완료
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-4 측정 가능성 검토 완료
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(secret 노출 — 쓰기전용)·R-2(소유 스코프 인가 — 엄격 ownership) 완화 모델 확정. 구현 책임=portfolio-hub-dev
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-3 Answered, OQ-1/OQ-2 Deferred(design 위임)
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 사장님+팀장 confirm-discovery 완료
# 9. 파일 처리 결과
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-011_20260628_daemon-crud-and-self-service-credential-api.md
- 참조 문서:
- trex-web docs/discovery/dcy-009_20260628_daemon-and-broker-credential-admin-management.md (교차 발원 — 소비자 UI/요건)
- docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md (선행 — 인증정보 모델/보안/REQ-051)
- docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (권한/토큰 모델)
# 10. 사용자 결정 필요 항목 요약
## DECIDE
- ~~데몬 owner 재할당 (OQ-3)~~ → **RESOLVED 2026-06-28: 재할당 허용(admin), owner 변경 시 기존 인증정보 자동 무효화**
## CONFIRM
- ~~쓰기 전용·secret 미반환·service-token 유일 decrypt 불변~~ → **RESOLVED 2026-06-28: 확정(NFR-1)**
- ~~엄격 소유 스코프 인가 + 데몬 CRUD admin~~ → **RESOLVED 2026-06-28: 확정(NFR-2)**
## DATA
- {{DATA_V1_CONVENTIONS:기존 v1 API 경로·권한 컨벤션(/v1/daemons, /v1/portfolios, dcy-006 admin) — 엔드포인트 형태(OQ-1/OQ-2 design) 설계 입력}}
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: REQ Drafting 진행 가능
- Handoff Rationale: confirm-discovery 완료(2026-06-28). CONFIRM 2건(쓰기전용·secret 미반환·service-token 유일 decrypt / 엄격 소유 스코프+데몬 CRUD admin) + DECIDE 1건(OQ-3 owner 재할당 허용+인증정보 자동 무효화) 확정. OQ-1/OQ-2(엔드포인트 경로)는 draft-batch-design 위임 Deferred. 잔여 Open 0건 → REQ 인계 가능. trex-web dcy-009가 이 계약 소비.
- Ready for REQ Drafting: true
- Confirmed By: 팀장(Claude)
- Confirmed At (KST): 2026-06-28
+1
View File
@@ -16,3 +16,4 @@
| dcy-008_20260625_current-holding-wac-cost-basis-read | 20260625 | 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존(교차 발원 trex-core dcy-034 FR-7). GET /v1/portfolios/{id}/holdings/cost-basis | [Discovery](./dcy-008_20260625_current-holding-wac-cost-basis-read.md) | confirmed |
| dcy-009_20260627_user-read-asset-catalog-and-daemons | 20260627 | 사용자용 read 2종 — GET /v1/assets require_read 완화 + 사용자 가용 데몬 목록 신규(교차 발원 trex-web 포트폴리오 관리 dcy-007) | [Discovery](./dcy-009_20260627_user-read-asset-catalog-and-daemons.md) | confirmed |
| dcy-010_20260627_daemon-scoped-broker-credential-provisioning | 20260627 | daemon-scoped 브로커(KIS) 인증정보 소유·전달 — 다중 인스턴스 trex-core가 daemon별 다른 계좌로 거래 (교차 발원 trex-core 중장기 멀티 인스턴스) | [Discovery](./dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md) | confirmed |
| dcy-011_20260628_daemon-crud-and-self-service-credential-api | 20260628 | 데몬 admin CRUD API + 사용자 self-service 브로커 인증정보 관리 API — 소유 스코프 write/rotate/메타데이터(복호화 secret 미반환), 데몬 생성·할당·(비)활성화는 admin (교차 발원 trex-web dcy-009) | [Discovery](./dcy-011_20260628_daemon-crud-and-self-service-credential-api.md) | confirmed |
@@ -0,0 +1,99 @@
# REQ-052: 사용자 소유 스코프 인증정보 관리 HTTP API
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- 현재 BrokerCredential 관리는 CLI(`pfhub credential`)와 service-token read 경로(REQ-051/REQ-049)만 존재하며, HTTP user-token 기반 관리 표면이 없다.
- trex-web dcy-009 사용자 self-service UI가 소비하는 백엔드 경로를 신설한다: 사용자가 자기 소유 데몬의 KIS 인증정보를 HTTP API로 등록·회전·삭제하고, 메타데이터(secret 미포함)를 조회할 수 있도록 한다.
- dcy-010 자산(BrokerCredential 모델, Fernet 암호화, `CREDENTIAL_ENCRYPTION_KEY`, PK `(daemon_id, broker)`)을 재사용하여 CLI와 동일 repository·암호화 경로를 공유한다.
## Requirement
- 시스템은 user-token으로 인증된 사용자가 **자기 소유 데몬**의 broker 인증정보를 등록·회전·삭제할 수 있는 write 엔드포인트를 제공해야 한다.
- 등록/회전: `app_key` + `app_secret` + `account_ref` 입력을 Fernet 암호화하여 `BrokerCredential`에 upsert. 회전 시 `credential_version` 증가, `last_rotated_at` 갱신.
- 삭제: 해당 `(daemon_id, broker)` 행 제거.
- 시스템은 사용자가 자기 소유 데몬의 인증정보 **메타데이터**를 조회할 수 있는 read 엔드포인트를 제공해야 한다. 응답에는 `broker`, `account_ref`, `credential_version`, `last_rotated_at`, 보유 여부(`has_credential`)만 포함하며 **복호화된 `app_key`/`app_secret`은 절대 포함하지 않는다**.
- **소유 스코프 인가**를 모든 관리 경로에 강제해야 한다: 요청자 `user_id`가 대상 데몬의 `owner_user_id`와 일치하지 않으면 HTTP 403 Forbidden을 반환한다. 인증 토큰이 없는 요청은 HTTP 401을 반환한다.
- 인증정보 write 요청이 성공하면 응답에는 메타데이터(`broker`, `account_ref`, `credential_version`, `last_rotated_at`)만 반환하고 `app_key`/`app_secret`은 포함하지 않는다.
- 엔드포인트 경로·HTTP 메서드의 구체 형태는 draft-batch-design에서 v1 컨벤션(`/v1/daemons/{daemon_id}/credential` 방향 권고, dcy-011 OQ-1 Deferred)에 맞춰 확정한다.
- 기존 CLI(`pfhub credential`)와 동일한 `BrokerCredentialRepository` 메서드 및 `app/core/encryption.py` Fernet 자산을 재사용해야 한다.
## Acceptance Criteria
1. Given user-token(소유자 A)으로 자기 소유 daemon D에 `app_key`/`app_secret`/`account_ref` 를 등록, When write 엔드포인트를 호출, Then HTTP 200/201 + 메타데이터 응답(`broker`, `account_ref`, `credential_version`, `last_rotated_at`)이 반환되고 응답 JSON에 `app_key`/`app_secret` 필드가 존재하지 않는다.
2. Given 같은 소유자 A가 동일 daemon D 인증정보를 재등록(회전), When write 엔드포인트를 호출, Then 응답의 `credential_version`이 이전보다 1 증가하고 `last_rotated_at`이 갱신된다.
3. Given 소유자 A가 daemon D 인증정보를 등록한 뒤, When service-token `/v1/user_info`(REQ-049)로 해당 daemon D의 인증정보를 fetch하면, Then `broker.account_ref`가 방금 등록한 값과 일치한다(CLI·API·service-token 경로 정합).
4. Given user-token(소유자 A)으로 자기 소유 daemon D 인증정보 메타데이터를 GET, When 조회 엔드포인트를 호출, Then HTTP 200 + `broker`, `account_ref`, `credential_version`, `last_rotated_at`, `has_credential` 반환되고 `app_key`/`app_secret` 필드는 응답에 없다.
5. Given user-token(소유자 A)으로 자기 소유 daemon D 인증정보를 삭제, When delete 엔드포인트를 호출, Then HTTP 200/204 반환 후 해당 daemon D 메타데이터 조회 시 `has_credential: false`(또는 인증정보 없음 응답).
6. Given user-token(사용자 B)으로 **사용자 A 소유** daemon D의 인증정보에 대한 write/read/delete 요청, When 엔드포인트를 호출, Then HTTP 403 Forbidden 반환(소유 스코프 위반).
7. Given 인증 토큰 없는 요청으로 인증정보 관리 엔드포인트 호출, When 호출, Then HTTP 401 반환.
8. Given 존재하지 않는 daemon_id를 대상으로 인증정보 관리 요청, When 호출, Then HTTP 404 반환.
9. Given admin 토큰으로 자기 소유 데몬이 아닌 daemon D의 인증정보 write 엔드포인트 호출, When 호출, Then HTTP 403 반환(관리 API는 소유 스코프 전용 — admin role로 타인 인증정보 조작 불가).
10. Given service-token으로 인증정보 관리 엔드포인트 호출, When 호출, Then HTTP 403 반환(service-token은 read 전용 `/v1/user_info` 경로만 허용).
## Impacted Area
- Docs:
- `docs/interface-contract.md` — 인증정보 관리 엔드포인트 경로·권한·응답 스키마(secret 미포함 정책) 신규 섹션 추가.
- Modules:
- 신규 라우터 파일(경로·파일명은 design 확정) — 인증정보 관리 write/read/delete 엔드포인트.
- `app/schemas/` — `CredentialSetRequest`(app_key/app_secret/account_ref), `CredentialMetaResponse`(메타데이터 only, secret 미포함) 스키마 신규 정의.
- `app/services/` — 소유 스코프 인가 검사 + Fernet 암호화/upsert/삭제 서비스 메서드 신규 또는 확장.
- `app/repositories/broker_credential_repository.py` — 메타데이터 조회·write 메서드 확장(CLI와 공유).
- `app/core/encryption.py` — 재사용 (변경 없음).
- `app/main.py` 또는 라우터 등록 — 신규 라우터 include.
- Tests:
- AC-1~10 커버 pytest 케이스. 특히 AC-6(소유 스코프 음성), AC-9(admin 소유 스코프 음성), AC-10(service-token 음성), AC-3(CLI·API·service-token 정합) 강제 검증.
## Notes
- OQ-1(엔드포인트 경로): draft-batch-design 위임(dcy-011 Deferred). 팀장 권고 = daemon-scoped(`/v1/daemons/{daemon_id}/credential`). v1 컨벤션·dcy-009 UI 흐름 정합 설계 단계 확정.
- **복호화 secret 미반환 불변**: 이 REQ의 어떤 응답도 `app_secret`/`app_key` 평문을 포함하지 않는다. 유일 decrypt-return 경로는 service-token `GET /v1/user_info`(REQ-051/REQ-049) — 본 REQ로 변경하지 않는다.
- AC-9(admin 소유 스코프): admin은 데몬 CRUD(REQ-053)를 담당하며, admin role로 타인 인증정보 write/read를 허용하지 않는다. 인증정보는 **사용자 본인 소유 스코프만**. 이 경계가 REQ-052(user-token, 소유 스코프)와 REQ-053(admin, 데몬 CRUD)의 책임 분리이다.
- NFR-1(보안 — secret 미반환·로그 미노출)은 REQ-054에서 독립 Non-Functional REQ로 추적한다. 이 REQ AC는 응답 schema 단의 secret 부재 확인을 포함하며, REQ-054가 전체 보안 격리를 측정한다.
- NFR-3(일관성 — CLI·API 정합): AC-3이 흡수한다. 구현 시 동일 repository 메서드 재사용 강제.
- Source: dcy-011 FR-1, FR-2, FR-3, NFR-2, NFR-3.
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-011 FR-1/FR-2/FR-3 산출물.
#### Intent
- dcy-011 confirmed Discovery의 FR-1(인증정보 등록·회전·삭제), FR-2(메타데이터 조회, secret 미반환), FR-3(소유 스코프 인가 강제)를 단일 Interface REQ로 정규화한다. 세 FR이 동일 관리 표면(user-token + 소유 스코프 인증정보 관리)에 속하므로 하나의 REQ로 묶는다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-10 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Interface/req-052_user-owned-credential-management-api.md` 신규 생성
- `docs/interface-contract.md` 갱신 예고
- Modules:
- 인증정보 관리 라우터(신규), 스키마(신규), 서비스·repository 확장
- Tests:
- 소유 스코프 음성(AC-6/9/10), 메타데이터 secret 부재(AC-1/4), CLI 정합(AC-3) 케이스 신규
#### Delivery Trace Delta
- Batches: 미정 (suggest-batch-reqs 대기)
- Releases: 미정
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-053/REQ-054와 동일 batch 묶기 권고. OQ-1(경로)은 design 단계 해소.
#### Validation Plan
- pytest로 AC-1~10 각 케이스 확인. AC-6(타 사용자 소유 스코프 음성), AC-9(admin 소유 스코프 음성), AC-10(service-token 음성)은 음성 테스트로 강제 검증.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-011 FR-1/2/3 추적. 소유 스코프 음성(AC-6/9/10)·메타데이터 secret 부재(AC-1/4)·CLI·API·service-token 정합(AC-3) 적절. **AC-9(admin도 타인 인증정보 write→403)** = 사장님 "self-service 전용·admin 감독 없음" 결정과 정합 확인(admin은 데몬 CRUD만, 인증정보는 본인 소유 스코프). 경로(OQ-1) design 위임. blocker 없음.
@@ -0,0 +1,104 @@
# REQ-053: admin 데몬 CRUD HTTP API
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- 현재 `Daemon` 엔티티는 `GET /v1/daemons`(read 전용, REQ-045)만 HTTP로 노출되며, 생성·변경·(비)활성화 HTTP API가 없다. 관리자(admin)가 HTTP API로 데몬을 provisioning하고 owner를 할당·변경·(비)활성화할 수 있어야 한다.
- trex-web dcy-009 admin UI가 소비하는 백엔드 경로를 신설한다.
- 데몬은 운영 런타임 식별자(service-token daemon_id 바인딩, KIS 계좌 연결)로 하드 삭제는 제공하지 않으며 **비활성화로 대체**한다(dcy-011 FR-4 결정). owner 재할당은 허용하되 **owner 변경 시 해당 데몬의 기존 인증정보를 자동 무효화**한다(이전 owner의 KIS 계좌 이관 금지, dcy-011 OQ-3 결정).
## Requirement
- 시스템은 admin role로 인증된 요청자가 데몬을 **생성**하고 `owner_user_id`를 할당할 수 있는 POST 엔드포인트를 제공해야 한다.
- 입력: `daemon_slug`(고유 식별자), `display_name`(선택), `owner_user_id`.
- 성공 시 생성된 Daemon의 `id`, `daemon_slug`, `display_name`, `owner_user_id`, `is_active` 반환.
- 시스템은 admin이 기존 데몬의 `display_name` 또는 `owner_user_id`를 **변경**할 수 있는 PATCH 엔드포인트를 제공해야 한다.
- **owner 변경 시 해당 데몬의 기존 `BrokerCredential` 전체를 자동 삭제(무효화)** 해야 한다 — 이전 owner의 KIS 계좌 정보가 새 owner에게 이관되는 것을 방지한다.
- 시스템은 admin이 데몬을 **비활성화**(`is_active=false`) 및 **재활성화**(`is_active=true`)할 수 있는 엔드포인트를 제공해야 한다.
- 비활성화 시 해당 데몬에 바인딩된 service token을 자동 revoke해야 한다(기존 REQ-022 정책 재사용).
- **하드 삭제(DELETE) 엔드포인트는 제공하지 않는다.** 비활성화가 유일한 종료 경로이다.
- admin role이 아닌 요청(user-token, service-token 포함)은 HTTP 403 Forbidden을 반환해야 한다.
- 엔드포인트 경로·HTTP 메서드의 구체 형태는 draft-batch-design에서 v1 admin 컨벤션(dcy-011 OQ-2 Deferred, dcy-006 admin 경로 참고)에 맞춰 확정한다.
## Acceptance Criteria
1. Given admin 토큰으로 daemon_slug, display_name, owner_user_id를 포함한 생성 요청, When POST 엔드포인트를 호출, Then HTTP 201 + 생성된 Daemon(`id`, `daemon_slug`, `display_name`, `owner_user_id`, `is_active=true`) 반환.
2. Given admin 토큰으로 중복 `daemon_slug`로 생성 요청, When POST 엔드포인트를 호출, Then HTTP 409(또는 422) Conflict 반환(중복 슬러그 거부).
3. Given admin 토큰으로 존재하는 daemon D의 `display_name` 변경 요청(owner 변경 없음), When PATCH 엔드포인트를 호출, Then HTTP 200 + 갱신된 Daemon 반환. 기존 BrokerCredential은 유지된다.
4. Given admin 토큰으로 daemon D의 `owner_user_id`를 사용자 B로 재할당 요청(원래 소유자 A), When PATCH 엔드포인트를 호출, Then HTTP 200 + 갱신된 Daemon 반환 + 해당 daemon D의 BrokerCredential이 모두 삭제(무효화)된다.
5. Given daemon D의 owner가 변경된 직후, When 변경 전 소유자 A의 service-token으로 `/v1/user_info`를 호출, Then `broker: null` 반환(이전 owner 인증정보 이관 차단).
6. Given admin 토큰으로 활성 daemon D 비활성화 요청, When 비활성화 엔드포인트를 호출, Then HTTP 200 + `is_active: false` 반환 + daemon D에 바인딩된 service token 자동 revoke(REQ-022 정책).
7. Given admin 토큰으로 비활성 daemon D 재활성화 요청, When 재활성화 엔드포인트를 호출, Then HTTP 200 + `is_active: true` 반환.
8. Given admin 토큰으로 daemon D에 DELETE 요청(하드 삭제), When 호출, Then HTTP 405 Method Not Allowed(또는 404) 반환 — 하드 삭제 엔드포인트 미제공.
9. Given user-token(admin role 없음)으로 데몬 생성·변경·비활성화 엔드포인트 호출, When 호출, Then HTTP 403 Forbidden 반환.
10. Given service-token으로 데몬 CRUD 엔드포인트 호출, When 호출, Then HTTP 403 Forbidden 반환.
11. Given 인증 토큰 없는 요청으로 데몬 CRUD 엔드포인트 호출, When 호출, Then HTTP 401 반환.
12. Given admin 토큰으로 존재하지 않는 daemon_id에 PATCH/비활성화 요청, When 호출, Then HTTP 404 반환.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — admin 데몬 CRUD 엔드포인트 경로·권한·owner 재할당 시 인증정보 무효화 정책 신규 섹션 추가.
- Modules:
- 신규 또는 확장 admin 데몬 라우터(경로·파일명은 design 확정).
- `app/schemas/` — `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 스키마 신규 정의.
- `app/services/` — 데몬 생성·변경·비활성화 서비스 메서드 신규. owner 재할당 시 `BrokerCredential` 무효화 로직 포함.
- `app/repositories/` — `DaemonRepository` create/update/deactivate/reactivate 메서드 신규.
- `app/repositories/broker_credential_repository.py` — owner 재할당 시 credential 삭제 메서드(신규 또는 확장).
- REQ-022(`daemon deactivation service token auto-revoke`) 서비스 로직 재사용 — 비활성화 경로에서 호출.
- Tests:
- AC-1~12 커버 pytest 케이스. AC-4/5(owner 재할당 + 인증정보 무효화), AC-8(DELETE 405 음성), AC-9/10/11(권한 음성) 강제 검증.
## Notes
- OQ-2(엔드포인트 경로): draft-batch-design 위임(dcy-011 Deferred). `/v1/admin/daemons` vs `/v1/daemons`(admin 분기) — dcy-006 admin 경로 컨벤션 참고.
- **owner 재할당 + 인증정보 자동 무효화(AC-4/5)**: dcy-011 OQ-3 Answered(2026-06-28 결정). 새 owner는 자기 KIS 인증정보를 REQ-052 API로 재등록해야 한다. 이 정책은 이전 owner의 KIS 계좌 정보가 데몬에 잔류하는 보안 위험을 제거한다.
- **하드 삭제 미제공(AC-8)**: 데몬은 운영 런타임 식별자(service-token daemon_id 바인딩)이므로 참조 무결성 보호를 위해 비활성화가 유일 종료 경로. dcy-011 FR-4 결정.
- **비활성화 → service token revoke(AC-6)**: 기존 REQ-022 정책(`daemon deactivation auto-revoke`)을 이 경로에서도 호출. REQ-022 구현 재사용.
- NFR-2(권한 — admin role 강제): AC-9/10/11로 흡수한다.
- daemon_slug 중복 처리(AC-2): HTTP 409 vs 422는 design 단계에서 v1 컨벤션에 맞춰 확정.
- Source: dcy-011 FR-4, NFR-2, OQ-3 Answered.
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-011 FR-4 산출물.
#### Intent
- dcy-011 confirmed Discovery의 FR-4(admin 데몬 생성·owner 할당·변경·(비)활성화, 삭제 제외) 및 OQ-3 결정(owner 재할당 허용 + 인증정보 자동 무효화)을 Interface REQ로 정규화한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-12 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Interface/req-053_admin-daemon-crud-api.md` 신규 생성
- `docs/interface-contract.md` 갱신 예고
- Modules:
- admin 데몬 라우터(신규), DaemonCreate/UpdateRequest/AdminResponse 스키마(신규), 서비스·repository 신규
- Tests:
- owner 재할당+인증정보 무효화(AC-4/5), DELETE 음성(AC-8), 권한 음성(AC-9~11) 케이스 신규
#### Delivery Trace Delta
- Batches: 미정 (suggest-batch-reqs 대기)
- Releases: 미정
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-052/REQ-054와 동일 batch 묶기 권고. OQ-2(경로)은 design 단계 해소.
#### Validation Plan
- pytest로 AC-1~12 각 케이스 확인. AC-4/5(owner 재할당 + 인증정보 무효화) 통합 시나리오, AC-8(DELETE 음성), AC-9(admin role 강제 음성) 강제 검증.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-011 FR-4/OQ-3 추적. 데몬 생성·할당·(비)활성화, **owner 재할당 시 인증정보 자동 무효화(AC-4/5)** 보안정합, **하드 삭제 미제공(AC-8)**·비활성화→service token revoke(REQ-022 재사용, AC-6)·admin role 강제(AC-9/10/11) 적절. 경로(OQ-2)·409/422·405/404 design 위임. blocker 없음.
@@ -1,6 +1,6 @@
# REQ-047: broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션
- Status: Approved
- Status: Implemented
- Type: Migration
- Priority: High
- Owner: Justin Ko
@@ -123,3 +123,37 @@
#### Validation Plan
- 사장님 결재 → Step1 실DB 적용 → REQ-048 백필 → Step2 실DB 적용 → AC-1~7 전건 real-DB evidence 확보 후 Implemented 전환.
### CHG-20260628-04
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req-implemented: `Approved` → `Implemented`.
#### Change Summary
- BAT-021 PR #18 머지(mergeCommit 7164f9e) — ensure_schema() broker_credentials 최종 daemon-scoped 스키마 갱신, _row_to_broker_credential 컬럼순서 정정, upsert ON CONFLICT (user_id,broker) → (daemon_id,broker) 수정, daemon 폴백 트랜잭션 abort 이슈(fallback 제거) 코드가 포함됨.
- 실 dev PostgreSQL 통합검증: Step1 → backfill → Step2 → downgrade 순서 전건 실행, AC-1~7 PASS.
- QC(opus) PASS-WITH-NOTES(blocker 0) 확인됨.
#### Intent
- Implemented 전환. AC-3/4/5(Step2 PK 전환 적용 후 schema) real-DB evidence 확보 완료.
- **주의**: Implemented는 코드 + dev DB 통합검증 완료 기준. **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md`, 배포순서 Step1→backfill→Step2→코드배포 엄수, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보.
#### Acceptance Criteria Delta
- Added: 없음
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- 없음 (상태 전환 기록)
#### Delivery Trace Delta
- Batches: BAT-021 (PR #18, mergeCommit 7164f9e) — 동반 코드 수정 + 통합검증 완료
- Releases: 미정 (Step2 실 운영 DB 적용은 사장님 결재 후)
#### Revalidation Impact
- Reverification Needed: no (dev DB AC-1~7 전건 PASS, 운영 적용 시 재확보 별도 필요)
- Existing Implementation Invalidated: no
- Status Recommendation: Implemented
#### Validation Plan
- BAT-021 verification.md의 AC-1~7 전건 real-DB evidence 참조. 운영 적용 시 런북 절차 준수.
@@ -0,0 +1,91 @@
# REQ-054: 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출
- Status: Approved
- Type: Non-Functional
- Priority: High
- Owner: Justin Ko
## Intent
- dcy-011 NFR-1(보안 최우선): 복호화된 `app_secret`/`app_key`(KIS API 비밀키 — 실계좌·실자금 직결)는 user-token/admin 어떤 관리 API 응답·로그에도 포함되어서는 안 된다.
- REQ-052(사용자 소유 스코프 인증정보 관리 API)와 REQ-053(admin 데몬 CRUD API)이 신설됨으로써 응답/로그 노출 표면이 확대된다. 이 표면에서 복호화 secret이 유출되지 않음을 독립적으로 측정·검증한다.
- `GET /v1/user_info`(service-token 전용, REQ-051/REQ-049)가 유일 decrypt-return 경로라는 불변 정책을 신규 관리 API 추가 이후에도 유지한다.
## Requirement
- 인증정보 관리 API(REQ-052: 소유 스코프 write/read/delete + 메타데이터, REQ-053: admin 데몬 CRUD) 응답 스키마에 `app_key` 및 `app_secret` 필드가 존재해서는 안 된다.
- 인증정보 관리 API 처리 중 생성되는 모든 로그(INFO, WARNING, ERROR 수준 포함)에 `app_secret` 또는 `app_key` 평문이 포함되어서는 안 된다. 로그에는 `broker` 이름, `daemon_id`, 오류 유형만 기록한다.
- 복호화된 `credential_payload`(`app_key`, `app_secret`)를 반환하는 유일 경로는 service-token 전용 `GET /v1/user_info`(REQ-051/REQ-049)이다. 이 REQ-052·REQ-053 신설로 인해 이 경로가 변경되어서는 안 된다.
- 인증정보 쓰기(등록/회전) 후 DB에 저장되는 값은 반드시 Fernet 암호화된 형태여야 한다. 평문 `app_secret`/`app_key`가 `broker_credentials` 테이블에 저장되어서는 안 된다.
## Acceptance Criteria
1. Given REQ-052 인증정보 write(등록/회전) 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret` 키가 최상위 및 중첩 객체에 포함되지 않는다.
2. Given REQ-052 인증정보 메타데이터 GET 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret` 키가 포함되지 않는다.
3. Given REQ-053 admin 데몬 생성·변경·(비)활성화 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret`, `credential_payload` 키가 포함되지 않는다.
4. Given 인증정보 write/rotate 처리 중 caplog 수집, When 로그 출력을 확인, Then 실제 `app_key`/`app_secret` 값이 로그 메시지에 포함되지 않는다.
5. Given 인증정보 write 요청의 `app_key`/`app_secret` 값으로 DB row를 직접 조회, When `broker_credentials.credential_payload` 컬럼을 확인, Then 평문이 아닌 Fernet 암호화 토큰(`gAAAA...`) 형태로 저장되어 있다.
6. Given REQ-052 인증정보 write 후 service-token `GET /v1/user_info`(REQ-049)로 조회, When 호출, Then `broker.credential_payload.app_secret`이 올바르게 복호화되어 반환된다 — 관리 API 추가가 유일 decrypt 경로(REQ-051)를 파괴하지 않음 확인.
7. Given REQ-052·REQ-053 신규 엔드포인트가 추가된 후 기존 `GET /v1/user_info` 보안 격리 테스트(REQ-051 AC-1~8), When 회귀 실행, Then 전체 PASS — 신규 API 추가가 기존 service-token 전용 경로에 영향 없음 확인.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — 인증정보 관리 API 보안 정책 섹션: 복호화 secret 미반환·로그 미노출·Fernet at-rest 불변·service-token 유일 decrypt 경로 명시.
- Modules:
- `app/schemas/` — REQ-052 응답 스키마(`CredentialMetaResponse`)에 `app_key`/`app_secret` 필드 부재 강제(Pydantic 모델 설계 단계 확인).
- `app/services/` — 인증정보 write 서비스에서 로그 호출 시 secret 평문 비포함 확인.
- `app/core/encryption.py` — Fernet 암호화 후 저장 경로(REQ-052 write) 재사용.
- Tests:
- caplog 기반 로그 secret 비노출 테스트(AC-4).
- 응답 스키마 secret 필드 부재 테스트(AC-1/2/3).
- DB 저장 암호화 확인 테스트(AC-5).
- REQ-051 회귀 테스트(AC-7): 신규 API 추가 후 기존 service-token gate 테스트 전부 PASS.
## Notes
- dcy-011 NFR-1(보안 — 최우선): REQ-052/REQ-053의 각 AC에 secret 부재 조건이 포함되어 있으나, 이 REQ-054는 신규 관리 API 전체를 아우르는 **독립 측정·검증 목적** Non-Functional REQ로 분리한다. REQ-051(서비스 토큰 전용 격리)과 나란히 추적한다.
- AC-7(REQ-051 회귀): REQ-052·REQ-053 구현 후 기존 service-token 보안 격리(REQ-051 AC-1~8)가 회귀 없이 통과함을 이 REQ의 검증 항목으로 포함한다.
- at-rest Fernet 암호화(AC-5): `CREDENTIAL_ENCRYPTION_KEY` 관리 정책은 REQ-051 Notes에 따라 이 REQ 범위 밖(환경변수 기반 유지).
- NFR-2(권한)·NFR-3(일관성)의 나머지는 REQ-052/REQ-053 AC에 흡수된다. NFR-1 보안만 별도 Non-Functional REQ로 승격한다.
- Source: dcy-011 NFR-1, R-1(복호화 secret 유출 리스크), S-2(복호화 secret 미포함 성공 기준).
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-011 NFR-1 산출물.
#### Intent
- dcy-011 NFR-1(복호화 secret이 user-token/admin 응답·로그에 미포함, REQ-051 패턴)을 신규 관리 API(REQ-052·REQ-053) 표면 전체에 대한 독립 측정 가능 Non-Functional REQ로 정규화한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-7 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Non-Functional/req-054_credential-management-security-write-only.md` 신규 생성
- `docs/interface-contract.md` 갱신 예고
- Modules:
- 응답 스키마(secret 필드 부재), 서비스 로그 확인
- Tests:
- caplog 로그 비노출(AC-4), 응답 schema secret 부재(AC-1~3), DB 암호화 확인(AC-5), REQ-051 회귀(AC-7) 신규
#### Delivery Trace Delta
- Batches: 미정 (suggest-batch-reqs 대기)
- Releases: 미정
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-052/REQ-053와 동일 batch 묶기 권고.
#### Validation Plan
- pytest caplog fixture로 AC-4(로그 비노출), 응답 JSON 필드 열거로 AC-1~3, DB row 직접 확인으로 AC-5, REQ-051 회귀 suite로 AC-7 검증.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-011 NFR-1 추적. 신규 관리 API(REQ-052/053) 응답·로그 secret 미포함(AC-1~4)·Fernet at-rest(AC-5)·service-token 유일 decrypt 불변(AC-6)·REQ-051 회귀(AC-7) testable. REQ-051과 나란히 독립 보안 REQ 승격 타당. blocker 없음.
+8 -4
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-052`
- Current: `REQ-055`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -127,16 +127,20 @@
| REQ-044 | GET /v1/assets 권한 완화 — require_admin → require_read | Interface | Implemented | High | Justin Ko | [req-044](./Interface/req-044_get-v1-assets-require-read-relaxation.md) |
| REQ-045 | 사용자 가용 데몬 목록 read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-045](./Interface/req-045_user-daemon-list-endpoint.md) |
| REQ-046 | BrokerCredential 도메인 모델 daemon_id 필드 추가 | Data | Implemented | High | Justin Ko | [req-046](./Data/req-046_broker-credential-daemon-id-field.md) |
| REQ-047 | broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 | Migration | Approved | High | Justin Ko | [req-047](./Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md) |
| REQ-047 | broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 | Migration | Implemented | High | Justin Ko | [req-047](./Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md) |
| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | Implemented | High | Justin Ko | [req-048](./Migration/req-048_broker-credentials-daemon-id-backfill.md) |
| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달로 확장 | Interface | Implemented | High | Justin Ko | [req-049](./Interface/req-049_user-info-daemon-scoped-broker-delivery.md) |
| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | Implemented | High | Justin Ko | [req-050](./Interface/req-050_credential-cli-daemon-id-option.md) |
| REQ-051 | 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단 | Non-Functional | Implemented | High | Justin Ko | [req-051](./Non-Functional/req-051_broker-credential-security-isolation.md) |
| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | Approved | High | Justin Ko | [req-052](./Interface/req-052_user-owned-credential-management-api.md) |
| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Approved | High | Justin Ko | [req-053](./Interface/req-053_admin-daemon-crud-api.md) |
| REQ-054 | 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 | Non-Functional | Approved | High | Justin Ko | [req-054](./Non-Functional/req-054_credential-management-security-write-only.md) |
## Recent Change Log Summary
- 2026-06-28: REQ-052/053/054 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-011 추적·AC testable·blocker 0. 소유 스코프 음성(REQ-052 AC-6/9/10)·owner 재할당+인증정보 무효화(REQ-053 AC-4/5)·secret 미반환·로그 미노출·REQ-051 회귀(REQ-054) 확인. admin도 인증정보는 소유 스코프 전용(self-service 결정 정합). 엔드포인트 경로(OQ-1/2) design 위임. 다음=suggest-batch-reqs.
- 2026-06-28: REQ-052/053/054 신규 생성 (Proposed) — dcy-011 confirmed Discovery 산출물. REQ-052(사용자 소유 스코프 인증정보 관리 HTTP API), REQ-053(admin 데몬 CRUD HTTP API), REQ-054(인증정보 관리 보안 격리 Non-Functional).
- 2026-06-28: REQ-047 `Approved` → `Implemented` (confirm-req-implemented) — BAT-021 PR #18 머지(7164f9e), 실 dev PG 통합검증 AC-1~7 PASS(Step1→backfill→Step2→downgrade), QC PASS-WITH-NOTES(blocker 0). **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 배포순서 Step1→backfill→Step2→코드배포, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보.
- 2026-06-28: REQ-046/048/049/050/051 `Approved` → `Implemented` (confirm-req-implemented) — BAT-020 PR #17 머지(d4acfd4), QC PASS-WITH-NOTES(blocker 0), 테스트 통과. REQ-047은 실 운영 DB 적용·실 PostgreSQL 통합검증이 비가역 사장님 결재 게이트 미완 → Approved 유지. AC-3/4/5(Step2 적용 후 schema) real-DB evidence 대기. 결재·적용 시 TODO: ensure_schema()·upsert ON CONFLICT를 (daemon_id,broker)로 갱신.
- 2026-06-28: REQ-046~051 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-010 daemon-scoped broker credential. 전건 dcy-010 FR/NFR 추적·AC testable·blocker 0 확인. 단계적 마이그레이션(REQ-047 Step1/2)·**REQ-047 Step2 PK 전환은 비가역 결재 게이트**·2데몬 격리(REQ-049 AC-4/5)·보안 격리(REQ-051) 반영. 비차단 design 위임 2건(REQ-048 구현방식, REQ-050 slug/UUID·과도기 정책).
- 2026-06-27: REQ-025 name 검증 완화(CHG-20260627-03) — `_PORTFOLIO_NAME_RE` 한글(가-힣)+공백 허용(`^[a-zA-Z0-9가-힣 ._:\-]{1,100}$`). minor-change. 기존 AC 유지, 테스트 3개 추가.
- 2026-06-27: REQ-044/045 `Approved` → `Implemented` — BAT-019 PR #16 main 머지(`7f15c6b`) QC PASS. 472 passed/0 failed. REQ-045 env토큰(user_id=None) 처리 Notes 정정: "HTTP 403" → "빈 목록 [] HTTP 200"(change-req CHG-20260627-02).
- 2026-06-27: REQ-044/045 신규 (Proposed) — dcy-009 FR-1(GET /v1/assets require_read 완화)·FR-2(사용자 가용 데몬 목록 엔드포인트 신설) REQ 초안 작성. 엔드포인트 경로·스키마(OQ-2) 및 가용 데몬 의미(OQ-3)는 design Deferred.
+516
View File
@@ -0,0 +1,516 @@
"""BAT-022: REQ-052 — GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (소유 스코프 인증정보 관리)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.auth import AuthContext
from app.core.config import Settings, get_settings
from app.core.encryption import encrypt_payload
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
from app.main import create_app
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
OWNER_USER_ID = "user-owner"
OTHER_USER_ID = "user-other"
DAEMON_ID = "daemon-001"
BROKER = "kis"
ACCOUNT_REF = "acc-001"
APP_KEY = "my-app-key-12345"
APP_SECRET = "my-app-secret-xyz"
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_TEST_ENC_KEY,
**kwargs,
)
def _make_client(settings: Settings, mock_repo) -> TestClient:
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _auth_header(token: str = "user-token") -> dict:
return {"Authorization": f"Bearer {token}"}
def _make_daemon(
daemon_id: str = DAEMON_ID,
owner_user_id: str = OWNER_USER_ID,
is_active: bool = True,
) -> Daemon:
return Daemon(
id=daemon_id,
owner_user_id=owner_user_id,
daemon_slug="trex-orb",
display_name="ORB",
is_active=is_active,
)
def _make_cred(
daemon_id: str = DAEMON_ID,
account_ref: str = ACCOUNT_REF,
credential_version: int = 1,
) -> BrokerCredential:
payload_json = json.dumps({"app_key": APP_KEY, "app_secret": APP_SECRET})
encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json)
return BrokerCredential(
user_id=OWNER_USER_ID,
broker=BROKER,
account_ref=account_ref,
encrypted_payload=encrypted,
credential_version=credential_version,
last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc),
daemon_id=daemon_id,
)
def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="tok-1",
user_id=user_id,
permission="read",
token_type="user",
runtime_id=None,
daemon_id=None,
)
def _make_service_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="svc-1",
user_id=user_id,
permission="read",
token_type="service",
runtime_id="rt-1",
daemon_id=DAEMON_ID,
)
def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal:
return TokenPrincipal(
token_id="adm-1",
user_id=user_id,
permission="admin",
token_type="admin",
)
# ---------------------------------------------------------------------------
# REQ-052 AC-1: PUT → 200 + 메타데이터 (secret 미포함)
# ---------------------------------------------------------------------------
def test_put_credential_returns_meta_without_secret() -> None:
"""REQ-052 AC-1: user-token(소유자) PUT → 200 + 메타데이터, app_key/app_secret 없음."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred(credential_version=1)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
headers=_auth_header(),
)
assert resp.status_code == 200
body = resp.json()
# Metadata fields present
assert body["daemon_id"] == DAEMON_ID
assert body["broker"] == BROKER
assert body["account_ref"] == ACCOUNT_REF
assert body["has_credential"] is True
assert body["credential_version"] == 1
# Secret fields MUST NOT be in response (REQ-052 AC-1, REQ-054 AC-1)
assert "app_key" not in body
assert "app_secret" not in body
assert "encrypted_payload" not in body
# ---------------------------------------------------------------------------
# REQ-052 AC-2: 회전 시 credential_version 증가
# ---------------------------------------------------------------------------
def test_put_credential_rotation_increments_version() -> None:
"""REQ-052 AC-2: 동일 daemon 재PUT → credential_version 이전보다 1 증가."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
# Simulate rotation: upsert returns version=2
cred_v2 = _make_cred(credential_version=2)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred_v2
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
headers=_auth_header(),
)
assert resp.status_code == 200
assert resp.json()["credential_version"] == 2
# upsert called with correct daemon_id
mock_repo.upsert_broker_credential.assert_called_once()
call_kwargs = mock_repo.upsert_broker_credential.call_args
assert call_kwargs.kwargs["daemon_id"] == DAEMON_ID
# ---------------------------------------------------------------------------
# REQ-052 AC-3: service-token /v1/user_info 정합 (CLI·API·service-token 경로 통일)
# AC-3 is integration-level: verified by REQ-054 AC-6 (service-token GET /v1/user_info after PUT)
# Here we verify upsert is called with the correct daemon_id + account_ref
# ---------------------------------------------------------------------------
def test_put_credential_upsert_called_with_correct_daemon_id() -> None:
"""REQ-052 AC-3 partial: PUT 이후 upsert에 daemon_id가 정확히 전달됨."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred(account_ref="my-account")
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account", "broker": BROKER},
headers=_auth_header(),
)
mock_repo.upsert_broker_credential.assert_called_once()
kwargs = mock_repo.upsert_broker_credential.call_args.kwargs
assert kwargs["daemon_id"] == DAEMON_ID
assert kwargs["account_ref"] == "my-account"
# ---------------------------------------------------------------------------
# REQ-052 AC-4: GET → 200 + 메타데이터 (secret 미포함)
# ---------------------------------------------------------------------------
def test_get_credential_meta_returns_metadata_without_secret() -> None:
"""REQ-052 AC-4: user-token(소유자) GET → 200 + 메타데이터, secret 없음."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.get_broker_credential_by_daemon.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
assert resp.status_code == 200
body = resp.json()
assert body["broker"] == BROKER
assert body["account_ref"] == ACCOUNT_REF
assert body["has_credential"] is True
assert body["credential_version"] == 1
assert "app_key" not in body
assert "app_secret" not in body
assert "encrypted_payload" not in body
# ---------------------------------------------------------------------------
# REQ-052 AC-5: DELETE → 204, 이후 GET has_credential=False
# ---------------------------------------------------------------------------
def test_delete_credential_returns_204() -> None:
"""REQ-052 AC-5: DELETE → 204."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.delete_broker_credential.return_value = True
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
assert resp.status_code == 204
def test_get_after_delete_returns_has_credential_false() -> None:
"""REQ-052 AC-5: DELETE 이후 GET → has_credential=False."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.get_broker_credential_by_daemon.return_value = None # deleted
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
assert resp.status_code == 200
assert resp.json()["has_credential"] is False
# ---------------------------------------------------------------------------
# REQ-052 AC-6: 타 사용자 토큰 → 403 (소유 스코프 음성)
# ---------------------------------------------------------------------------
def test_put_credential_other_user_returns_403() -> None:
"""REQ-052 AC-6: 다른 user의 토큰으로 PUT → 403."""
settings = _make_settings()
# OTHER user's principal
principal = _make_user_principal(user_id=OTHER_USER_ID)
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref", "broker": BROKER},
headers=_auth_header("other-token"),
)
assert resp.status_code == 403
def test_get_credential_other_user_returns_403() -> None:
"""REQ-052 AC-6: 다른 user의 토큰으로 GET → 403."""
settings = _make_settings()
principal = _make_user_principal(user_id=OTHER_USER_ID)
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
assert resp.status_code == 403
def test_delete_credential_other_user_returns_403() -> None:
"""REQ-052 AC-6: 다른 user의 토큰으로 DELETE → 403."""
settings = _make_settings()
principal = _make_user_principal(user_id=OTHER_USER_ID)
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-052 AC-7: 비인증 → 401
# ---------------------------------------------------------------------------
def test_credential_no_token_returns_401() -> None:
"""REQ-052 AC-7: 인증 토큰 없이 호출 → 401."""
settings = _make_settings()
client = _make_client(settings, MagicMock())
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential")
assert resp.status_code == 401
resp2 = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
)
assert resp2.status_code == 401
resp3 = client.delete(f"/v1/daemons/{DAEMON_ID}/credential")
assert resp3.status_code == 401
# ---------------------------------------------------------------------------
# REQ-052 AC-8: 미존재 daemon_id → 404
# ---------------------------------------------------------------------------
def test_credential_nonexistent_daemon_returns_404() -> None:
"""REQ-052 AC-8: 존재하지 않는 daemon_id → 404."""
settings = _make_settings()
principal = _make_user_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = None # not found
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/nonexistent/credential", headers=_auth_header())
assert resp.status_code == 404
def test_put_nonexistent_daemon_returns_404() -> None:
"""REQ-052 AC-8: PUT 미존재 daemon → 404."""
settings = _make_settings()
principal = _make_user_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
"/v1/daemons/nonexistent/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
headers=_auth_header(),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# REQ-052 AC-9: admin 토큰으로 타인 daemon 인증정보 write → 403
# ---------------------------------------------------------------------------
def test_admin_cannot_write_other_user_credential() -> None:
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon write → 403."""
settings = _make_settings()
admin_principal = _make_admin_principal(user_id="admin-user")
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER, not admin
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
headers=_auth_header("admin-token"),
)
assert resp.status_code == 403
def test_admin_cannot_read_other_user_credential() -> None:
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon GET → 403."""
settings = _make_settings()
admin_principal = _make_admin_principal(user_id="admin-user")
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("admin-token"))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-052 AC-10: service-token → 403
# ---------------------------------------------------------------------------
def test_service_token_credential_get_returns_403() -> None:
"""REQ-052 AC-10: service-token으로 GET /v1/daemons/{id}/credential → 403."""
settings = _make_settings()
svc_principal = _make_service_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
assert resp.status_code == 403
def test_service_token_credential_put_returns_403() -> None:
"""REQ-052 AC-10: service-token으로 PUT → 403."""
settings = _make_settings()
svc_principal = _make_service_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
headers=_auth_header("svc-token"),
)
assert resp.status_code == 403
def test_service_token_credential_delete_returns_403() -> None:
"""REQ-052 AC-10: service-token으로 DELETE → 403."""
settings = _make_settings()
svc_principal = _make_service_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
assert resp.status_code == 403
@@ -0,0 +1,653 @@
"""BAT-022: REQ-053 — POST/PATCH /v1/admin/daemons (admin 데몬 CRUD)."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, call, patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
from app.main import create_app
from app.repositories.postgres import PostgresPortfolioRepository
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
ADMIN_USER_ID = "admin-user"
OWNER_A_ID = "user-a"
OWNER_B_ID = "user-b"
DAEMON_ID = "daemon-001"
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_TEST_ENC_KEY,
**kwargs,
)
def _make_client(settings: Settings, mock_repo) -> TestClient:
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _auth_header(token: str = "admin-token") -> dict:
return {"Authorization": f"Bearer {token}"}
def _make_daemon(
daemon_id: str = DAEMON_ID,
owner_user_id: str = OWNER_A_ID,
daemon_slug: str = "trex-orb",
display_name: str = "ORB",
is_active: bool = True,
) -> Daemon:
return Daemon(
id=daemon_id,
owner_user_id=owner_user_id,
daemon_slug=daemon_slug,
display_name=display_name,
is_active=is_active,
created_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc),
)
def _make_admin_principal(user_id: str = ADMIN_USER_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="adm-1",
user_id=user_id,
permission="admin",
token_type="admin",
)
def _make_user_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="usr-1",
user_id=user_id,
permission="read",
token_type="user",
)
def _make_service_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="svc-1",
user_id=user_id,
permission="read",
token_type="service",
daemon_id=DAEMON_ID,
)
# ---------------------------------------------------------------------------
# REQ-053 AC-1: POST 생성 → 201 + DaemonAdminResponse
# ---------------------------------------------------------------------------
def test_admin_create_daemon_returns_201() -> None:
"""REQ-053 AC-1: admin POST → 201 + id/daemon_slug/display_name/owner_user_id/is_active."""
settings = _make_settings()
principal = _make_admin_principal()
daemon = _make_daemon()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.create_daemon.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(
"/v1/admin/daemons",
json={"daemon_slug": "trex-orb", "display_name": "ORB", "owner_user_id": OWNER_A_ID},
headers=_auth_header(),
)
assert resp.status_code == 201
body = resp.json()
assert body["id"] == DAEMON_ID
assert body["daemon_slug"] == "trex-orb"
assert body["display_name"] == "ORB"
assert body["owner_user_id"] == OWNER_A_ID
assert body["is_active"] is True
# Secret fields must not appear (REQ-054 AC-3)
assert "app_key" not in body
assert "app_secret" not in body
assert "credential_payload" not in body
# ---------------------------------------------------------------------------
# REQ-053 AC-2: 중복 slug → 409
# ---------------------------------------------------------------------------
def test_admin_create_daemon_duplicate_slug_returns_409() -> None:
"""REQ-053 AC-2: 중복 daemon_slug → 409 Conflict."""
settings = _make_settings()
principal = _make_admin_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.create_daemon.side_effect = ValueError("이미 존재하는 daemon slug입니다: trex-orb")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(
"/v1/admin/daemons",
json={"daemon_slug": "trex-orb", "owner_user_id": OWNER_A_ID},
headers=_auth_header(),
)
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# REQ-053 AC-3: display_name 변경 — BrokerCredential 유지
# ---------------------------------------------------------------------------
def test_admin_patch_display_name_credential_preserved() -> None:
"""REQ-053 AC-3: display_name 변경 → 200, BrokerCredential 삭제 없음."""
settings = _make_settings()
principal = _make_admin_principal()
daemon_before = _make_daemon(display_name="OLD NAME")
daemon_after = _make_daemon(display_name="NEW NAME")
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon_before
mock_repo.update_daemon.return_value = daemon_after
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.patch(
f"/v1/admin/daemons/{DAEMON_ID}",
json={"display_name": "NEW NAME"},
headers=_auth_header(),
)
assert resp.status_code == 200
assert resp.json()["display_name"] == "NEW NAME"
# update_daemon_owner must NOT be called (no owner change)
mock_repo.update_daemon_owner.assert_not_called()
# delete_credentials_by_daemon_id must NOT be called
mock_repo.delete_credentials_by_daemon_id.assert_not_called()
# ---------------------------------------------------------------------------
# REQ-053 AC-4: owner 재할당 → BrokerCredential 자동 삭제
# ---------------------------------------------------------------------------
def test_admin_patch_owner_invalidates_credentials() -> None:
"""REQ-053 AC-4: owner 재할당 → 200, BrokerCredential 자동 삭제(무효화)."""
settings = _make_settings()
principal = _make_admin_principal()
daemon_before = _make_daemon(owner_user_id=OWNER_A_ID)
daemon_after = _make_daemon(owner_user_id=OWNER_B_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon_before
mock_repo.update_daemon_owner.return_value = daemon_after
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.patch(
f"/v1/admin/daemons/{DAEMON_ID}",
json={"owner_user_id": OWNER_B_ID},
headers=_auth_header(),
)
assert resp.status_code == 200
assert resp.json()["owner_user_id"] == OWNER_B_ID
# update_daemon_owner must be called (includes credential deletion)
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
# ---------------------------------------------------------------------------
# REQ-053 AC-5: owner 변경 후 이전 owner의 /v1/user_info → broker=null
# (verified via update_daemon_owner deleting credentials; AC-4 covers the mechanism)
# ---------------------------------------------------------------------------
def test_admin_patch_owner_then_old_owner_loses_credential() -> None:
"""REQ-053 AC-5: owner 재할당 이후 이전 owner 인증정보 제거 확인 (update_daemon_owner 호출로 검증)."""
settings = _make_settings()
principal = _make_admin_principal()
daemon_before = _make_daemon(owner_user_id=OWNER_A_ID)
daemon_after = _make_daemon(owner_user_id=OWNER_B_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon_before
mock_repo.update_daemon_owner.return_value = daemon_after
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.patch(
f"/v1/admin/daemons/{DAEMON_ID}",
json={"owner_user_id": OWNER_B_ID},
headers=_auth_header(),
)
assert resp.status_code == 200
# update_daemon_owner deletes credentials as part of the operation (see postgres.py implementation)
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
# ---------------------------------------------------------------------------
# REQ-053 AC-6: 비활성화 → is_active=False + service token auto-revoke
# ---------------------------------------------------------------------------
def test_admin_deactivate_daemon_revokes_tokens() -> None:
"""REQ-053 AC-6: 비활성화 → 200 + is_active=False, service token auto-revoke."""
settings = _make_settings()
principal = _make_admin_principal()
daemon_active = _make_daemon(is_active=True)
daemon_inactive = _make_daemon(is_active=False)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon_active
mock_repo.update_daemon.return_value = daemon_inactive
mock_repo.revoke_tokens_by_daemon_id.return_value = 1
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(f"/v1/admin/daemons/{DAEMON_ID}/deactivate", headers=_auth_header())
assert resp.status_code == 200
assert resp.json()["is_active"] is False
mock_repo.update_daemon.assert_called_once_with(DAEMON_ID, is_active=False)
mock_repo.revoke_tokens_by_daemon_id.assert_called_once_with(DAEMON_ID)
# ---------------------------------------------------------------------------
# REQ-053 AC-7: 재활성화 → is_active=True
# ---------------------------------------------------------------------------
def test_admin_activate_daemon_returns_active() -> None:
"""REQ-053 AC-7: 재활성화 → 200 + is_active=True."""
settings = _make_settings()
principal = _make_admin_principal()
daemon_inactive = _make_daemon(is_active=False)
daemon_active = _make_daemon(is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon_inactive
mock_repo.update_daemon.return_value = daemon_active
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(f"/v1/admin/daemons/{DAEMON_ID}/activate", headers=_auth_header())
assert resp.status_code == 200
assert resp.json()["is_active"] is True
# ---------------------------------------------------------------------------
# REQ-053 AC-8: DELETE → 405 (하드 삭제 미제공)
# ---------------------------------------------------------------------------
def test_admin_delete_daemon_returns_405() -> None:
"""REQ-053 AC-8: DELETE /v1/admin/daemons/{id} → 405 Method Not Allowed."""
settings = _make_settings()
principal = _make_admin_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.delete(f"/v1/admin/daemons/{DAEMON_ID}", headers=_auth_header())
assert resp.status_code == 405
# ---------------------------------------------------------------------------
# REQ-053 AC-9: user-token(비admin) → 403
# ---------------------------------------------------------------------------
def test_non_admin_user_token_create_daemon_returns_403() -> None:
"""REQ-053 AC-9: user-token(non-admin) → POST /v1/admin/daemons → 403."""
settings = _make_settings()
principal = _make_user_principal() # permission=read
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(
"/v1/admin/daemons",
json={"daemon_slug": "test", "owner_user_id": OWNER_A_ID},
headers=_auth_header("user-token"),
)
assert resp.status_code == 403
def test_non_admin_patch_returns_403() -> None:
"""REQ-053 AC-9: user-token → PATCH /v1/admin/daemons/{id} → 403."""
settings = _make_settings()
principal = _make_user_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.patch(
f"/v1/admin/daemons/{DAEMON_ID}",
json={"display_name": "X"},
headers=_auth_header("user-token"),
)
assert resp.status_code == 403
def test_non_admin_deactivate_returns_403() -> None:
"""REQ-053 AC-9: user-token → POST /v1/admin/daemons/{id}/deactivate → 403."""
settings = _make_settings()
principal = _make_user_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(
f"/v1/admin/daemons/{DAEMON_ID}/deactivate",
headers=_auth_header("user-token"),
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-053 AC-10: service-token → 403
# ---------------------------------------------------------------------------
def test_service_token_admin_crud_returns_403() -> None:
"""REQ-053 AC-10: service-token → POST /v1/admin/daemons → 403."""
settings = _make_settings()
svc_principal = _make_service_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(
"/v1/admin/daemons",
json={"daemon_slug": "test", "owner_user_id": OWNER_A_ID},
headers=_auth_header("svc-token"),
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-053 AC-11: 비인증 → 401
# ---------------------------------------------------------------------------
def test_admin_crud_no_token_returns_401() -> None:
"""REQ-053 AC-11: 비인증 → POST /v1/admin/daemons → 401."""
settings = _make_settings()
client = _make_client(settings, MagicMock())
resp = client.post("/v1/admin/daemons", json={"daemon_slug": "test", "owner_user_id": "u1"})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# REQ-053 AC-12: 미존재 daemon_id → 404
# ---------------------------------------------------------------------------
def test_admin_patch_nonexistent_daemon_returns_404() -> None:
"""REQ-053 AC-12: PATCH 미존재 daemon → 404."""
settings = _make_settings()
principal = _make_admin_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.patch(
"/v1/admin/daemons/nonexistent",
json={"display_name": "X"},
headers=_auth_header(),
)
assert resp.status_code == 404
def test_admin_deactivate_nonexistent_daemon_returns_404() -> None:
"""REQ-053 AC-12: deactivate 미존재 daemon → 404."""
settings = _make_settings()
principal = _make_admin_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(
"/v1/admin/daemons/nonexistent/deactivate",
headers=_auth_header(),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# BAT-022 QC Major: update_daemon_owner 원자성 — 부분 실패 시 credential leak 방지
# (dcy-011 OQ-3: 이전 owner의 KIS 인증정보 신규 owner에게 상속 불가)
# ---------------------------------------------------------------------------
def _make_mock_conn_for_owner_update(*, fail_on_update: bool = False):
"""
psycopg Connection 모의 객체를 반환.
conn.transaction() 컨텍스트 매니저를 지원하고, 내부 cursor 실행을 추적한다.
fail_on_update=True 이면 UPDATE daemons 실행 시 예외를 발생시켜
트랜잭션 롤백을 유발한다.
"""
from contextlib import contextmanager
from unittest.mock import MagicMock
import psycopg
executed_sqls: list[str] = []
rollback_called = False
commit_called = False
class TrackingCursor:
"""execute 호출을 기록하고 SELECT/UPDATE/DELETE에 대한 결과를 제공."""
def __init__(self) -> None:
self._last_sql = ""
self._rows: list = []
def execute(self, sql: str, params=None) -> None:
sql_stripped = sql.strip()
executed_sqls.append(sql_stripped)
if sql_stripped.startswith("SELECT"):
# daemon exists
self._rows = [("daemon-001",)]
elif sql_stripped.startswith("DELETE"):
self._rows = []
elif sql_stripped.startswith("UPDATE"):
if fail_on_update:
raise psycopg.DatabaseError("simulated UPDATE failure")
self._rows = [(
"daemon-001",
"user-b",
"trex-orb",
"ORB",
True,
datetime(2026, 6, 28, tzinfo=timezone.utc),
datetime(2026, 6, 28, tzinfo=timezone.utc),
)]
def fetchone(self):
if self._rows:
return self._rows[0]
return None
def __enter__(self):
return self
def __exit__(self, *args):
pass
class FakeTransaction:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
nonlocal rollback_called, commit_called
if exc_type is not None:
rollback_called = True
return False # re-raise
commit_called = True
return False
class FakeConn:
def transaction(self):
return FakeTransaction()
def cursor(self):
return TrackingCursor()
return FakeConn(), executed_sqls, lambda: rollback_called, lambda: commit_called
def test_update_daemon_owner_executes_delete_before_update() -> None:
"""BAT-022 QC Major / dcy-011 OQ-3: credential DELETE가 owner UPDATE보다 먼저 실행됨을 확인.
안전 순서 보장: DELETE가 먼저이므로 UPDATE 실패 시 owner 미변경+credential 없음(leak 없음).
"""
from unittest.mock import patch as _patch
fake_conn, executed_sqls, is_rollback, is_commit = _make_mock_conn_for_owner_update()
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
with _patch.object(repo, "_connect", return_value=fake_conn), \
_patch.object(repo, "ensure_schema"):
result = repo.update_daemon_owner("daemon-001", owner_user_id="user-b")
# DELETE must come before UPDATE in execution order
delete_idx = next(i for i, s in enumerate(executed_sqls) if s.startswith("DELETE"))
update_idx = next(i for i, s in enumerate(executed_sqls) if s.startswith("UPDATE"))
assert delete_idx < update_idx, (
f"DELETE must execute before UPDATE to prevent credential leak. "
f"Actual order: DELETE={delete_idx}, UPDATE={update_idx}"
)
# Owner change committed
assert is_commit()
assert result.owner_user_id == "user-b"
def test_update_daemon_owner_rollback_on_update_failure_prevents_leak() -> None:
"""BAT-022 QC Major / dcy-011 OQ-3: UPDATE daemons 실패 시 트랜잭션 롤백(rollback 경로 확인).
부분 실패 시 "새 owner + 이전 credential 잔류" 상태(credential leak)가 발생하지 않아야 함.
트랜잭션 블록 안에서 UPDATE가 실패하면 예외가 전파되고 commit은 호출되지 않는다.
"""
import psycopg
from unittest.mock import patch as _patch
fake_conn, executed_sqls, is_rollback, is_commit = _make_mock_conn_for_owner_update(fail_on_update=True)
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
with _patch.object(repo, "_connect", return_value=fake_conn), \
_patch.object(repo, "ensure_schema"):
with pytest.raises(psycopg.DatabaseError):
repo.update_daemon_owner("daemon-001", owner_user_id="user-b")
# DELETE was attempted before UPDATE
assert any(s.startswith("DELETE") for s in executed_sqls), \
"DELETE must have been attempted before UPDATE"
# UPDATE failure triggered rollback (not commit)
assert is_rollback(), "Transaction must roll back when UPDATE fails"
assert not is_commit(), "Transaction must NOT commit on UPDATE failure"
def test_update_daemon_owner_both_ops_in_single_transaction() -> None:
"""BAT-022 QC Major: DELETE + UPDATE가 단일 conn.transaction() 블록 안에서 실행됨을 확인.
두 연산이 별도 커넥션이 아닌 같은 트랜잭션 안에 묶여 있어야 원자성이 보장된다.
"""
from unittest.mock import patch as _patch, call as _call
transaction_enter_count = [0]
executed_sqls: list[str] = []
class CountingTransaction:
def __enter__(self):
transaction_enter_count[0] += 1
return self
def __exit__(self, *args):
return False
class TrackingCursor:
def execute(self, sql: str, params=None) -> None:
executed_sqls.append(sql.strip())
def fetchone(self):
sql = executed_sqls[-1] if executed_sqls else ""
if sql.startswith("SELECT"):
return ("daemon-001",)
if sql.startswith("UPDATE"):
return (
"daemon-001", "user-b", "trex-orb", "ORB", True,
datetime(2026, 6, 28, tzinfo=timezone.utc),
datetime(2026, 6, 28, tzinfo=timezone.utc),
)
return None
def __enter__(self):
return self
def __exit__(self, *args):
pass
class FakeConn:
def transaction(self):
return CountingTransaction()
def cursor(self):
return TrackingCursor()
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
with _patch.object(repo, "_connect", return_value=FakeConn()), \
_patch.object(repo, "ensure_schema"):
repo.update_daemon_owner("daemon-001", owner_user_id="user-b")
# Exactly one transaction block entered (not two separate autocommit calls)
assert transaction_enter_count[0] == 1, (
f"Expected 1 transaction block, got {transaction_enter_count[0]}. "
"DELETE and UPDATE must be inside a single transaction."
)
+444
View File
@@ -0,0 +1,444 @@
"""BAT-022: REQ-054 — 인증정보 관리 보안 격리 (write-only, secret 미반환, 로그 미노출) + REQ-051 회귀."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.core.encryption import decrypt_payload, encrypt_payload
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
from app.main import create_app
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
DAEMON_ID = "daemon-001"
OWNER_USER_ID = "user-owner"
BROKER = "kis"
ACCOUNT_REF = "acc-001"
APP_KEY = "real-app-key-secret"
APP_SECRET = "real-app-secret-value"
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_TEST_ENC_KEY,
**kwargs,
)
def _make_client(settings: Settings, mock_repo) -> TestClient:
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _auth_header(token: str = "user-token") -> dict:
return {"Authorization": f"Bearer {token}"}
def _make_daemon(owner_user_id: str = OWNER_USER_ID) -> Daemon:
return Daemon(
id=DAEMON_ID,
owner_user_id=owner_user_id,
daemon_slug="trex-orb",
display_name="ORB",
is_active=True,
)
def _make_cred(
account_ref: str = ACCOUNT_REF,
credential_version: int = 1,
app_key: str = APP_KEY,
app_secret: str = APP_SECRET,
) -> BrokerCredential:
payload_json = json.dumps({"app_key": app_key, "app_secret": app_secret})
encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json)
return BrokerCredential(
user_id=OWNER_USER_ID,
broker=BROKER,
account_ref=account_ref,
encrypted_payload=encrypted,
credential_version=credential_version,
last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc),
daemon_id=DAEMON_ID,
)
def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="tok-1",
user_id=user_id,
permission="read",
token_type="user",
)
def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal:
return TokenPrincipal(
token_id="adm-1",
user_id=user_id,
permission="admin",
token_type="admin",
)
def _make_service_principal(user_id: str = OWNER_USER_ID, daemon_id: str = DAEMON_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="svc-1",
user_id=user_id,
permission="read",
token_type="service",
runtime_id="rt-1",
daemon_id=daemon_id,
)
# ---------------------------------------------------------------------------
# REQ-054 AC-1: PUT 응답에 app_key/app_secret 없음
# ---------------------------------------------------------------------------
def test_put_credential_response_has_no_secret_fields() -> None:
"""REQ-054 AC-1: PUT 응답 JSON에 app_key, app_secret 키 없음(최상위·중첩 포함)."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
headers=_auth_header(),
)
assert resp.status_code == 200
body = resp.json()
# Recursively check no secret fields anywhere in response
body_str = json.dumps(body)
assert "app_key" not in body_str
assert "app_secret" not in body_str
assert "encrypted_payload" not in body_str
# ---------------------------------------------------------------------------
# REQ-054 AC-2: GET 응답에 app_key/app_secret 없음
# ---------------------------------------------------------------------------
def test_get_credential_meta_response_has_no_secret_fields() -> None:
"""REQ-054 AC-2: GET 응답 JSON에 app_key, app_secret 키 없음."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.get_broker_credential_by_daemon.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
assert resp.status_code == 200
body_str = json.dumps(resp.json())
assert "app_key" not in body_str
assert "app_secret" not in body_str
assert "encrypted_payload" not in body_str
# ---------------------------------------------------------------------------
# REQ-054 AC-3: admin CRUD 응답에 app_key/app_secret/credential_payload 없음
# ---------------------------------------------------------------------------
def test_admin_create_response_has_no_credential_fields() -> None:
"""REQ-054 AC-3: POST /v1/admin/daemons 응답에 app_key/app_secret/credential_payload 없음."""
settings = _make_settings()
from app.domain.models import Daemon
principal = _make_admin_principal()
daemon = Daemon(
id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb",
display_name="ORB", is_active=True,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.create_daemon.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.post(
"/v1/admin/daemons",
json={"daemon_slug": "trex-orb", "owner_user_id": OWNER_USER_ID},
headers=_auth_header("admin-token"),
)
assert resp.status_code == 201
body_str = json.dumps(resp.json())
assert "app_key" not in body_str
assert "app_secret" not in body_str
assert "credential_payload" not in body_str
def test_admin_patch_response_has_no_credential_fields() -> None:
"""REQ-054 AC-3: PATCH /v1/admin/daemons/{id} 응답에 app_key/app_secret/credential_payload 없음."""
settings = _make_settings()
from app.domain.models import Daemon
principal = _make_admin_principal()
daemon = Daemon(
id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb",
display_name="NEW", is_active=True,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.update_daemon.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.patch(
f"/v1/admin/daemons/{DAEMON_ID}",
json={"display_name": "NEW"},
headers=_auth_header("admin-token"),
)
assert resp.status_code == 200
body_str = json.dumps(resp.json())
assert "app_key" not in body_str
assert "app_secret" not in body_str
assert "credential_payload" not in body_str
# ---------------------------------------------------------------------------
# REQ-054 AC-4: caplog — PUT 처리 중 로그에 app_key/app_secret 값 미포함
# ---------------------------------------------------------------------------
def test_put_credential_log_does_not_expose_secret(caplog) -> None:
"""REQ-054 AC-4: PUT 처리 중 로그에 실제 app_key/app_secret 값 미포함."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred(app_key=APP_KEY, app_secret=APP_SECRET)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred
with caplog.at_level(logging.DEBUG, logger="app.api.v1_daemon_credential"), \
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
headers=_auth_header(),
)
log_text = " ".join(caplog.messages)
# Actual values must not appear in logs
assert APP_KEY not in log_text, f"app_key found in logs: {log_text}"
assert APP_SECRET not in log_text, f"app_secret found in logs: {log_text}"
# ---------------------------------------------------------------------------
# REQ-054 AC-5: DB に암호화 저장 확인 (encrypted_payload가 gAAAA... 형태인지 upsert 호출 인수로 검증)
# ---------------------------------------------------------------------------
def test_put_credential_stores_encrypted_payload() -> None:
"""REQ-054 AC-5: PUT 시 Fernet 암호화된 payload를 DB에 저장함 확인."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred()
captured_encrypted = {}
def mock_upsert(user_id, broker, *, account_ref, encrypted_payload, daemon_id=None):
captured_encrypted["payload"] = encrypted_payload
return cred
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.side_effect = mock_upsert
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
headers=_auth_header(),
)
assert resp.status_code == 200
# The stored payload must be a Fernet token (starts with gAAAA)
stored = captured_encrypted["payload"]
assert stored.startswith("gAAAA"), f"Not a Fernet token: {stored[:20]}"
# Must not be plaintext
assert APP_KEY not in stored
assert APP_SECRET not in stored
# Verify it actually decrypts correctly
decrypted = json.loads(decrypt_payload(_TEST_ENC_KEY, stored))
assert decrypted["app_key"] == APP_KEY
assert decrypted["app_secret"] == APP_SECRET
# ---------------------------------------------------------------------------
# REQ-054 AC-6: PUT 이후 service-token GET /v1/user_info → correct account_ref returned
# (유일 decrypt 경로 불변 확인)
# ---------------------------------------------------------------------------
def test_put_then_service_token_user_info_returns_correct_account_ref() -> None:
"""REQ-054 AC-6: PUT 후 service-token /v1/user_info에서 동일 account_ref 반환 (decrypt 경로 불변)."""
settings = _make_settings()
# 1. PUT credential
user_principal = _make_user_principal()
daemon = _make_daemon()
cred_stored = _make_cred(account_ref="my-account-789", app_key=APP_KEY, app_secret=APP_SECRET)
mock_repo_put = MagicMock()
mock_repo_put.get_token_principal.return_value = user_principal
mock_repo_put.get_daemon_by_id.return_value = daemon
mock_repo_put.upsert_broker_credential.return_value = cred_stored
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_put), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo_put):
client_put = _make_client(settings, mock_repo_put)
resp_put = client_put.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account-789", "broker": BROKER},
headers=_auth_header("user-token"),
)
assert resp_put.status_code == 200
# 2. GET /v1/user_info via service-token
svc_principal = _make_service_principal()
user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True)
mock_repo_svc = MagicMock()
mock_repo_svc.get_token_principal.return_value = svc_principal
mock_repo_svc.get_user.return_value = user
mock_repo_svc.list_broker_credentials.return_value = [cred_stored]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_svc), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_svc):
client_svc = _make_client(settings, mock_repo_svc)
resp_svc = client_svc.get("/v1/user_info", headers={"Authorization": "Bearer svc-token"})
assert resp_svc.status_code == 200
body = resp_svc.json()
assert body["broker"] is not None
assert body["broker"]["account_ref"] == "my-account-789"
# credential_payload must be decrypted and returned on service-token path
assert body["broker"]["credential_payload"] is not None
assert body["broker"]["credential_payload"]["app_key"] == APP_KEY
assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET
# ---------------------------------------------------------------------------
# REQ-054 AC-7: REQ-051 회귀 — 신규 API 추가 후 기존 service-token 보안 격리 PASS
# ---------------------------------------------------------------------------
def test_req051_regression_web_token_user_info_still_403() -> None:
"""REQ-054 AC-7 / REQ-051 AC-1 회귀: web token → /v1/user_info → 403 (불변)."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id=OWNER_USER_ID, permission="read",
token_type="user", daemon_id=DAEMON_ID,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer web-token"})
assert resp.status_code == 403
def test_req051_regression_service_token_without_daemon_id_403() -> None:
"""REQ-054 AC-7 / REQ-051 AC-3 회귀: service-token without daemon_id → 403."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id=OWNER_USER_ID, permission="read",
token_type="service", daemon_id=None,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer svc-no-daemon"})
assert resp.status_code == 403
def test_req051_regression_unauthenticated_401() -> None:
"""REQ-054 AC-7 / REQ-051 AC-2 회귀: 비인증 → 401."""
settings = _make_settings()
client = _make_client(settings, MagicMock())
resp = client.get("/v1/user_info")
assert resp.status_code == 401
def test_req051_regression_service_token_with_daemon_returns_broker() -> None:
"""REQ-054 AC-7 / REQ-051 AC-1 회귀: service-token + daemon_id → 200 + broker (불변)."""
settings = _make_settings()
svc_principal = _make_service_principal()
user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True)
cred = _make_cred()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [cred]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
body = resp.json()
assert body["broker"] is not None
assert body["broker"]["account_ref"] == ACCOUNT_REF
assert body["broker"]["credential_payload"]["app_key"] == APP_KEY
assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET
def test_req051_regression_other_endpoints_no_credential_exposure() -> None:
"""REQ-054 AC-7 / REQ-051 AC-5 회귀: /health 등에 credential 필드 없음."""
settings = _make_settings()
client = TestClient(create_app(settings_factory=lambda: settings))
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert "app_key" not in body
assert "app_secret" not in body
assert "credential_payload" not in body
assert "encrypted_payload" not in body