Merge pull request #17 from fourblocks2020/feat/dcy-010-daemon-scoped-broker-credential

dcy-010: daemon-scoped 브로커(KIS) 인증정보 — BAT-020 (REQ-046~051)
This commit is contained in:
2026-06-28 07:43:30 +09:00
committed by GitHub
28 changed files with 2783 additions and 88 deletions
+35 -17
View File
@@ -31,41 +31,59 @@ def get_user_info(
auth: AuthContext = Depends(_require_service_token),
settings: Settings = Depends(get_settings),
) -> UserInfoResponse:
# REQ-049, REQ-051: daemon_id 없으면 403 (daemon-scoped 인가 규율)
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id binding required",
)
repo = get_portfolio_repository_from_settings(settings)
try:
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
# Fetch broker credentials and decrypt
brokers: list[BrokerItem] = []
# REQ-049: daemon_id로 단일 broker 인증정보 조회 및 복호화
broker_item: BrokerItem | None = None
try:
credentials = repo.list_broker_credentials(auth.user_id) # type: ignore[arg-type]
enc_key: str | None = None
for cred in credentials:
credentials = repo.list_broker_credentials( # type: ignore[call-arg]
auth.user_id, # type: ignore[arg-type]
daemon_id=auth.daemon_id,
)
if credentials:
cred = credentials[0]
credential_payload = None
try:
if enc_key is None:
enc_key = settings.require_credential_encryption_key()
enc_key = settings.require_credential_encryption_key()
raw = decrypt_payload(enc_key, cred.encrypted_payload)
credential_payload = json.loads(raw)
except (InvalidToken, RuntimeError, Exception) as exc:
logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc)
brokers.append(BrokerItem(
# REQ-051 AC-8: 로그에 app_key, app_secret 값 미포함
# exc는 Fernet 토큰 오류 메시지만 포함하며 credential 값을 담지 않는다.
logger.warning(
"credential decryption failed for broker %s daemon %s: %s",
cred.broker,
auth.daemon_id,
type(exc).__name__,
)
broker_item = BrokerItem(
broker=cred.broker,
account_ref=cred.account_ref,
credential_payload=credential_payload,
credential_version=cred.credential_version,
last_rotated_at=cred.last_rotated_at,
))
)
except AttributeError:
# repo does not implement list_broker_credentials yet (mock or older impl)
pass
except Exception as exc: # pragma: no cover - defensive
# Broker credentials are supplementary. A failure here (e.g. missing
# broker_credentials table, DB error) must not crash /v1/user_info —
# the critical fields (daemon_id, user_id, permission) do not depend
# on broker credentials. Degrade gracefully with an empty broker list.
logger.warning("broker credential lookup failed; returning empty brokers: %s", exc)
brokers = []
# REQ-049, REQ-051: broker 인증정보 조회 실패는 broker=null로 graceful degrade.
# daemon_id, trading_enabled 필수 필드는 영향 없음.
logger.warning(
"broker credential lookup failed for daemon %s; returning broker=null: %s",
auth.daemon_id,
type(exc).__name__,
)
broker_item = None
finally:
if callable(getattr(type(repo), "close", None)):
@@ -80,7 +98,7 @@ def get_user_info(
daemon_id=auth.daemon_id,
trading_enabled=user.is_active,
disabled_reason=disabled_reason,
brokers=brokers,
broker=broker_item,
user_id=user.id,
username=user.username,
email=user.email,
+111 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from typing import Optional
import typer
@@ -28,10 +29,25 @@ def _with_service(ctx: typer.Context, callback):
repository.close()
def _resolve_daemon_id(repository, owner_user_id: str, daemon_id_input: str) -> str:
"""daemon_slug 또는 UUID로 daemon.id(UUID)를 반환한다. 없으면 ValueError."""
# 먼저 owner + slug로 시도
daemon = None
if hasattr(repository, "get_daemon_by_slug"):
daemon = repository.get_daemon_by_slug(owner_user_id, daemon_id_input)
# slug로 못 찾으면 UUID 직접 시도
if daemon is None and hasattr(repository, "get_daemon_by_id"):
daemon = repository.get_daemon_by_id(daemon_id_input)
if daemon is None:
raise ValueError(f"데몬을 찾을 수 없습니다: {daemon_id_input}")
return daemon.id
@credential_app.command("set")
def set_credential(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon slug or ID. If omitted, credential is stored without daemon binding (backfill required later)."),
broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."),
account_ref: str = typer.Option(..., "--account-ref", help="Account reference (e.g. account number)."),
app_key: str = typer.Option(..., "--app-key", help="Broker app_key."),
@@ -45,6 +61,16 @@ def set_credential(
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
else:
app_context.console.print(
"[yellow]WARN: --daemon-id 미지정. daemon_id 없이 등록된 인증정보는 "
"REQ-047 Step2 PK 전환 후 backfill이 필요합니다.[/yellow]"
)
enc_key = app_context.settings.require_credential_encryption_key()
payload = json.dumps({"app_key": app_key, "app_secret": app_secret})
encrypted = encrypt_payload(enc_key, payload)
@@ -53,6 +79,7 @@ def set_credential(
broker,
account_ref=account_ref,
encrypted_payload=encrypted,
daemon_id=resolved_daemon_id,
)
result = _with_service(ctx, _callback)
@@ -62,6 +89,7 @@ def set_credential(
ctx.obj.output_format,
{
"user_id": result.user_id,
"daemon_id": result.daemon_id,
"broker": result.broker,
"account_ref": result.account_ref,
"credential_version": result.credential_version,
@@ -74,6 +102,7 @@ def set_credential(
def list_credentials(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Filter by daemon slug or ID (optional)."),
) -> None:
"""List broker credentials for a user (payload not shown)."""
@@ -81,9 +110,15 @@ def list_credentials(
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
records = repository.list_broker_credentials(owner.id)
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
records = repository.list_broker_credentials(owner.id, daemon_id=resolved_daemon_id)
return [
{
"daemon_id": r.daemon_id,
"broker": r.broker,
"account_ref": r.account_ref,
"credential_version": r.credential_version,
@@ -107,6 +142,7 @@ def list_credentials(
def remove_credential(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon slug or ID. If provided, deletes by (daemon_id, broker). Otherwise, deletes by (user_id, broker)."),
broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
@@ -117,7 +153,12 @@ def remove_credential(
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
deleted = repository.delete_broker_credential(owner.id, broker)
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
deleted = repository.delete_broker_credential(owner.id, broker, daemon_id=resolved_daemon_id)
if not deleted:
raise ValueError(f"인증 정보를 찾을 수 없습니다: user={user} broker={broker}")
return {"deleted": True, "user": user, "broker": broker}
@@ -125,3 +166,71 @@ def remove_credential(
result = _with_service(ctx, _callback)
if result is not None:
render_output(ctx.obj.console, ctx.obj.output_format, result)
@credential_app.command("backfill-daemon-id")
def backfill_daemon_id(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: str = typer.Option(..., "--daemon-id", help="Daemon slug or ID to assign to this user's NULL daemon_id credentials."),
broker: Optional[str] = typer.Option(None, "--broker", help="Specific broker to backfill (default: all NULL daemon_id credentials for the user)."),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be updated without making changes."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
"""Backfill daemon_id for existing user-scoped broker credentials (REQ-048).
Sets daemon_id on broker_credentials rows where daemon_id IS NULL.
Run after REQ-047 Step1 migration and before Step2 PK conversion.
"""
def _callback(repository, app_context):
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
# daemon_id IS NULL인 인증정보 조회 (list_broker_credentials는 daemon_id 필터 없이 전체 조회)
# NULL인 행을 찾기 위해 전체 조회 후 필터
all_creds = repository.list_broker_credentials(owner.id)
null_creds = [c for c in all_creds if c.daemon_id is None]
if broker is not None:
null_creds = [c for c in null_creds if c.broker == broker]
if not null_creds:
app_context.console.print("이미 완료됨: daemon_id IS NULL인 인증정보가 없습니다.")
return {"status": "already_complete", "updated": 0}
if dry_run:
app_context.console.print(f"[dry-run] 대상 {len(null_creds)}건:")
for c in null_creds:
app_context.console.print(
f" user_id={c.user_id} broker={c.broker} → daemon_id={resolved_daemon_id}"
)
return {"status": "dry_run", "would_update": len(null_creds)}
_confirm(force, f"user '{user}'의 {len(null_creds)}건 인증정보에 daemon_id={resolved_daemon_id}를 설정하시겠습니까?")
updated = 0
for c in null_creds:
if hasattr(repository, "update_broker_credential_daemon_id"):
ok = repository.update_broker_credential_daemon_id(c.user_id, c.broker, resolved_daemon_id)
else:
# fallback: 직접 upsert (기존 payload 유지)
repository.upsert_broker_credential(
c.user_id,
c.broker,
account_ref=c.account_ref,
encrypted_payload=c.encrypted_payload,
daemon_id=resolved_daemon_id,
)
ok = True
if ok:
updated += 1
app_context.console.print(f"백필 완료: {updated}건 업데이트됨")
return {"status": "complete", "updated": updated}
result = _with_service(ctx, _callback)
if result is not None:
render_output(ctx.obj.console, ctx.obj.output_format, result)
+1
View File
@@ -182,4 +182,5 @@ class BrokerCredential:
encrypted_payload: str
credential_version: int = 1
last_rotated_at: datetime | None = None
daemon_id: str | None = None
+111 -26
View File
@@ -474,6 +474,7 @@ class PostgresPortfolioRepository:
encrypted_payload=row[3],
credential_version=int(row[4]) if len(row) > 4 else 1,
last_rotated_at=row[5] if len(row) > 5 else None,
daemon_id=row[6] if len(row) > 6 else None,
)
@staticmethod
@@ -739,50 +740,134 @@ class PostgresPortfolioRepository:
*,
account_ref: str,
encrypted_payload: str,
daemon_id: str | None = None,
) -> BrokerCredential:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload)
VALUES (%s, %s, %s, %s)
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(user_id, broker, account_ref, encrypted_payload),
)
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 마이그레이션 이후 존재.
# Step1 적용 전 환경에서는 daemon_id 컬럼이 없어 INSERT 실패 가능.
# Step2(PK 전환) 이후에는 ON CONFLICT 절도 (daemon_id, broker)로 변경 필요.
# 현재는 기존 PK (user_id, broker) ON CONFLICT 유지하고 daemon_id를 컬럼에 포함.
try:
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, daemon_id)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
daemon_id = EXCLUDED.daemon_id,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
""",
(user_id, broker, account_ref, encrypted_payload, daemon_id),
)
except Exception:
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload)
VALUES (%s, %s, %s, %s)
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(user_id, broker, account_ref, encrypted_payload),
)
row = cur.fetchone()
return self._row_to_broker_credential(row)
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
def list_broker_credentials(
self,
user_id: str,
daemon_id: str | None = None,
) -> list[BrokerCredential]:
self.ensure_schema()
with self._connect().cursor() as cur:
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 이후 존재.
# daemon_id 컬럼이 없는 환경(Step1 미적용)에서는 6컬럼 조회 fallback.
try:
if daemon_id is not None:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
FROM broker_credentials
WHERE user_id = %s AND daemon_id = %s
ORDER BY broker ASC
""",
(user_id, daemon_id),
)
else:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
FROM broker_credentials
WHERE user_id = %s
ORDER BY broker ASC
""",
(user_id,),
)
rows = cur.fetchall()
except Exception:
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s
ORDER BY broker ASC
""",
(user_id,),
)
rows = cur.fetchall()
return [self._row_to_broker_credential(row) for row in rows]
def update_broker_credential_daemon_id(
self,
user_id: str,
broker: str,
daemon_id: str,
) -> bool:
"""백필용: 특정 (user_id, broker) 인증정보에 daemon_id를 설정한다 (REQ-048)."""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s
ORDER BY broker ASC
UPDATE broker_credentials
SET daemon_id = %s
WHERE user_id = %s AND broker = %s AND daemon_id IS NULL
RETURNING user_id
""",
(user_id,),
(daemon_id, user_id, broker),
)
rows = cur.fetchall()
return [self._row_to_broker_credential(row) for row in rows]
row = cur.fetchone()
return row is not None
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
def delete_broker_credential(
self,
user_id: str,
broker: str,
daemon_id: str | None = None,
) -> bool:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id",
(user_id, broker),
)
if daemon_id is not None:
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s AND broker = %s RETURNING user_id",
(daemon_id, broker),
)
else:
cur.execute(
"DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id",
(user_id, broker),
)
row = cur.fetchone()
return row is not None
+1 -1
View File
@@ -96,7 +96,7 @@ class UserInfoResponse(BaseModel):
daemon_id: str | None = None
trading_enabled: bool
disabled_reason: str | None = None
brokers: list[BrokerItem] = []
broker: BrokerItem | None = None
# Additional informational fields
user_id: str
username: str
@@ -0,0 +1,89 @@
# BAT-020 Design
## Design 위임 2건 확정
### D-1: REQ-048 백필 구현방식 — CLI subcommand vs alembic data migration revision
**결정: CLI subcommand (`pfhub credential backfill-daemon-id`)**
근거:
1. **운영자 제공 매핑 필요**: 어떤 user가 어떤 daemon_id에 매핑되는가는 인프라/운영 도메인 지식이 필요하며 자동화 불가 (REQ-048 Notes). alembic data migration revision은 매핑을 하드코딩하거나 env 변수에서 읽어야 해 유연성이 낮다.
2. **--dry-run 필수 (REQ-048 AC-3)**: CLI subcommand는 typer option으로 dry-run을 자연스럽게 표현하고, 운영자가 실행 전 확인 후 적용 가능. alembic revision에 dry-run을 구현하면 비표준 패턴이 된다.
3. **기존 CLI 패턴과 일관성**: 현재 `pfhub credential set/list/remove`가 이미 CLI로 구현돼 있으며, `--user` → user_id 변환 패턴을 그대로 활용 가능 (`get_user_by_username`).
4. **반복 실행 가능**: 1회성 운영 작업이지만 개발 환경에서 반복 실행 가능하도록 CLI가 더 적합. alembic은 이미 적용된 revision을 재실행하지 않아 개발 테스트에 불편.
구현: `pfhub credential backfill-daemon-id --user <username> --daemon-id <daemon_slug_or_id> [--dry-run] [--force]`
---
### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책
**결정 (1): `--daemon-id`는 daemon_slug를 받는다 (UUID도 허용, 내부에서 해석)**
근거:
- 기존 `daemons` 테이블에 `daemon_slug` (TEXT, UNIQUE per owner)와 `id` (UUID TEXT) 두 식별자가 있다.
- 기존 CLI 패턴: `--user`가 username(사람이 읽기 쉬운 식별자)을 받아 내부에서 `get_user_by_username()`으로 user_id 변환. 이 패턴과 동일하게 `--daemon-id`도 daemon_slug를 우선 받는다.
- `get_daemon_by_slug(owner_user_id, daemon_slug)` 메서드가 이미 존재함 (postgres.py:2204). `--user`로 먼저 owner user를 조회하고, 그 user_id + daemon_slug로 daemon을 조회한다.
- UUID 형식이면 `get_daemon_by_id()`로도 조회 시도 가능하나, 이 batch에서는 **daemon_slug 우선 + user 소유 검증**으로 단순화한다. UUID 직접 지정은 미지원(오운 user에서 slug로 찾지 못하면 에러).
- 입력 파라미터 이름은 `--daemon-id`로 유지(REQ-050 명세)하되, 값은 daemon_slug로 해석.
**결정 (2): REQ-047 과도기(daemon_id nullable 구간)의 --daemon-id 필수/선택 정책**
- `pfhub credential set` — REQ-047 Step1 이후(nullable 구간): `--daemon-id` 미제공 시에도 upsert 허용 (기존 user-scoped 동작 유지). **단, 경고 메시지 출력**: "daemon_id 없이 등록된 인증정보는 REQ-047 Step2 PK 전환 후 backfill이 필요합니다."
- 이유: 단계적 마이그레이션 과도기에 운영자가 기존 방식으로도 인증정보를 등록할 수 있어야 함. Step2 PK 전환 이후에는 daemon_id NOT NULL 제약이 DB 레벨에서 강제된다.
- REQ-050 AC-6 ("--daemon-id 미제공 시 exit 1")은 REQ-047 Step2 PK 전환 완료 이후 상태 기준. 이 batch에서는 미제공 시 경고만 출력하고 허용. (AC-6은 Step2 이후 환경에서 DB 제약으로 자동 강제됨)
- `pfhub credential remove` — `--daemon-id` 제공 시 (daemon_id, broker) 기반 삭제, 미제공 시 기존 (user_id, broker) 기반 삭제 (하위 호환).
- `pfhub credential list` — `--daemon-id` 제공 시 해당 daemon_id 필터, 미제공 시 user 전체 (기존 동작).
---
## 주요 설계 결정
### DS-1: UserInfoResponse.brokers → broker 단일화 (REQ-049)
- `brokers: list[BrokerItem]` 필드 제거, `broker: BrokerItem | None = None` 추가
- 기존 소비자(trex-core hub_read.whoami)가 `daemon_id`만 읽고 `brokers` 미참조 → 파괴적 변경이나 실질 영향 없음 (dcy-010 OQ-2)
- 이 batch의 기존 테스트 중 `body["brokers"]`를 참조하는 테스트는 `body["broker"]`로 갱신 필요
### DS-2: daemon_id 없음 → 403 (REQ-049, REQ-051)
- `auth.daemon_id is None`이면 HTTP 403 Forbidden 반환
- 기존 `_require_service_token()` 함수는 token_type 검사만 함 → daemon_id 검사는 엔드포인트 핸들러 초입에서 추가
### DS-3: graceful degrade 계층 (REQ-049, REQ-051)
- `list_broker_credentials` DB 오류 → except → `broker=None` 반환 (기존 패턴 유지, 확장)
- 복호화 실패 → `credential_payload=None`인 BrokerItem 반환 (기존 패턴 유지)
- 어느 단계에서도 `app_key`, `app_secret` 값은 로그에 출력하지 않음
### DS-4: _row_to_broker_credential 하위호환 (REQ-046 AC-4)
- 기존 DB(daemon_id 컬럼 없음): 6컬럼 행 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
- REQ-047 Step1 이후: 7컬럼 행 (위 + daemon_id)
- `_row_to_broker_credential(row)` — `len(row) > 6`이면 `row[6]`을 daemon_id로 읽고, 없으면 None. 단, SELECT 쿼리에서 daemon_id를 명시적으로 포함하는 것이 더 안전. 따라서 쿼리에 daemon_id 컬럼을 추가하되, 컬럼 미존재 시 에러가 나지 않도록 Step1 revision 이후에만 활성화.
- 실용적 접근: 현재 `_SELECT_*` 상수 방식이 아니라 inline SQL이므로, `list_broker_credentials`에서 daemon_id를 선택적으로 포함하는 것이 복잡. 대신 `_row_to_broker_credential`에서 인덱스 기반 조회 시 `len(row) > 6`으로 분기.
- 단, `list_broker_credentials` 쿼리에 daemon_id 컬럼이 없으면 row에 포함되지 않아 항상 6컬럼 → daemon_id 항상 None. 이는 Step1 이전에는 올바른 동작이나 Step1 이후엔 정보 손실.
- **최종 결정**: `list_broker_credentials` 및 `upsert_broker_credential` 쿼리에 `daemon_id` 컬럼을 포함하고, `_row_to_broker_credential`이 7컬럼 행을 처리. 단 ensure_schema의 broker_credentials CREATE TABLE은 수정하지 않음 (Step2 이후 ensure_schema 갱신은 별도 주석).
### DS-5: upsert_broker_credential ON CONFLICT 처리 (REQ-046 Notes)
- REQ-047 Step1 이후 daemon_id 컬럼 존재하나 PK는 여전히 (user_id, broker) → ON CONFLICT 대상 불변
- REQ-047 Step2 이후 PK가 (daemon_id, broker)로 변경 → ON CONFLICT 절이 달라짐
- 이 batch에서는 Step2 이후 ON CONFLICT 절은 TODO 주석으로 표시하고, 현재는 기존 (user_id, broker) ON CONFLICT 유지 + daemon_id INSERT 컬럼 추가
### DS-6: backfill CLI `get_daemon` 조회 방식 (REQ-048)
- `--daemon-id <value>`: daemon_slug로 먼저 시도 (`get_daemon_by_slug(owner_user_id, value)`), 실패 시 `get_daemon_by_id(value)` 재시도
- `--user <username>`으로 user_id를 먼저 조회한 후 get_daemon_by_slug 호출
- 단, backfill은 user-scoped 인증정보를 특정 daemon에 할당하는 작업이므로 `--user`와 `--daemon-id`를 함께 받음
## 기존 테스트 영향 분석
| 테스트 | 영향 | 조치 |
| --- | --- | --- |
| `test_v1.py::test_v1_user_info_returns_user` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 |
| `test_v1.py::test_v1_user_info_returns_decrypted_broker_credentials` | `body["brokers"]` 리스트 참조 | `body["broker"]` 단일 필드 검증으로 변경 |
| `test_v1.py::test_v1_user_info_degrades_when_broker_credentials_lookup_fails` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 |
| `test_cli.py::FakeRepo.upsert_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 |
| `test_cli.py::FakeRepo.list_broker_credentials` | daemon_id 필터 없음 | daemon_id 필터 추가 |
| `test_cli.py::FakeRepo.delete_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 |
@@ -0,0 +1,30 @@
# BAT-020 Implementation
## 변경 파일 요약
| 파일 | 변경 | REQ |
| --- | --- | --- |
| `app/domain/models.py` | BrokerCredential.daemon_id: str | None = None 추가 | REQ-046 |
| `app/repositories/postgres.py` | _row_to_broker_credential 7컬럼 지원, upsert/list/delete daemon_id 파라미터, update_broker_credential_daemon_id 메서드 신규 | REQ-046, REQ-048 |
| `app/schemas/v1.py` | UserInfoResponse: brokers list 제거, broker: BrokerItem | None = None 추가 | REQ-049 |
| `app/api/v1_user_info.py` | daemon_id=None → 403, daemon_id 기반 단일 broker 조회, 로그 개선(app_secret 미노출) | REQ-049, REQ-051 |
| `app/cli/commands/credential.py` | set/list/remove --daemon-id 옵션, backfill-daemon-id subcommand | REQ-048, REQ-050 |
| `migrations/versions/20260628_0026_*.py` | broker_credentials daemon_id TEXT NULL 컬럼 추가 (Step1) | REQ-047 |
| `migrations/versions/20260628_0027_*.py` | broker_credentials PK 전환 (Step2, 사장님 결재 후 적용) | REQ-047 |
| `tests/test_bat020_broker_credential_domain.py` | REQ-046 AC-1~6 단위 테스트 신규 | REQ-046 |
| `tests/test_bat020_user_info_daemon_scoped.py` | REQ-049/051 AC 전체 테스트 신규 | REQ-049, REQ-051 |
| `tests/test_bat020_credential_cli.py` | REQ-048/050 AC 전체 테스트 신규 | REQ-048, REQ-050 |
| `tests/test_v1.py` | brokers→broker 반영, daemon_id=None → 403 반영, 기존 테스트 갱신 | REQ-049 |
| `tests/test_cli.py` | FakeRepo 시그니처 갱신, credential 플로우 테스트 --daemon-id 포함 | REQ-050 |
| `tests/test_daemon_assignment.py` | daemon_id=None → 403 정책 갱신 | REQ-049 |
## Design 위임 확정 결과
### D-1: REQ-048 구현방식 → CLI subcommand 선택
- `pfhub credential backfill-daemon-id --user <username> --daemon-id <slug> [--dry-run] [--force] [--broker <broker>]`
- 근거: 운영자 제공 매핑 필요, --dry-run 자연스러운 구현, 기존 CLI 패턴과 일관성
### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책
- daemon_slug 우선 (`get_daemon_by_slug(owner_user_id, slug)`) + UUID 폴백 (`get_daemon_by_id`)
- 과도기(Step1 완료, Step2 미완료): `--daemon-id` 미제공 시 경고 출력 + 허용
- Step2 PK 전환 후: DB NOT NULL 제약으로 자동 강제
@@ -0,0 +1,39 @@
# BAT-020 — daemon-scoped 브로커 인증정보
- Batch ID: BAT-020
- Profile: standard
- Status: in-progress
- Discovery: dcy-010
- Included REQ: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051
- Folder: docs/batches/bat-020_20260628_daemon-scoped-broker-credential/
- Created: 2026-06-28
## Summary
dcy-010(daemon-scoped 브로커 인증정보 소유·전달)을 portfolio-hub에 구현한다.
BrokerCredential 도메인 모델에 daemon_id 필드 추가, broker_credentials 테이블 PK 전환 마이그레이션(Step1/Step2), 백필 CLI, GET /v1/user_info daemon-scoped 단일 broker 전달, CLI --daemon-id 옵션, 보안 격리 검증.
## Included REQ
| REQ | 제목 | Type | 의존 |
| --- | --- | --- | --- |
| REQ-046 | BrokerCredential daemon_id 필드 추가 | Data | 없음 |
| REQ-047 | broker_credentials PK 전환 마이그레이션 | Migration | REQ-046 |
| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | REQ-047 Step1 |
| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달 | Interface | REQ-046, REQ-047 |
| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | REQ-046, REQ-047 |
| REQ-051 | 브로커 인증정보 보안 격리 | Non-Functional | REQ-049 |
## DoR (진입 조건)
- [x] 6개 REQ 전건 Approved 상태 확인
- [x] dcy-010 confirmed Discovery 확인
- [x] 구현 대상 파일 존재 확인(app/domain/models.py, app/repositories/postgres.py, app/api/v1_user_info.py, app/schemas/v1.py, app/cli/commands/credential.py, migrations/versions/)
- [x] design 위임 2건 식별 완료(REQ-048 구현방식, REQ-050 --daemon-id 입력 타입·과도기 정책)
## DoD (완료 조건)
- [ ] REQ-046~051 전 AC 테스트 통과
- [ ] alembic revision Step1/Step2 작성 완료(dev DB 검증)
- [ ] 기존 테스트 회귀 없음
- [ ] draft-batch-verification 문서에 evidence 기록
## 비가역 결재 게이트
REQ-047 Step2(PK 전환) 실DB 적용은 사장님 결재 대상. 이 batch에서는 revision 코드 작성 + dev/test DB 검증까지만 수행.
@@ -0,0 +1,73 @@
# BAT-020 Planning
## 구현 의존 순서
1. **REQ-046** — BrokerCredential 도메인 모델 + repository 시그니처 변경 (daemon_id 필드)
2. **REQ-047 Step1** — alembic revision: broker_credentials에 daemon_id TEXT NULL 컬럼 추가
3. **REQ-048** — backfill-daemon-id CLI subcommand 추가
4. **REQ-047 Step2** — alembic revision: PK (user_id,broker) → (daemon_id,broker) 전환 (dev/test DB만)
5. **REQ-049** — GET /v1/user_info daemon-scoped 단일 broker 전달 (스키마 + 엔드포인트)
6. **REQ-050** — credential CLI --daemon-id 옵션 추가 (set/list/remove)
7. **REQ-051** — 보안 격리 테스트: service-token 전용, web 토큰 차단, 로그 비노출 검증
## Task Breakdown
### T-1: REQ-046 모델·repository 변경
- `app/domain/models.py` — `BrokerCredential.daemon_id: str | None = None` 추가
- `app/repositories/postgres.py` — `_row_to_broker_credential` daemon_id 처리 (7컬럼/6컬럼 하위호환)
- `app/repositories/postgres.py` — `upsert_broker_credential(daemon_id=None)` 파라미터 추가
- `app/repositories/postgres.py` — `list_broker_credentials(daemon_id=None)` 파라미터 + 필터 추가
- `app/repositories/postgres.py` — `delete_broker_credential(daemon_id=None)` 파라미터 추가
- Tests: REQ-046 AC-1~6 단위 테스트 신규
### T-2: REQ-047 Step1 alembic revision
- `migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py`
- 내용: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가 + downgrade(컬럼 제거, PK 복원)
- Tests: AC-1 (컬럼 존재), AC-2 (기존 데이터 NULL 보존), AC-6 (downgrade)
### T-3: REQ-048 backfill CLI
- `app/cli/commands/credential.py` — `backfill-daemon-id` subcommand 추가
- Tests: AC-1~6 단위 테스트 (mock repo 기반)
### T-4: REQ-047 Step2 alembic revision
- `migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py`
- 내용: PK 전환 (user_id,broker) → (daemon_id,broker), daemon_id NOT NULL 제약, downgrade 안전장치
- `app/repositories/postgres.py` — `ensure_schema()` broker_credentials 정의 최종 스키마로 갱신 (Step2 이후 활성화 주석)
- Tests: AC-3~5 (dev DB에서 Step1→백필→Step2 순서 검증), AC-7 (downgrade 안전장치)
### T-5: REQ-049 user_info daemon-scoped 확장
- `app/schemas/v1.py` — `UserInfoResponse.broker: BrokerItem | None = None`, `brokers: list` 제거
- `app/api/v1_user_info.py` — daemon_id 없으면 403, daemon_id 기반 list_broker_credentials 호출, 단일 broker 반환
- Tests: REQ-049 AC-1~9 (daemon_id 있음/없음, broker 있음/없음, 2데몬 격리, 복호화 실패, 회귀)
### T-6: REQ-050 credential CLI --daemon-id
- `app/cli/commands/credential.py` — set/list/remove 에 `--daemon-id` 옵션 추가
- `tests/test_cli.py` FakeRepo — upsert/list/delete에 daemon_id 파라미터 반영
- Tests: REQ-050 AC-1~7
### T-7: REQ-051 보안 격리 테스트
- web 토큰 403 (AC-1), 비인증 401 (AC-2), daemon_id=None 403 (AC-3), 타데몬 null (AC-4)
- 타 엔드포인트 credential 미노출 (AC-5)
- DB 오류 graceful degrade (AC-6), 복호화 실패 degrade (AC-7)
- 로그 비노출 (AC-8, caplog fixture)
## 파일 변경 대상 요약
| 파일 | 변경 내용 |
| --- | --- |
| `app/domain/models.py` | BrokerCredential.daemon_id 추가 |
| `app/repositories/postgres.py` | _row_to_broker_credential, upsert/list/delete 시그니처, ensure_schema broker_credentials |
| `app/schemas/v1.py` | UserInfoResponse brokers→broker 단일화 |
| `app/api/v1_user_info.py` | daemon-scoped 조회, daemon_id 없음 403, 로그 비노출 확인 |
| `app/cli/commands/credential.py` | set/list/remove --daemon-id, backfill-daemon-id subcommand |
| `migrations/versions/20260628_0026_*.py` | Step1 revision |
| `migrations/versions/20260628_0027_*.py` | Step2 revision |
| `tests/test_v1.py` | user_info 테스트 갱신 + 신규(daemon-scoped, 보안) |
| `tests/test_cli.py` | credential CLI 테스트 갱신 + 신규 |
| `tests/test_bat020_broker_credential_domain.py` | REQ-046 단위 테스트 신규 |
| `tests/test_bat020_broker_credential_migration.py` | REQ-047 마이그레이션 테스트 신규 |
## 위험
- REQ-049: `brokers` → `broker` 응답 변경으로 기존 `test_v1.py`의 `brokers` 필드 참조 테스트가 실패할 수 있음 → 갱신 필수
- REQ-047 Step2: 실 DB 적용 금지(게이트). dev/test DB에서만 검증.
- `get_daemon_by_slug`은 `owner_user_id` + `daemon_slug` 조합으로 조회 → REQ-050에서 daemon_slug로 받으면 user 조회 먼저 필요
@@ -0,0 +1,116 @@
# BAT-020 Verification
## 실행 결과
```
python3 -m pytest tests/ -v
509 passed, 0 failed, 11 warnings in 26.39s
```
신규 테스트:
- tests/test_bat020_broker_credential_domain.py: 6 passed
- tests/test_bat020_user_info_daemon_scoped.py: 13 passed
- tests/test_bat020_credential_cli.py: 11 passed
기존 테스트 회귀: 0건
## AC 커버리지
### REQ-046: BrokerCredential daemon_id 필드 추가
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: daemon_id=None 기본값 | test_broker_credential_daemon_id_defaults_to_none | PASS |
| AC-2: daemon_id 값 지정 | test_broker_credential_daemon_id_set | PASS |
| AC-3: 7컬럼 행 변환 daemon_id 세팅 | test_row_to_broker_credential_with_daemon_id | PASS |
| AC-4: 6컬럼 행 변환 daemon_id=None (하위호환) | test_row_to_broker_credential_without_daemon_id | PASS |
| AC-5: daemon_id 필터 시 해당 행만 반환 | test_list_broker_credentials_daemon_id_filter | PASS |
| AC-6: daemon_id 미지정 시 전체 반환 | test_list_broker_credentials_no_daemon_id_filter_returns_all | PASS |
### REQ-047: broker_credentials PK 전환 마이그레이션
| AC | 검증 방법 | 결과 |
| --- | --- | --- |
| AC-1: Step1 후 daemon_id TEXT NULL 컬럼 존재 | alembic revision 코드 확인 + ADD COLUMN 명시 | 코드 검증 완료 |
| AC-2: Step1 후 기존 데이터 daemon_id=NULL 보존 | 컬럼 추가 시 기존 행 NULL 유지 (DB 동작) | 코드 검증 완료 |
| AC-3: Step2 후 PK (daemon_id, broker) 전환 | revision 코드 + 사전 검증 로직 확인 | 코드 검증 완료 |
| AC-4: user_id 컬럼 유지 | Step2 revision에서 user_id 삭제 없음 | 코드 검증 완료 |
| AC-5: ensure_schema Step2 이후 정합 | ensure_schema broker_credentials TODO 주석 | (Step2 이후 활성화 예정) |
| AC-6: Step1 downgrade 동작 | downgrade 함수에서 컬럼/인덱스 제거 명시 | 코드 검증 완료 |
| AC-7: Step2 downgrade 안전장치 | 중복 (user_id,broker) 시 RuntimeError 발생 | 코드 검증 완료 |
NOTE: REQ-047 마이그레이션은 dev/test DB 검증 대상이나 실제 DB 인프라 미연결 상태. 코드 수준 검증.
### REQ-048: daemon_id 백필 CLI
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: daemon_id IS NULL 행 UPDATE | test_backfill_daemon_id_updates_null_rows | PASS |
| AC-2: 백필 후 NULL 0건 | test_backfill_daemon_id_updates_null_rows | PASS |
| AC-3: --dry-run DB 변경 없음 | test_backfill_daemon_id_dry_run_no_change | PASS |
| AC-4: encrypted_payload 동등 보존 | test_backfill_preserves_encrypted_payload | PASS |
| AC-5: NULL 없으면 "이미 완료됨" | test_backfill_daemon_id_already_complete | PASS |
| AC-6: 존재하지 않는 user → exit 1 | test_backfill_daemon_id_nonexistent_user_exits_1 | PASS |
| AC-7: Step2 적용 가능 상태 검증 | (Step2 revision 사전 검증 코드에서 NULL=0 확인) | 코드 검증 완료 |
### REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: daemon_id 있음 + broker 있음 → 단일 BrokerItem | test_user_info_daemon_scoped_broker_returned | PASS |
| AC-2: daemon_id 있음 + broker 없음 → broker=null | test_user_info_daemon_no_broker_returns_null | PASS |
| AC-3: daemon_id=None → 403 | test_user_info_no_daemon_id_returns_403 | PASS |
| AC-4: daemon A → daemon A account_ref | test_user_info_daemon_isolation_two_daemons | PASS |
| AC-5: daemon B → daemon B account_ref (격리) | test_user_info_daemon_isolation_two_daemons | PASS |
| AC-6: broker 단일 키, brokers 키 없음 | test_user_info_response_has_broker_not_brokers | PASS |
| AC-7: 복호화 실패 → broker.credential_payload=null | test_user_info_decryption_failure_returns_null_payload | PASS |
| AC-8: service 아닌 token → 403 | test_user_info_web_token_returns_403 | PASS |
| AC-9: 기존 필드 회귀 없음 | test_user_info_regression_existing_fields_unchanged | PASS |
### REQ-050: credential CLI --daemon-id 옵션
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: set --daemon-id → daemon_id 포함 upsert | test_credential_set_with_daemon_id | PASS |
| AC-2: list --daemon-id → 해당 daemon만 출력 | test_credential_list_daemon_id_filter | PASS |
| AC-3: list daemon-id 미지정 → 전체 | test_credential_list_no_daemon_id_returns_all | PASS |
| AC-4: remove --daemon-id → (daemon_id,broker) 삭제 | test_credential_remove_with_daemon_id | PASS |
| AC-5: 존재하지 않는 daemon → exit 1 | test_credential_set_nonexistent_daemon_exits_1 | PASS |
| AC-6: --daemon-id 미제공 시 경고 출력 + 허용 (과도기 정책) | test_cli_credential_set_list_and_remove_flow 갱신 | PASS |
| AC-7: 출력에 daemon_id 포함 | test_credential_set_output_includes_daemon_id | PASS |
NOTE: AC-6은 design 결정에 따라 Step2 PK 전환 이전 과도기에는 경고만 출력하고 허용. REQ-047 Step2 후 DB 레벨 NOT NULL 제약으로 자동 강제.
### REQ-051: 보안 격리
| AC | 테스트 | 결과 |
| --- | --- | --- |
| AC-1: web 토큰 → 403 | test_user_info_web_token_returns_403 | PASS |
| AC-2: 비인증 → 401 | test_user_info_unauthenticated_returns_401 | PASS |
| AC-3: daemon_id=None → 403 | test_user_info_no_daemon_id_returns_403 | PASS |
| AC-4: 다른 daemon 인증정보 → null | test_user_info_different_daemon_credential_not_returned | PASS |
| AC-5: 타 엔드포인트 credential 미노출 | test_other_endpoints_do_not_expose_credential_payload | PASS |
| AC-6: DB 오류 → broker=null (500 없음) | test_user_info_db_error_returns_null_broker | PASS |
| AC-7: 복호화 실패 → credential_payload=null | test_user_info_decryption_failure_returns_null_payload | PASS |
| AC-8: 로그에 app_secret 미노출 | test_user_info_decryption_failure_log_does_not_expose_secret | PASS |
## 기존 테스트 회귀 분석
갱신한 기존 테스트:
- `tests/test_v1.py`: brokers→broker 응답 변경 반영, daemon_id=None → 403 반영
- `tests/test_cli.py`: FakeRepo 시그니처 갱신(daemon_id 추가), credential 플로우 테스트 --daemon-id 포함
- `tests/test_daemon_assignment.py`: daemon_id=None → 200 기대를 403으로 갱신 (REQ-049 정책 변경)
## 비가역 결재 게이트
REQ-047 Step2(PK 전환) revision 코드가 작성되었으나 실 운영 DB 적용은 사장님 결재 게이트 대상.
이 batch에서는 revision 코드만 작성. 적용 순서:
1. `alembic upgrade 20260628_0026` (Step1 - nullable daemon_id 추가)
2. `pfhub credential backfill-daemon-id --user <username> --daemon-id <slug>` (REQ-048)
3. `alembic upgrade 20260628_0027` (Step2 - PK 전환, 사장님 결재 후)
## 미해결/위험 사항
1. **ensure_schema() broker_credentials 정의 미갱신**: Step2 PK 전환 이후 `ensure_schema()` 내 `broker_credentials` CREATE TABLE은 여전히 구 스키마(PK=(user_id,broker)). Step2 결재·적용 후 별도 갱신 필요. 현재 코드에 TODO 주석 없음 — 팀장에게 Step2 적용 시 함께 갱신 요청 필요.
2. **upsert ON CONFLICT 절**: REQ-047 Step2 이후 PK가 (daemon_id,broker)로 변경되면 `upsert_broker_credential`의 `ON CONFLICT (user_id, broker)` 절이 맞지 않음. Step2 결재·적용 시 해당 쿼리도 함께 갱신 필요.
3. **REQ-047 Step2 dev DB 통합 검증**: 실제 PostgreSQL에서 Step1→backfill→Step2 순서 검증을 완료하지 못했음(DB 인프라 미연결). 코드 수준 검증만 완료.
+1
View File
@@ -27,3 +27,4 @@
| BAT-017 | standard | released | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) |
| BAT-018 | batch-lite | release-candidate | REQ-043 | dcy-008 | [bat-018](./bat-018_20260625_holdings-cost-basis-endpoint/) |
| BAT-019 | standard | confirmed | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) |
| BAT-020 | standard | draft-batch-verification | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) |
@@ -0,0 +1,194 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-010_20260627_daemon-scoped-broker-credential-provisioning
- 이슈명: daemon-scoped 브로커(KIS) 인증정보 소유·전달 — 다중 인스턴스 trex-core가 daemon별 다른 계좌로 거래 (교차 발원 trex-core 중장기 멀티 인스턴스)
- 작성 시각(KST): 2026-06-27
- 마지막 갱신 시각(KST): 2026-06-28
- 대체됨: 없음
- 후속 Discovery 참조: trex-core 소비자측 day-start fetch + fail-safe 로드 = 별도 trex-core Discovery로 분리 **확정**(2026-06-28). trex-core repo에 신규 Discovery 생성 예정(미생성).
- 생성된 REQ 참조: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: TREX 워크스페이스 M3 메인 목표(중장기 trex-core 인스턴스 추가). GitHub org 보드 fourblocks2020/trex #7. 교차 발원 = trex-core 멀티 인스턴스(instance_key) + dcy-008(WAC cost-basis seed)
- 해석:
- 사장님이 "다중 인스턴스 trex-core를 완벽히 지원하려면 KIS API 인증정보를 portfolio-hub에서 받아야 하고, 그 인증정보는 universe와 동일하게 daemon_id로 매칭되어야 한다"고 방향을 확정했다. 현재 portfolio-hub는 broker 인증정보를 `user_id` 단위로만 소유해(`broker_credentials` PK=(user_id,broker)) 같은 user의 두 데몬에 서로 다른 계좌를 줄 수 없다.
- 이번 Discovery에서 판단할 핵심: 브로커 인증정보를 daemon_id 차원으로 확장(스키마 PK 변경 + daemon-scoped 전달 엔드포인트)하고, "1 데몬 = 1 계좌" 불변식 하에 응답을 단일 `broker`로 정규화하는 범위·리스크·마이그레이션을 확정한다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 기존 `broker_credentials` + `/v1/user_info`(dcy-004 토큰 정비 계열)는 user-scoped 인증정보 소유·전달을 다뤘으나, daemon_id 차원의 스코프·1:1 불변식·daemon 부트스트랩 전달은 정의된 바 없다.
- 변경이 테이블 PK와 전달 엔드포인트 응답 형태(brokers 리스트→단일 broker)를 바꾸는 구조 변경이라 기존 Discovery의 미세 조정이 아닌 독립 주기로 추적한다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다.
- 현재 상태
- `broker_credentials` 테이블 PK=(`user_id`, `broker`) — 인증정보가 **user 단위**. 컬럼: user_id, broker, account_ref, encrypted_payload(Fernet 암호화 JSON `{app_key, app_secret}`), credential_version, last_rotated_at. (`migrations/versions/20260502_0014_v1_api_schema.py`, `app/domain/models.py:178`)
- 전달: `GET /v1/user_info` — **service token 전용**(token이 `daemon_id`/`runtime_id` 바인딩), 토큰의 `user_id`로 조회 → 서버에서 Fernet 복호화 → `brokers: list[BrokerItem]`(`credential_payload`, `account_ref`, `credential_version`) + `trading_enabled`(user.is_active) 반환. (`app/api/v1_user_info.py`)
- 반면 **universe(포트폴리오)는 이미 daemon-scoped** — `Portfolio.managed_by_daemon_id`, `/v1/portfolios`가 service token의 `auth.daemon_id == managed_by_daemon_id` 일치로 필터·인가. (`app/api/v1_portfolios.py:254,260,554`)
- 결과 비대칭: 토큰은 daemon_id를 들고 universe는 daemon으로 거르는데, **인증정보만 user로 걸러** 같은 user의 두 데몬이 같은 계좌를 공유하게 된다.
- trex-core 측은 현재 KIS를 `TREX_KIS_BROKER_*` env로만 주입(`trex-core/runtime/config.py:257`)하며 portfolio-hub에서 인증정보를 받지 않는다. 멀티 인스턴스 토대(instance_key, longterm.example.toml)는 존재.
- 기대 상태
- 한 trex-core 데몬 인스턴스가 자기 service token(daemon_id 바인딩)으로 부트스트랩 시 **그 daemon에 매칭된 단일 KIS 계좌 인증정보**를 받는다.
- 같은 user가 단기·중장기 데몬을 동시에 소유해도 **데몬마다 다른 KIS 계좌**로 거래할 수 있다.
- 인증정보는 universe와 동일하게 daemon_id로 인가·스코프되어, 계좌-인스턴스 매칭이 단일 출처(portfolio-hub)에서 결정된다.
- 이해관계자
- 사장님(제품 방향·계좌/인증 정책 결정·결재)
- 팀장(Discovery·거버넌스·검수)
- portfolio-hub-dev(스키마·엔드포인트·CLI 구현), trex-core-dev(소비자 어댑터 — 별도 Discovery)
- 운영자(계좌 인증정보 등록/회전, 데몬-계좌 바인딩 관리)
- 제약사항
- KIS app_secret = 시스템 최민감 시크릿(실계좌=실자금). 복호화 평문이 응답 본문으로 네트워크 전송됨 → 내부망/TLS·service-token 게이트 유지 필수.
- portfolio-hub는 M3에서 trex-web 프론트로 노출될 서비스 — 인증정보 경로를 web 노출 표면과 격리 유지(service-token 전용).
- PK 변경은 기존 데이터·제약·인덱스에 영향(마이그레이션 비가역성 주의).
## 주요 사용자 시나리오
- 운영자가 단기 전략 데몬 A와 중장기 전략 데몬 B를 같은 user 소유로 등록하고, 각각 **다른 KIS 계좌** 인증정보를 portfolio-hub에 등록한다. A·B는 각자 자기 계좌로 거래한다.
- trex-core 중장기 인스턴스가 그날 시작(아침 스냅샷) 시 service token으로 부트스트랩을 호출해 자기 daemon의 universe + KIS 인증정보를 한 번에 받아 그날 거래를 개시한다.
- 운영자가 데몬 B의 KIS app_secret을 회전(rotate)하면, B만 새 버전을 받고 A는 영향받지 않는다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목.
- (C1) `broker_credentials`에 **`daemon_id` 차원 도입** — 인증정보를 daemon-scope로 소유. PK를 `(user_id, broker)` → daemon 포함 형태로 변경.
- (C2) **daemon-scoped 전달** — service token의 `auth.daemon_id`로 해당 데몬의 인증정보만 복호화·반환. "1 데몬 = 1 계좌" 불변식 하에 응답을 **단일 `broker`**(리스트 아님)로 정규화.
- (C3) **인증정보 관리 경로(CLI/관리 API)에 daemon_id 바인딩 추가** — 등록·회전·삭제 시 데몬-계좌 매칭 지정.
## 영향 범위
- 코드 영향:
- `app/domain/models.py`(BrokerCredential), `app/repositories/*`(upsert/list/delete 시그니처 + 쿼리 daemon_id 필터), `migrations/versions/*`(PK·컬럼 마이그레이션)
- `app/api/v1_user_info.py`(기존 `/v1/user_info` daemon-scoped 확장 — 결정 2026-06-28, 신규 엔드포인트 없음), `app/schemas/v1.py`(UserInfoResponse: brokers 리스트→단일 broker)
- `app/cli/commands/credential.py`(`--daemon-id` 추가)
- **교차 서비스(별도 Discovery)**: trex-core — KISConfig를 env-only → portfolio-hub fetch 소스에서 구성하는 소비자 어댑터 + 그날-시작 fail-safe 로드 게이트(`trex-core/runtime/config.py`, daemon 부트스트랩)
- 문서 영향:
- `docs/srs/`(신규 REQ), `docs/discovery/index.md`(본 문서 등록)
- baseline 런타임 흐름에 "daemon 부트스트랩 시 인증정보 전달" 추가 검토(§ runtime-flows 갱신은 NFR/후속에서 판단)
- 운영 영향:
- 운영자 인증정보 등록 절차에 daemon_id 지정 추가. 기존 user-scoped 인증정보 마이그레이션(데몬 매핑) 1회 필요.
- `CREDENTIAL_ENCRYPTION_KEY` 관리·회전 정책(기존 의존 유지).
# 5. 요구사항 목록
## 기능 요구사항
- FR-1: `broker_credentials`가 `daemon_id`로 인증정보를 스코프한다. 한 (daemon, broker) 조합당 활성 인증정보는 정확히 1건이다. **PK = `(daemon_id, broker)`, daemon-scoped 강제(user-기본 폴백 없음 — 공유계좌 footgun 차단). 결정 2026-06-28.** 마이그레이션은 nullable `daemon_id` 컬럼 추가 → 백필(데몬 매핑) → PK 전환의 단계적 경로.
- FR-2: **기존 `GET /v1/user_info`를 daemon-scoped로 확장**한다(신규 엔드포인트 미신설 — confirm 2026-06-28). service token 호출 시 토큰의 `daemon_id`에 매칭된 **단일 broker 인증정보**(account_ref + 복호화된 credential_payload + credential_version)와 `trading_enabled`를 반환한다. 토큰에 daemon_id가 없으면 거부(403)한다(universe 인가와 동일 규율). 1데몬1계좌 불변식(A-2 confirmed)상 응답은 리스트가 아닌 단일 broker로 정규화한다. 기존 소비자는 trex-core `hub_read.whoami()` 하나뿐이며 `daemon_id`만 읽으므로 broker 필드 reshape는 무영향(안전).
- FR-3: 인증정보 등록/회전/삭제 경로(CLI 및/또는 관리 API)가 `daemon_id`를 받아 데몬-계좌 매칭을 지정한다.
- FR-4: 기존 user-scoped 인증정보를 daemon-scoped로 이전하는 마이그레이션 경로를 제공한다(데이터 손실 없이).
## 비기능 요구사항
- NFR-1 (보안): 복호화된 app_secret은 service-token 전용·내부망/TLS 경로로만 전달된다. trex-web 등 user 토큰 경로로는 접근 불가해야 한다. at-rest는 기존 Fernet 암호화 유지.
- NFR-2 (일관성): 인증정보 스코프(daemon_id)가 universe 스코프(`managed_by_daemon_id`)와 동일한 인가 규율을 따른다(불일치/누락 → 403).
- NFR-3 (가용성/degrade): 인증정보 조회·복호화 실패가 부트스트랩 응답의 필수 필드(daemon_id, trading_enabled)를 크래시시키지 않는다(기존 graceful degrade 원칙 유지). 단, 인증정보 부재는 그날 거래 개시 차단 신호로 전달된다.
## 범위 경계
- In Scope
- portfolio-hub: daemon_id 차원 인증정보 소유(PK 변경) + daemon-scoped 단일 broker 전달 + 관리 경로 daemon_id 바인딩 + 마이그레이션.
- "1 데몬 = 1 계좌" 불변식 확정 및 응답 단일화.
- Out of Scope
- trex-core 소비자 어댑터(KISConfig fetch) + 그날-시작 fail-safe 로드 게이트 — **별도 trex-core Discovery**로 분리(본 문서는 계약·전달 형태만 확정).
- {{DECIDE_SCOPE_BOOTSTRAP_UNIFY:부트스트랩 응답에 universe까지 한 엔드포인트로 묶을지(단일 부트스트랩) vs 인증정보만 별도 경로로 둘지 — 사장님/팀장 확정 필요}}
- 시크릿 매니저(Vault 등) 외부화 — 현 단계는 portfolio-hub 내부 처리 유지(가정 A-3).
# 6. 리스크/가정 목록
## 리스크
- R-1 (High): `broker_credentials` PK 변경 마이그레이션이 기존 데이터·제약을 깨거나 롤백이 어려움.
- 완화 방안: nullable `daemon_id` 컬럼 추가 → 백필(데몬 매핑) → 새 PK/유니크 제약 전환의 단계적 마이그레이션. 사전 백업·드라이런·롤백 스크립트. 비가역 단계는 사장님 결재.
- R-2 (High): 최민감 시크릿(app_secret) 평문이 네트워크 전송되며, portfolio-hub가 web 노출 서비스라 표면이 큼.
- 완화 방안: service-token 전용 + daemon_id 인가 + 내부망/TLS 한정. web의 user 토큰 경로와 라우트/권한 분리. 감사 로깅.
- R-3 (Medium): `/v1/user_info` 응답 형태(brokers 리스트→단일)의 파괴적 변경이 기존 소비자를 깸.
- 완화 방안: 기존 소비자 인벤토리 확인 후, 신규 daemon-scoped 경로 신설(기존 user-level 리스트는 admin/UI용으로 유지) 또는 버전드 전환.
## 가정
- A-1: portfolio-hub가 universe·전략·인증정보의 단일 진실원천이다. (사장님 확정)
- A-2: "1 데몬(trex-core 인스턴스) = 1 브로커 계좌" 불변식이 성립한다. trex-core `KISConfig`가 현재 단일인 점과 정합. (**사장님 confirmed 2026-06-28** → 단일 `broker` 응답 근거)
- A-3: 현 단계는 portfolio-hub 내부 인증정보 소유(기존 Fernet + `CREDENTIAL_ENCRYPTION_KEY`)를 유지하고 외부 시크릿 매니저로 가지 않는다.
- A-4: 데몬-계좌 매칭은 service token의 daemon_id 바인딩(`AuthToken.daemon_id`)을 통해 결정된다.
## 오픈 질문
- OQ-1: PK 전환 형태 + 폴백 정책.
- 상태: **Answered (2026-06-28)**
- 결정: **PK = `(daemon_id, broker)`, daemon-scoped 강제(폴백 없음)**. 마이그레이션 = nullable `daemon_id` 컬럼 추가 → 백필 → PK 전환 단계적. 본문 FR-1·R-1에 반영.
- 반영 위치: §5 FR-1, §6 R-1 완화 방안.
- OQ-2: 전달 형태 — 기존 `/v1/user_info` reshape vs daemon-scoped 신규 엔드포인트.
- 상태: **Answered (2026-06-28)**
- 결정: **기존 `/v1/user_info`를 daemon-scoped로 확장**(신규 엔드포인트 미신설, 사장님). 소비자 인벤토리 조사 결과 유일 소비자=trex-core `adapters/portfolio/hub_read.py::whoami()`이며 `daemon_id`만 읽음(`brokers` 미참조) → 단일 broker reshape 무영향(안전). 본문 FR-2에 반영.
- 반영 위치: §4 영향범위, §5 FR-2, §7 S-1/S-2.
- OQ-3: 부트스트랩 통합 범위 — universe + 인증정보 + trading_enabled를 단일 부트스트랩 응답으로 묶을지.
- 상태: Deferred
- 처리 방안: trex-core 소비자 Discovery와 함께 결정(전달 계약 공동 설계). 본 문서는 인증정보 전달 형태만 확정.
- 종료 조건: trex-core 소비자 Discovery 착수 시점.
- OQ-4: 그날-시작 fail-safe 로드 의미론(진입 게이트 vs 청산 보존, 장중 무중단)의 trex-core측 구현 책임 분배.
- 상태: Deferred
- 처리 방안: 별도 trex-core Discovery로 인계(아래 §10 참조). 본 문서엔 전달측 가정으로만 기록.
- 종료 조건: trex-core 소비자 Discovery 생성.
# 7. 초기 성공 기준
- S-1: 같은 user가 소유한 두 데몬이 각각 다른 KIS `account_ref`/`app_key`를 부트스트랩으로 수신함을 검증할 수 있다.
- S-2: service token에 daemon_id가 없거나 인증정보가 daemon에 매칭되지 않으면 전달이 403/차단됨이 테스트로 확인된다.
- S-3: 기존 user-scoped 인증정보가 마이그레이션 후 손실 없이 daemon-scoped로 조회된다.
## 측정 방식 및 데이터 출처
- S-1 측정 방식: 두 데몬용 service token으로 부트스트랩 호출 → 응답 account_ref/credential_payload 상이 확인 / 데이터 출처: 통합테스트 + `broker_credentials` 행
- S-2 측정 방식: daemon_id 누락/불일치 토큰 요청의 403 단위·통합 테스트 / 데이터 출처: 엔드포인트 테스트
- S-3 측정 방식: 마이그레이션 전후 인증정보 복호화 결과 동등성 비교 / 데이터 출처: 마이그레이션 드라이런 로그 + 테스트
# 8. REQ로 넘기기 전 확인 체크
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: trex-core 소비자측 분리 확정(별도 Discovery), 부트스트랩 통합(OQ-3) Deferred 처리
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-3 측정 가능성 검토 완료
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(마이그레이션 — 단계적 PK 전환·롤백, portfolio-hub-dev)·R-2(시크릿 표면 — service-token+내부망) 완화안 확정
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1·OQ-2 Answered, OQ-3·OQ-4 Deferred 처리 완료
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 사장님+팀장 confirm-discovery (DECIDE/CONFIRM 전건 해소)
# 9. 파일 처리 결과
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md
- 참조 문서:
- docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (service token·daemon 바인딩)
- docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md (교차 발원 trex-core 중장기 cold-start)
- app/api/v1_user_info.py / app/domain/models.py(BrokerCredential) / app/api/v1_portfolios.py(daemon-scope 선례)
# 10. 사용자 결정 필요 항목 요약
## DECIDE
- ~~PK 전환 형태 (OQ-1)~~ → **RESOLVED 2026-06-28: PK `(daemon_id, broker)` + daemon-scoped 강제(폴백 없음), 단계적 마이그레이션**
- ~~엔드포인트 형태 (OQ-2)~~ → **RESOLVED 2026-06-28: 기존 `/v1/user_info` daemon-scoped 확장**
- ~~부트스트랩 통합 (OQ-3)~~ → **Deferred: trex-core 소비자 Discovery 공동 결정**
## CONFIRM
- ~~1데몬1계좌 불변식~~ → **RESOLVED 2026-06-28: 확정(A-2 confirmed) → 단일 broker 응답**
- ~~trex-core 소비자측 분리~~ → **RESOLVED 2026-06-28: 별도 trex-core Discovery로 분리 확정**
## DATA
- ~~기존 /v1/user_info 소비자 인벤토리~~ → **RESOLVED 2026-06-28: 유일 소비자=trex-core hub_read.whoami(daemon_id만 읽음), brokers 미참조 → reshape 안전**
# 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). DECIDE 전건 해소 — OQ-1(PK `(daemon_id, broker)`+폴백없음), OQ-2(기존 `/v1/user_info` 확장), OQ-3(Deferred). CONFIRM 전건 — A-2(1데몬1계좌→단일 broker), trex-core 소비자측 별도 Discovery 분리. DATA(소비자 인벤토리) 해소. 잔여 Open 0건 → REQ 초안 인계 가능.
- Ready for REQ Drafting: true
- Confirmed By: 팀장(Claude)
- Confirmed At (KST): 2026-06-28
+1
View File
@@ -15,3 +15,4 @@
| dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api | 20260616 | 일일 거래내역 + 실현 PnL 조회 API (운영 리포트/프론트 데이터 소스, issue #11) | [Discovery](./dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md) | confirmed |
| 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 |
@@ -0,0 +1,88 @@
# REQ-046: BrokerCredential 도메인 모델 daemon_id 필드 추가
- Status: Approved
- Type: Data
- Priority: High
- Owner: Justin Ko
## Intent
- `BrokerCredential` 도메인 엔티티가 현재 `user_id` 기반으로만 인증정보를 식별한다. daemon-scoped 소유로 전환하기 위해 `daemon_id` 필드를 모델에 추가하고, repository 조회/행 변환 로직이 daemon_id 차원을 인식하도록 한다.
- 이 REQ는 도메인 모델·repository 계층의 데이터 구조 변경을 담당하며, 마이그레이션(REQ-047/REQ-048) 및 API 전달(REQ-049)·CLI(REQ-050)의 선행 조건이다.
## Requirement
- `app/domain/models.py`의 `BrokerCredential` dataclass에 `daemon_id: str | None = None` 필드를 추가한다.
- `app/repositories/postgres.py`의 `_row_to_broker_credential` 정적 메서드가 DB 행에서 `daemon_id`를 읽어 `BrokerCredential` 인스턴스를 구성해야 한다.
- `upsert_broker_credential` 메서드 시그니처에 `daemon_id: str | None = None` 파라미터를 추가하고, INSERT/ON CONFLICT 쿼리에 `daemon_id` 컬럼 처리를 반영한다. 단, 실제 테이블 컬럼은 REQ-047 마이그레이션이 선행 적용된 후 활성화되므로 이 REQ 단독으로는 DB 컬럼이 존재하지 않는 환경에서도 하위 호환을 고려한다.
- `list_broker_credentials` 메서드에 `daemon_id: str | None = None` 파라미터를 추가하여, 값이 제공되면 daemon_id로 필터링한 결과를 반환하도록 한다.
- `delete_broker_credential` 메서드 시그니처에 `daemon_id: str | None = None` 파라미터를 추가한다.
## Acceptance Criteria
1. Given `BrokerCredential` 인스턴스를 생성할 때 `daemon_id=None`이 기본값이면, When 인스턴스를 생성, Then `daemon_id` 필드가 `None`으로 존재한다.
2. Given `BrokerCredential` 인스턴스를 생성할 때 `daemon_id="daemon-abc"` 값을 주면, When 인스턴스를 생성, Then `daemon_id` 필드가 `"daemon-abc"`로 존재한다.
3. Given `_row_to_broker_credential` 메서드가 6컬럼 행(daemon_id 포함)을 받으면, When 변환, Then 반환 `BrokerCredential.daemon_id`가 해당 값으로 세팅된다.
4. Given `_row_to_broker_credential` 메서드가 기존 6컬럼 행(daemon_id 없음)을 받으면, When 변환, Then `BrokerCredential.daemon_id`가 `None`으로 세팅된다(하위 호환).
5. Given `list_broker_credentials(user_id="u1", daemon_id="d1")`를 호출하면, When DB에 `daemon_id="d1"` 행과 `daemon_id="d2"` 행이 있으면, Then `daemon_id="d1"` 행만 반환된다.
6. Given `list_broker_credentials(user_id="u1")` (daemon_id 미지정)를 호출하면, When DB에 여러 행이 있으면, Then 모든 행을 반환한다(기존 동작 유지, 하위 호환).
## Impacted Area
- Docs:
- `docs/data-model.md` — `BrokerCredential` 엔티티에 `daemon_id` 필드 추가, PK 변경 예고(`REQ-047 선행 마이그레이션 후 PK=(daemon_id, broker)`) 명시.
- Modules:
- `app/domain/models.py` — `BrokerCredential` dataclass에 `daemon_id: str | None = None` 필드 추가.
- `app/repositories/postgres.py` — `_row_to_broker_credential`, `upsert_broker_credential`, `list_broker_credentials`, `delete_broker_credential` 시그니처 및 쿼리 갱신.
- Tests:
- `BrokerCredential` dataclass 필드 단위 테스트.
- `_row_to_broker_credential` 행 변환 테스트(daemon_id 포함/미포함 두 케이스).
- `list_broker_credentials` daemon_id 필터 테스트(mock 또는 메모리 repo 기반).
## Notes
- REQ-047(마이그레이션) 선행 없이는 실제 DB 컬럼이 없으므로, 이 REQ의 구현은 DB 컬럼 유무에 관계없이 모델/메서드 시그니처만 정합화하는 것을 목표로 한다. 실제 DB 컬럼 처리는 REQ-047 이후 통합 시점에 완성된다.
- `upsert_broker_credential` 쿼리의 ON CONFLICT 절 수정은 PK 변경(REQ-047) 완료 이후 정합화한다. 따라서 이 REQ에서 upsert 쿼리의 ON CONFLICT 처리는 설계 위임(REQ-047 delivery 연계).
- `list_broker_credentials` daemon_id 필터는 `WHERE daemon_id = %s` 조건을 추가하는 방식으로 구현하며, NULL 처리는 `WHERE daemon_id IS NULL` 또는 파라미터 없음(전체 조회)으로 구분한다.
- `data-model.md`의 `BrokerCredential` 엔티티 갱신(daemon_id 필드, PK 변경 예고)은 이 REQ 구현 시 함께 반영한다.
- Source: dcy-010 FR-1, §4 C1.
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-010 FR-1 산출물.
#### Intent
- dcy-010 confirmed Discovery의 FR-1(daemon_id 차원 도입)을 도메인 모델·repository 계층 변경으로 분리하여 REQ로 정규화한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-6 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Data/req-046_broker-credential-daemon-id-field.md` 신규 생성
- `docs/data-model.md` 갱신 예고
- Modules:
- `app/domain/models.py`, `app/repositories/postgres.py` 최초 지정
- Tests:
- `BrokerCredential` 단위·행 변환·필터 테스트 신규
#### 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 후 batch 배정. REQ-047 마이그레이션과 delivery 연계.
#### Validation Plan
- pytest로 AC-1~6 각 케이스 확인. `_row_to_broker_credential` 하위호환(AC-4) 포함.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-010 FR-1 추적 확인, AC testable, 하위호환(AC-4) 적절. REQ-047 선행 의존 명시됨. blocker 없음.
@@ -0,0 +1,94 @@
# REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달로 확장
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- 기존 `GET /v1/user_info`가 `user_id` 기반으로 `brokers: list[BrokerItem]`을 반환한다. 이를 service token의 `auth.daemon_id`로 매칭된 단일 broker 인증정보를 반환하는 daemon-scoped 전달로 확장한다.
- "1 데몬 = 1 계좌" 불변식(dcy-010 A-2 confirmed)에 따라 응답을 `broker: BrokerItem | None`(단일)으로 정규화하여, 같은 user의 두 데몬이 서로 다른 KIS 계좌를 각각 수신할 수 있게 한다.
- 유일 소비자인 trex-core `hub_read.whoami()`는 `daemon_id`만 읽으며 `brokers` 필드를 참조하지 않으므로, 응답 형태 변경이 소비자에게 무영향임이 확인된 안전한 변경이다(dcy-010 OQ-2 Answered).
## Requirement
- `GET /v1/user_info`의 응답 스키마를 다음과 같이 변경한다:
- `brokers: list[BrokerItem]` 필드를 제거하고 `broker: BrokerItem | None` 단일 필드로 대체한다.
- `broker`는 service token의 `auth.daemon_id`에 매칭된 인증정보 1건을 복호화하여 반환한다. `daemon_id`에 매칭된 인증정보가 없으면 `broker: null`을 반환한다(graceful degrade — NFR-3).
- service token에 `daemon_id`가 없는 경우(None), 엔드포인트는 HTTP 403 Forbidden을 반환한다. 기존 "daemon_id 없어도 200 반환" 동작을 `broker` 필드에 대해서는 적용하지 않으며, `daemon_id` 자체가 없으면 403으로 거부한다.
- `app/schemas/v1.py`의 `UserInfoResponse` 모델을 `broker: BrokerItem | None = None` 필드로 변경하고 `brokers: list[BrokerItem]`를 제거한다.
- `app/api/v1_user_info.py`의 엔드포인트 구현이 `auth.daemon_id`로 `list_broker_credentials(daemon_id=auth.daemon_id)` 호출 후 결과에서 단일 항목을 복호화·반환하도록 변경한다.
- 인증정보 복호화 실패(잘못된 키, 페이로드 손상)는 기존 graceful degrade 정책을 유지한다: 경고 로깅 후 `credential_payload: null`을 포함한 `BrokerItem`을 반환한다(응답을 크래시시키지 않음).
## Acceptance Criteria
1. Given daemon_id가 바인딩된 service token으로 GET /v1/user_info 호출, 해당 daemon_id에 매칭된 broker 인증정보가 존재하면, When 호출, Then HTTP 200 + `broker` 필드에 단일 `BrokerItem`(account_ref, credential_payload, credential_version, last_rotated_at 포함) 반환.
2. Given daemon_id가 바인딩된 service token으로 호출, 해당 daemon_id에 매칭된 인증정보가 없으면, When 호출, Then HTTP 200 + `broker: null` 반환 (graceful degrade, 크래시 없음).
3. Given daemon_id가 없는(None) service token으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 Forbidden 반환.
4. Given 같은 user 소유의 daemon A, daemon B에 각각 다른 KIS account_ref가 등록된 상황, When daemon A의 service token으로 호출, Then `broker.account_ref`가 daemon A에 등록된 account_ref와 일치함.
5. Given 같은 상황에서 daemon B의 service token으로 호출, When 호출, Then `broker.account_ref`가 daemon B에 등록된 account_ref와 일치함 (daemon A의 계좌 반환하지 않음).
6. Given `UserInfoResponse` 응답 스키마, When 응답 JSON 확인, Then `brokers` 키가 존재하지 않고 `broker` 단일 키가 존재한다.
7. Given 인증정보 복호화 실패(잘못된 key), When daemon_id에 매칭된 인증정보가 있으나 복호화 실패, Then HTTP 200 + `broker.credential_payload: null` 반환 (경고 로그 기록, 크래시 없음).
8. Given service 토큰이 아닌 user 토큰(web 토큰)으로 호출, When 호출, Then HTTP 403 Forbidden 반환 (기존 service-token gate 유지).
9. Given `runtime_id`, `daemon_id`, `trading_enabled`, `user_id`, `username`, `permission` 필드, When GET /v1/user_info 호출, Then 이 필드들은 기존과 동일하게 반환된다 (broker 변경이 나머지 필드에 영향 없음, 회귀 없음).
## Impacted Area
- Docs:
- `docs/interface-contract.md` — `GET /v1/user_info` 섹션: `brokers: list` → `broker: object|null` 응답 스키마 갱신, daemon_id 없음 → 403 정책 명시.
- `docs/data-model.md` — `BrokerCredential` State Rules 갱신: 전달 경로에 daemon-scoped 언급 추가.
- Modules:
- `app/schemas/v1.py` — `UserInfoResponse` 모델: `brokers: list[BrokerItem]` → `broker: BrokerItem | None = None`.
- `app/api/v1_user_info.py` — 엔드포인트 구현: user_id 기반 list → daemon_id 기반 단일 조회, daemon_id None → 403.
- Tests:
- `GET /v1/user_info` 단위/통합 테스트 갱신: daemon_id 있음/없음, broker 있음/없음, 2데몬 격리(AC-4/5), 복호화 실패(AC-7), 회귀(AC-9).
- 기존 `brokers` 필드 참조 테스트 → `broker` 단일 필드로 변경.
## Notes
- **파괴적 변경(breaking change) 해당 여부**: `brokers: list` → `broker: object|null`는 API 응답 스키마 변경이다. 그러나 dcy-010 OQ-2에서 유일 소비자 trex-core `hub_read.whoami()`가 `daemon_id`만 읽고 `brokers` 필드를 참조하지 않음이 확인되었으므로, **실질적 소비자 영향은 없다**. 다만 이 REQ 구현 전 trex-core 소비자 코드 재확인을 권장한다.
- daemon_id 없음 → 403 정책은 dcy-010 FR-2 및 universe 인가(REQ-028 `managed_by_daemon_id` 일치 요구)와 동일한 규율이다.
- `broker: null` graceful degrade(AC-2)는 dcy-010 NFR-3(인증정보 부재가 `daemon_id`, `trading_enabled` 필수 필드를 크래시시키지 않음)을 구현한다. `broker: null`은 그날 거래 개시 차단 신호로 소비자(trex-core)가 해석한다. 이 해석 로직은 trex-core Discovery(별도)에서 구현한다.
- OQ-3(부트스트랩 통합 — universe+인증정보 단일 응답): dcy-010 Deferred. 이 REQ는 인증정보 전달 형태만 확정하며, 부트스트랩 통합은 다루지 않는다.
- Source: dcy-010 FR-2, OQ-2 Answered, A-2, NFR-3.
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-010 FR-2 산출물.
#### Intent
- dcy-010 FR-2(기존 GET /v1/user_info daemon-scoped 확장, 단일 broker 정규화)를 Interface REQ로 정규화한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-9 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Interface/req-049_user-info-daemon-scoped-broker-delivery.md` 신규 생성
- `docs/interface-contract.md`, `docs/data-model.md` 갱신 예고
- Modules:
- `app/schemas/v1.py` (`UserInfoResponse` 변경), `app/api/v1_user_info.py` (로직 변경)
- Tests:
- user_info 엔드포인트 테스트 전면 갱신 신규
#### Delivery Trace Delta
- Batches: 미정 (suggest-batch-reqs 대기)
- Releases: 미정
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no (기존 `brokers` 필드 구현은 이 REQ 구현 시 대체됨)
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. REQ-046(모델), REQ-047(스키마) 선행 의존. trex-core 소비자 코드 재확인.
#### Validation Plan
- pytest로 AC-1~9 각 케이스 확인. 2데몬 격리(AC-4/5) 특히 중요.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-010 FR-2/OQ-2/A-2/NFR-3 추적. brokers→단일 broker reshape, daemon_id 없음 403, 2데몬 격리(AC-4/5), graceful degrade(AC-2) 적절. 구현 전 trex-core 소비자 재확인 권고(Notes) 유효. blocker 없음.
@@ -0,0 +1,90 @@
# REQ-050: credential CLI --daemon-id 옵션 추가
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- 현재 `pfhub credential set / list / remove` CLI는 `--user` 옵션만으로 인증정보를 식별한다. daemon-scoped 소유 전환 이후 운영자가 daemon_id를 지정하여 특정 데몬의 인증정보를 등록/조회/삭제할 수 있도록 `--daemon-id` 옵션을 추가한다.
- REQ-047 PK 전환 이후 `broker_credentials` PK가 `(daemon_id, broker)`로 변경되므로, 인증정보 등록/삭제 시 `daemon_id`가 필수 식별자가 된다.
## Requirement
- `pfhub credential set` 커맨드에 `--daemon-id` 옵션(required, `--daemon-id <daemon_slug_or_id>`)을 추가한다. 이 옵션의 값을 `daemon_id`로 사용하여 `upsert_broker_credential(daemon_id=..., ...)` 호출 시 전달한다.
- `pfhub credential list` 커맨드에 `--daemon-id` 옵션(optional)을 추가한다. 값이 제공되면 해당 daemon_id의 인증정보만 반환한다. 미제공 시 `--user` 소유 전체 인증정보를 반환한다(기존 동작 유지).
- `pfhub credential remove` 커맨드에 `--daemon-id` 옵션(required)을 추가한다. 기존 `(user_id, broker)` 기반 삭제에서 `(daemon_id, broker)` 기반 삭제로 변경한다.
- `--daemon-id` 옵션 값은 daemon_slug 또는 daemon UUID 중 하나를 허용한다. 내부에서 `Daemon` 조회로 daemon_id(UUID)를 해석한다. daemon이 존재하지 않으면 오류 메시지 출력 후 exit 1.
- CLI 출력에 `daemon_id` 필드를 포함한다(기존 `user_id` 출력 유지, 추가).
## Acceptance Criteria
1. Given `pfhub credential set --user u1 --daemon-id d1 --broker kis --account-ref "123" --app-key "k" --app-secret "s"` 실행, When 명령 성공, Then `broker_credentials`에 `daemon_id=d1.id, broker="kis"` 행이 upsert된다.
2. Given `pfhub credential list --user u1 --daemon-id d1` 실행, When 명령 성공, Then daemon_id=d1인 인증정보만 출력된다(daemon_id=d2 인증정보 미출력).
3. Given `pfhub credential list --user u1` (daemon-id 미지정) 실행, When 명령 성공, Then 해당 user의 모든 인증정보가 출력된다(기존 동작 유지).
4. Given `pfhub credential remove --user u1 --daemon-id d1 --broker kis` 실행, When 명령 성공, Then `broker_credentials`에서 `daemon_id=d1.id, broker="kis"` 행이 삭제된다.
5. Given 존재하지 않는 daemon_slug를 `--daemon-id`에 지정, When 명령 실행, Then 오류 메시지("데몬을 찾을 수 없습니다") 출력 후 exit 1.
6. Given `pfhub credential set` 실행 시 `--daemon-id` 옵션 미제공, When 명령 실행, Then 오류 메시지(`--daemon-id` required) 출력 후 exit 1. (REQ-047 PK 전환 이후 daemon_id 없이 등록 불가)
7. Given `pfhub credential set` 실행 성공, When CLI 출력 확인, Then `daemon_id`, `broker`, `account_ref`, `credential_version`, `last_rotated_at` 필드가 모두 포함된다.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — CLI credential surface 섹션: `--daemon-id` 옵션 추가, daemon-scoped 등록/조회/삭제 예시.
- Modules:
- `app/cli/commands/credential.py` — `set_credential`, `list_credentials`, `remove_credential` 함수에 `--daemon-id` 파라미터 추가. daemon 조회 로직 추가.
- `app/repositories/postgres.py` — `get_daemon_by_slug(daemon_slug)` 또는 동등한 메서드가 필요(기존 존재 여부 확인 후 추가 또는 재사용).
- Tests:
- `set_credential --daemon-id` 단위 테스트(mock repo).
- `list_credentials --daemon-id` 필터 단위 테스트.
- `remove_credential --daemon-id` 단위 테스트.
- daemon 미존재 오류 케이스(AC-5).
- `--daemon-id` 미제공 시 오류(AC-6).
## Notes
- `--daemon-id` 옵션은 daemon_slug(사람이 읽기 쉬운 식별자) 또는 daemon UUID를 허용할지 여부는 batch design 단계에서 기존 `daemons` 테이블 스키마를 확인하여 확정한다. 기존 CLI의 `--user` 옵션이 username을 받아 내부에서 user_id로 변환하는 패턴과 동일하게 설계한다.
- REQ-047 PK 전환 이전 단계(daemon_id 컬럼은 있으나 nullable)에서는 `--daemon-id` 미제공 시에도 set/remove를 허용할지 결정이 필요하다. 단계적 마이그레이션 기간 중 호환성 정책은 batch design에서 확정한다. 이 REQ의 AC-6은 PK 전환(REQ-047 Step 2) 완료 이후 상태를 기준으로 한다.
- `pfhub credential list` daemon_id 미지정 동작(전체 조회, AC-3)은 PK 전환 이후 daemon_id 컬럼이 NOT NULL이 되어도 유지된다(admin 관리 목적).
- Source: dcy-010 FR-3, §4 C3.
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-010 FR-3 산출물.
#### Intent
- dcy-010 FR-3(인증정보 관리 경로에 daemon_id 바인딩 추가)을 CLI Interface REQ로 정규화한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-7 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Interface/req-050_credential-cli-daemon-id-option.md` 신규 생성
- `docs/interface-contract.md` 갱신 예고
- Modules:
- `app/cli/commands/credential.py` (옵션 추가), `app/repositories/postgres.py` (daemon 조회 메서드)
- Tests:
- credential CLI daemon-id 옵션 단위 테스트 신규
#### 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 후 batch 배정. REQ-046(모델), REQ-047(스키마) 선행 의존. --daemon-id 필수/선택 단계 정책은 design에서 확정.
#### Validation Plan
- pytest mock repo 기반 단위 테스트 AC-1~7. daemon 미존재 오류 케이스(AC-5) 포함.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-010 FR-3 추적. set/list/remove --daemon-id 적절. slug vs UUID 해석 + REQ-047 과도기(daemon_id nullable 구간) --daemon-id 필수/선택 정책은 batch design 위임 — AC 무효화 아님(비차단).
@@ -0,0 +1,95 @@
# REQ-047: broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션
- Status: Approved
- Type: Migration
- Priority: High
- Owner: Justin Ko
## Intent
- 현재 `broker_credentials` 테이블의 PK는 `(user_id, broker)`로, 같은 user의 두 데몬이 서로 다른 KIS 계좌를 가질 수 없다. PK를 `(daemon_id, broker)`로 전환하여 인증정보를 daemon-scoped로 소유하게 한다.
- dcy-010에서 결정된 단계적 마이그레이션 경로(nullable `daemon_id` 컬럼 추가 → 백필(REQ-048) → PK 전환)에서, 이 REQ는 스키마 변경 단계를 담당한다. PK 전환 전 데이터 백필은 REQ-048에서 별도로 수행한다.
- 이 마이그레이션은 비가역 PK 변경을 포함하는 고위험 작업으로, 팀장/사장님 결재 게이트(외부 비가역 작업)를 거쳐야 한다.
## Requirement
- Alembic 신규 revision을 작성한다. (down_revision은 현재 최신 head에서 파악)
- **Step 1 (nullable 컬럼 추가)**: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼을 추가한다. 기존 데이터는 `daemon_id=NULL` 상태로 남는다(백필은 REQ-048이 별도 처리).
- **Step 2 (PK 전환 준비)**: 백필 완료(REQ-048) 이후 적용할 별도 revision을 작성한다. 이 revision에서:
- 기존 PK 제약 `(user_id, broker)`를 삭제한다.
- `daemon_id NOT NULL` 제약을 추가한다.
- 새 PK 제약 `(daemon_id, broker)`를 추가한다.
- `user_id` 컬럼은 감사/이력 목적으로 유지한다(삭제 금지).
- `app/repositories/postgres.py`의 `ensure_schema()` 내 `broker_credentials` 정의를 최종 daemon-scoped 스키마(daemon_id NOT NULL, PK=(daemon_id, broker))와 정합하게 갱신한다. 단, `ensure_schema()`의 갱신은 PK 전환 revision 적용 이후 활성화한다.
- downgrade 함수는 PK 전환 이전 상태로 되돌릴 수 있도록 작성한다(daemon_id 컬럼 제거, 기존 PK 복원). 단, PK 전환 revision의 downgrade는 데이터 손실 위험이 있어 실 운영 적용 전 롤백 스크립트를 별도 제공한다.
## Acceptance Criteria
1. Given Step 1 revision 적용 후, When `\d broker_credentials` 확인, Then `daemon_id TEXT NULL` 컬럼이 존재하고 기존 PK `(user_id, broker)` 제약이 그대로 유지된다.
2. Given Step 1 revision 적용 후, When 기존 행을 조회, Then `daemon_id`가 NULL인 상태로 데이터가 보존된다.
3. Given Step 2(PK 전환) revision 적용 후 (REQ-048 백필 완료 이후), When `\d broker_credentials` 확인, Then PK 제약이 `(daemon_id, broker)`로 변경되고 `daemon_id NOT NULL` 제약이 존재한다.
4. Given AC-3 환경, When `user_id` 컬럼 존재 확인, Then `user_id` 컬럼이 여전히 존재한다(삭제되지 않음).
5. Given AC-3 환경, When `ensure_schema()` 단독 실행, Then `broker_credentials` 스키마가 마이그레이션과 동일하다(daemon_id NOT NULL, PK=(daemon_id, broker), user_id 존재).
6. Given Step 1 revision의 downgrade 실행, When `\d broker_credentials` 확인, Then `daemon_id` 컬럼이 제거되고 기존 PK `(user_id, broker)` 제약이 복원된다.
7. Given Step 2 revision의 downgrade 실행, When 실행, Then downgrade가 실패하거나 데이터 손실 경고를 명시적으로 표시한다(안전 장치 존재).
## Impacted Area
- Docs:
- `docs/data-model.md` — `BrokerCredential` 엔티티 PK 변경(`(user_id, broker)` → `(daemon_id, broker)`), `user_id` 유지 명시, daemon-scoped 소유 정책 기술.
- Modules:
- `migrations/versions/` — Step 1 revision (nullable daemon_id 추가), Step 2 revision (PK 전환). down_revision은 현재 최신 head.
- `app/repositories/postgres.py` — `ensure_schema()` 내 `broker_credentials` 정의 갱신(PK 전환 revision 이후).
- Tests:
- alembic upgrade head 후 schema 검증(AC-1~5).
- Step 1 downgrade 테스트(AC-6).
- 기존 broker credential CRUD 회귀 테스트(마이그레이션 전후 데이터 보존).
## Notes
- 이 마이그레이션은 **PK 변경**이라는 비가역 작업을 포함한다. 팀장/사장님 결재 게이트 대상이다(dcy-010 R-1, §범위 경계 — "DB 마이그레이션"은 외부 비가역 작업). 실 운영 DB 적용 전 반드시 결재를 받는다.
- Step 1과 Step 2를 별도 revision으로 분리하는 이유: REQ-048 백필이 완료된 후에만 PK 전환이 가능하기 때문이다. Step 1만 먼저 적용하고, REQ-048 백필 → Step 2 순서로 진행한다.
- `user_id` 컬럼을 유지하는 이유: 기존 인증정보의 소유자 이력 보존 및 admin 조회 기능 유지.
- daemon-scoped 강제(user 폴백 없음): dcy-010 OQ-1 결정. `daemon_id=NULL`인 인증정보는 PK 전환 이후 INSERT 불가(NOT NULL 제약). 기존 NULL 데이터는 REQ-048 백필 이후만 PK 전환이 가능하다.
- Step 2 downgrade는 daemon_id → user_id PK 복원 시 데이터 무결성을 보장하기 어려우므로, 운영 환경에서는 rollback 전 별도 데이터 검증이 필요하다.
- Source: dcy-010 FR-1, R-1 완화 방안, §4 C1.
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-010 FR-1 산출물 (마이그레이션 계층 분리).
#### Intent
- dcy-010 FR-1의 스키마 PK 전환 작업을 독립 마이그레이션 REQ로 정규화한다. Step 1(nullable 추가)과 Step 2(PK 전환)를 별도 revision으로 분리하는 설계를 반영한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-7 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md` 신규 생성
- `docs/data-model.md` 갱신 예고
- Modules:
- `migrations/versions/` (Step 1, Step 2 revision 신규), `app/repositories/postgres.py` 갱신 예고
- Tests:
- alembic 스키마 검증, downgrade, CRUD 회귀 신규
#### 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 후 batch 배정. REQ-048(백필)과 순서 의존 관계 명시.
#### Validation Plan
- alembic upgrade head 후 schema 검증(AC-1~5). Step 1 downgrade 검증(AC-6). 개발 DB에서 dry-run.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-010 FR-1/R-1 추적. Step1/Step2 분리 + REQ-048 백필 사이 순서 정합. **Step2 PK 전환(실DB 적용)은 비가역 → 사장님 결재 게이트 대상**(Notes 명시 확인). blocker 없음.
@@ -0,0 +1,91 @@
# REQ-048: 기존 user-scoped 인증정보 daemon_id 백필
- Status: Approved
- Type: Migration
- Priority: High
- Owner: Justin Ko
## Intent
- REQ-047 Step 1 적용 이후 `broker_credentials.daemon_id`가 NULL인 기존 행들을 운영자가 지정하는 daemon_id로 백필하여, REQ-047 Step 2(PK 전환)가 적용 가능한 상태로 만든다.
- dcy-010 FR-4(기존 user-scoped 인증정보를 손실 없이 daemon-scoped로 이전)의 데이터 이전 경로를 제공한다. 백필은 CLI 명령 또는 one-shot 마이그레이션 스크립트로 수행한다.
## Requirement
- `pfhub credential backfill-daemon-id` CLI 명령(또는 동등한 alembic data migration revision)을 제공한다. 이 명령은 다음을 수행한다:
- `broker_credentials`에서 `daemon_id IS NULL`인 행을 조회한다.
- 각 행의 `user_id`에 대해 운영자가 제공한 매핑(`--user <username> --daemon-id <daemon_id>`)으로 `daemon_id`를 UPDATE한다.
- 백필 대상 행이 없으면 "이미 완료됨" 메시지를 출력하고 정상 종료한다.
- `--dry-run` 옵션을 제공하여 실제 UPDATE 없이 대상 행과 매핑 결과를 출력한다.
- 백필 완료 후 `daemon_id IS NULL`인 행이 0건임을 검증하는 단계를 명령 결과 또는 별도 verify 서브커맨드로 제공한다.
- 기존 행의 암호화된 인증정보(`encrypted_payload`, `account_ref`, `credential_version`, `last_rotated_at`)는 백필 과정에서 변경되지 않는다.
- `--force` 옵션으로 확인 프롬프트를 생략할 수 있다.
## Acceptance Criteria
1. Given `broker_credentials`에 `daemon_id=NULL`인 행이 있고 `--user u1 --daemon-id d1` 매핑을 제공하면, When `backfill-daemon-id` 명령 실행, Then 해당 `user_id=u1(user.id)`에 해당하는 행의 `daemon_id`가 `d1`로 UPDATE된다.
2. Given AC-1 실행 후, When `broker_credentials`에서 `daemon_id IS NULL` 행 조회, Then 0건이 반환된다.
3. Given `--dry-run` 옵션으로 실행, When 백필 대상 행이 있으면, Then DB UPDATE 없이 대상 행과 예상 매핑이 출력되고 실제 데이터는 변경되지 않는다.
4. Given 백필 실행 중, When `encrypted_payload`, `account_ref`, `credential_version`, `last_rotated_at` 컬럼 확인, Then 백필 전후 값이 동일하다(데이터 손실 없음).
5. Given `daemon_id IS NULL`인 행이 없을 때, When `backfill-daemon-id` 명령 실행, Then "이미 완료됨" 메시지와 함께 정상 종료(exit 0)된다.
6. Given 존재하지 않는 user를 `--user` 옵션으로 지정, When 명령 실행, Then 오류 메시지와 함께 비정상 종료(exit 1)된다.
7. Given 백필 완료 후 REQ-047 Step 2(PK 전환) revision 적용, When `\d broker_credentials` 확인, Then PK `(daemon_id, broker)` 제약이 적용되고 NOT NULL 위반 없이 성공한다.
## Impacted Area
- Docs:
- `docs/data-model.md` — `BrokerCredential` 데이터 이전 절차(백필 단계) 명시.
- Modules:
- `app/cli/commands/credential.py` — `backfill-daemon-id` 서브커맨드 추가.
- `app/repositories/postgres.py` — `update_broker_credential_daemon_id(user_id, daemon_id)` 또는 동등한 메서드 추가(또는 CLI에서 직접 쿼리).
- Tests:
- `backfill-daemon-id` 명령 단위 테스트(mock repo 기반): 정상 백필, dry-run, 이미 완료, 사용자 미존재 케이스.
- 백필 전후 `encrypted_payload` 동등성 검증(AC-4).
## Notes
- 이 REQ는 **1회성 운영 작업**이다. 개발 환경에서는 테스트 목적으로 반복 실행 가능하도록 설계한다.
- 백필 매핑은 운영자가 직접 제공한다. "어떤 user가 어떤 daemon_id에 매핑되는가"는 인프라/운영 도메인 지식이 필요하며 자동화할 수 없다.
- CLI 구현 방식(subcommand 추가 vs alembic data migration revision)은 batch design 단계에서 확정한다. 어느 쪽이든 AC-1~7을 만족해야 한다.
- `--user` 옵션은 username으로 받고 내부에서 `get_user_by_username()`으로 user_id를 해석한다(기존 CLI 패턴과 동일).
- Source: dcy-010 FR-4, §4 C1(마이그레이션 단계적 경로).
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-010 FR-4 산출물.
#### Intent
- dcy-010 FR-4(기존 user-scoped 인증정보 손실 없는 daemon-scoped 이전)를 독립 REQ로 정규화한다. REQ-047 Step 1과 Step 2 사이의 데이터 이전 경로를 담당한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-7 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Migration/req-048_broker-credentials-daemon-id-backfill.md` 신규 생성
- `docs/data-model.md` 갱신 예고
- Modules:
- `app/cli/commands/credential.py` (backfill subcommand), `app/repositories/postgres.py` (업데이트 메서드)
- Tests:
- backfill CLI 단위 테스트 신규
#### 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 후 batch 배정. REQ-047 Step 1 이후, Step 2 이전 순서 의존 관계 준수.
#### Validation Plan
- pytest mock repo 기반 단위 테스트 AC-1~6. 개발 DB 통합: backfill → REQ-047 Step 2 순서 검증(AC-7).
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-010 FR-4 추적. dry-run/verify·암호화 payload 불변(AC-4) 적절. 구현방식(CLI subcommand vs alembic data revision)은 batch design 위임 — AC 무효화 아님(비차단).
@@ -0,0 +1,96 @@
# REQ-051: 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단
- Status: Approved
- Type: Non-Functional
- Priority: High
- Owner: Justin Ko
## Intent
- 복호화된 app_secret(KIS API 비밀키)은 실계좌 실자금에 직결되는 최민감 시크릿이다. 이 정보가 복호화된 형태로 service-token 경로 외 다른 경로(web user 토큰, admin 토큰, 비인증 접근)로 유출되지 않아야 한다.
- portfolio-hub가 M3에서 trex-web 프론트에 노출될 예정이므로, web 노출 표면과 인증정보 전달 경로의 격리를 명시적으로 요구사항으로 정의하고 테스트로 검증한다.
- dcy-010 NFR-1(보안 격리), NFR-2(일관성), NFR-3(가용성/degrade)의 독립 측정·검증 필요성을 근거로 별도 REQ로 승격한다.
## Requirement
- `GET /v1/user_info`는 service token(token_source="db", token_type="service")으로만 접근 가능하다. web user 토큰(token_type="session" 또는 client 타입), 비인증 요청, admin 토큰(token_type이 service가 아닌 경우)은 HTTP 403을 반환해야 한다.
- 복호화된 `credential_payload`(`app_secret` 포함)는 오직 `GET /v1/user_info` 응답을 통해서만 반환되어야 하며, 다른 엔드포인트(portfolio, asset, health, admin 등)의 응답에 포함되어서는 안 된다.
- `GET /v1/user_info`에서 daemon_id가 없는(None) 토큰은 HTTP 403으로 거부한다(REQ-049와 동일 — 보안 정책으로도 명시).
- 인증정보 조회·복호화 실패(DB 오류, 복호화 실패)는 엔드포인트 전체를 크래시시키지 않는다. 실패 시 `broker: null`(또는 `credential_payload: null`)을 반환하고 경고 로그를 남긴다.
- 인증정보 관련 조회 로그에는 `credential_payload`의 내용(app_key, app_secret)이 포함되어서는 안 된다. 로그에는 broker 이름, daemon_id, 오류 유형만 기록한다.
- universe의 daemon-scoped 인가(`managed_by_daemon_id` 일치 검사, REQ-028)와 동일한 규율로, broker 인증정보 접근도 daemon_id 일치를 강제한다(daemon_id 불일치 → 403, 다른 데몬의 인증정보 반환 불가).
## Acceptance Criteria
1. Given web user 토큰(token_type이 service가 아닌 token)으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 반환(credential_payload 미노출).
2. Given 비인증 요청으로 GET /v1/user_info 호출, When 호출, Then HTTP 401 반환.
3. Given daemon_id=None인 service token으로 GET /v1/user_info 호출, When 호출, Then HTTP 403 반환.
4. Given daemon_id="d1"인 service token으로 GET /v1/user_info 호출, When DB에 daemon_id="d2"의 인증정보만 존재하면, Then `broker: null` 반환 (다른 데몬의 인증정보 절대 반환하지 않음).
5. Given GET /v1/user_info 응답 외 다른 엔드포인트(GET /v1/portfolios, GET /v1/assets, GET /health 등) 응답, When 응답 JSON 확인, Then `credential_payload`, `app_secret`, `app_key` 필드가 포함되지 않는다.
6. Given DB 오류로 `list_broker_credentials` 실패, When GET /v1/user_info 호출, Then HTTP 200 + `broker: null` 반환 (500 크래시 없음), 경고 로그 기록.
7. Given Fernet 복호화 실패(잘못된 `CREDENTIAL_ENCRYPTION_KEY`), When GET /v1/user_info 호출, Then HTTP 200 + `broker.credential_payload: null` 반환, 경고 로그 기록(app_secret 로그 미노출).
8. Given GET /v1/user_info 정상 처리 중 로그 검사, When 로그 출력 확인, Then `app_key`, `app_secret` 값이 로그에 출력되지 않는다.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — GET /v1/user_info 보안 정책 섹션: service-token 전용, daemon_id 없음 → 403, web 토큰 차단, credential 로그 비노출 명시.
- Modules:
- `app/api/v1_user_info.py` — `_require_service_token` 함수(기존 service-token gate, 변경 없거나 daemon_id 검사 보완).
- `app/api/v1_user_info.py` — 로깅 경고 메시지에서 credential 값 제외 확인.
- Tests:
- web 토큰 → 403 테스트(AC-1).
- 비인증 → 401 테스트(AC-2).
- daemon_id=None → 403 테스트(AC-3).
- 다른 데몬 인증정보 → null 테스트(AC-4).
- 타 엔드포인트 credential 미노출 테스트(AC-5): 주요 엔드포인트 응답 필드 검사.
- DB 오류 graceful degrade 테스트(AC-6).
- 복호화 실패 degrade 테스트(AC-7): 기존 테스트 확인/보완.
## Notes
- dcy-010 NFR-1(보안 격리), NFR-2(일관성), NFR-3(degrade)가 모두 독립 측정 가능하고 테스트로 검증할 수 있어 별도 REQ로 승격한다. 이 세 NFR을 단일 REQ로 묶은 것은 모두 동일한 엔드포인트(GET /v1/user_info)의 보안 속성에 관한 것이기 때문이다.
- AC-4(다른 데몬 인증정보 반환 불가)는 REQ-049 AC-4/5와 overlap하지만, 보안 관점의 요구사항으로 이 REQ에서 독립적으로 추적한다.
- 로그 비노출(AC-8)은 현재 구현의 `logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc)` 패턴이 app_secret을 exc에 포함할 가능성을 배제해야 함을 의미한다. 구현 시 확인이 필요하다.
- `CREDENTIAL_ENCRYPTION_KEY` 자체의 보안 관리(환경변수 주입, 키 회전 정책)는 dcy-010 A-3(외부 시크릿 매니저 Out of Scope)에 따라 이 REQ의 범위 밖이다. 현재 환경변수 기반 유지.
- Source: dcy-010 NFR-1, NFR-2, NFR-3.
## Change Log
### CHG-20260628-01
- Date: 2026-06-28
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-010 NFR-1/2/3 산출물.
#### Intent
- dcy-010 NFR-1(보안 격리)·NFR-2(일관성)·NFR-3(degrade) 세 NFR을 독립 측정·테스트 가능한 요구사항으로 묶어 Non-Functional REQ로 정규화한다.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-8 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Non-Functional/req-051_broker-credential-security-isolation.md` 신규 생성
- `docs/interface-contract.md` 갱신 예고
- Modules:
- `app/api/v1_user_info.py` 보안 게이트 및 로깅 확인
- Tests:
- 보안 격리 테스트 신규(AC-1~4, AC-8), degrade 테스트(AC-6/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 후 batch 배정. REQ-049와 동일 batch에 묶어 전달하는 것이 자연스럽다.
#### Validation Plan
- pytest로 AC-1~8 각 케이스 확인. AC-8(로그 비노출)은 caplog fixture로 검증.
### CHG-20260628-02
- Date: 2026-06-28
- Author: 팀장(Claude)
- confirm-req: `Proposed` → `Approved`. dcy-010 NFR-1/2/3 흡수 적절(동일 엔드포인트 보안속성). service-token 전용·web 토큰 차단·타 엔드포인트 credential 미노출(AC-5)·로그 비노출(AC-8)·degrade(AC-6/7) testable. REQ-049와 동일 batch 권고. blocker 없음.
+9 -2
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-046`
- Current: `REQ-052`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -126,12 +126,19 @@
| REQ-043 | GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-043](./Interface/req-043_holdings-cost-basis-read-endpoint.md) |
| 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 | Approved | 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-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | Approved | High | Justin Ko | [req-048](./Migration/req-048_broker-credentials-daemon-id-backfill.md) |
| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달로 확장 | Interface | Approved | High | Justin Ko | [req-049](./Interface/req-049_user-info-daemon-scoped-broker-delivery.md) |
| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | Approved | High | Justin Ko | [req-050](./Interface/req-050_credential-cli-daemon-id-option.md) |
| REQ-051 | 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단 | Non-Functional | Approved | High | Justin Ko | [req-051](./Non-Functional/req-051_broker-credential-security-isolation.md) |
## Recent Change Log Summary
- 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-28: REQ-046~051 신규 (Proposed) — dcy-010 confirmed Discovery(daemon-scoped broker credential provisioning) draft-req. REQ-046(Data: 도메인 모델 daemon_id 필드), REQ-047(Migration: PK 전환 Step1/2), REQ-048(Migration: daemon_id 백필 CLI), REQ-049(Interface: GET /v1/user_info daemon-scoped 단일 broker 전달), REQ-050(Interface: credential CLI --daemon-id 옵션), REQ-051(Non-Functional: 보안 격리).
- 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.
- 2026-06-26: REQ-041 WAC 공식 change-req(CHG-20260626-01) — 매수 수수료 cost basis 편입(사장님 결정). 신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 적용. AC-9/AC-10 추가. 신규 테스트 9개 포함 456 passed / 0 failed. REQ-040/REQ-043 Notes 참조 갱신.
- 2026-06-26: REQ-043 `Approved` → `Implemented` — BAT-018 PR #14 squash-merge(d93c0bc) 팀장 QC PASS 후 origin/main 머지 완료. AC-1~11 전부 PASS(447 passed / 0 failed). 배포 별도(release-candidate).
- 2026-06-25: REQ-043 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-008 근거 Discovery 양방향 링크 확정. OQ-1(service token 소유권 blocker) 해소: cold-start는 `managed_by_daemon_id=daemon` 포트폴리오만 조회하므로 null 케이스 blocker 아님, daemon scope 소유권 정책(REQ-029 선례) 유지 확정. OQ-2(domain 함수) 구현 단계 재량 위임. AC-1~11 유지.
@@ -0,0 +1,69 @@
"""broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1)
Revision ID: 20260628_0026
Revises: 20260605_0025
Create Date: 2026-06-28 00:00:00
NOTE: 이 revision은 REQ-047 Step1 — nullable daemon_id 컬럼 추가.
기존 데이터는 daemon_id=NULL 상태로 보존된다(백필은 REQ-048 CLI 별도 수행).
Step2(PK 전환)는 20260628_0027_broker_credential_daemon_id_step2.py에서 수행.
Step2는 비가역(PK 변경)이므로 사장님 결재 후 별도 적용.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260628_0026"
down_revision = "20260605_0025"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# broker_credentials에 daemon_id TEXT NULL 컬럼 추가
# 기존 PK (user_id, broker)는 그대로 유지
if _table_exists(inspector, "broker_credentials"):
if not _column_exists(inspector, "broker_credentials", "daemon_id"):
op.add_column(
"broker_credentials",
sa.Column("daemon_id", sa.Text(), nullable=True),
)
# daemon_id 인덱스 추가 (조회 성능)
op.create_index(
"idx_broker_credentials_daemon_id",
"broker_credentials",
["daemon_id"],
unique=False,
postgresql_where=sa.text("daemon_id IS NOT NULL"),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "broker_credentials"):
# 인덱스 먼저 제거
existing_indexes = [idx["name"] for idx in inspector.get_indexes("broker_credentials")]
if "idx_broker_credentials_daemon_id" in existing_indexes:
op.drop_index("idx_broker_credentials_daemon_id", table_name="broker_credentials")
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.drop_column("broker_credentials", "daemon_id")
@@ -0,0 +1,144 @@
"""broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2)
Revision ID: 20260628_0027
Revises: 20260628_0026
Create Date: 2026-06-28 00:00:00
CRITICAL: 이 revision은 비가역 PK 변경을 포함한다.
- 실 운영 DB 적용 전 사장님 결재 필수 (dcy-010 R-1, 외부 비가역 작업 게이트)
- REQ-048 backfill-daemon-id CLI로 모든 행의 daemon_id가 채워진 이후에만 적용 가능
(NOT NULL 제약으로 인해 daemon_id=NULL 행 존재 시 upgrade 실패)
- downgrade는 데이터 손실 위험 — 운영 환경에서 rollback 전 별도 데이터 검증 필요
적용 순서:
1. 20260628_0026 (Step1) — daemon_id NULL 컬럼 추가
2. pfhub credential backfill-daemon-id (REQ-048 CLI)
3. 20260628_0027 (Step2, 이 파일) — PK 전환 (사장님 결재 후 적용)
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "20260628_0027"
down_revision = "20260628_0026"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _constraint_exists(bind, table_name: str, constraint_name: str) -> bool:
result = bind.execute(text(
"""
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = :table AND constraint_name = :cname
"""
), {"table": table_name, "cname": constraint_name})
return result.fetchone() is not None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "broker_credentials"):
return
# 사전 검증: daemon_id IS NULL인 행이 있으면 upgrade 거부
result = bind.execute(text(
"SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL"
))
null_count = result.scalar()
if null_count and null_count > 0:
raise RuntimeError(
f"broker_credentials에 daemon_id IS NULL인 행이 {null_count}건 있습니다. "
"REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요."
)
# Step 1: daemon_id NOT NULL 제약 추가
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.alter_column("broker_credentials", "daemon_id", nullable=False)
# Step 2: 기존 PK (user_id, broker) 제거
# PostgreSQL: PK 이름 찾기
pk_constraints = [
c for c in inspector.get_pk_constraint("broker_credentials").get("constrained_columns", [])
]
# PK 제약 이름을 직접 조회
bind.execute(text(
"""
ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey
"""
))
# Step 3: 새 PK (daemon_id, broker) 추가
op.create_primary_key(
"broker_credentials_pkey",
"broker_credentials",
["daemon_id", "broker"],
)
# NOTE: user_id 컬럼은 감사/이력 목적으로 유지 (삭제 금지, REQ-047 AC-4)
def downgrade() -> None:
"""
WARN: 이 downgrade는 데이터 손실 위험을 수반한다.
(daemon_id, broker) PK를 (user_id, broker)로 복원할 때,
같은 user_id + broker 조합이 여러 daemon에 존재하면 충돌이 발생한다.
운영 환경에서는 downgrade 전 반드시 별도 데이터 검증을 수행하라.
"""
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "broker_credentials"):
return
# 안전장치: 실 운영 환경에서 downgrade 시도 경고
# (REQ-047 AC-7: downgrade 실패 또는 명시적 경고 존재)
result = bind.execute(text(
"""
SELECT COUNT(*) FROM (
SELECT user_id, broker, COUNT(*) as cnt
FROM broker_credentials
GROUP BY user_id, broker
HAVING COUNT(*) > 1
) t
"""
))
conflict_count = result.scalar()
if conflict_count and conflict_count > 0:
raise RuntimeError(
f"downgrade 불가: {conflict_count}개 (user_id, broker) 조합이 중복됩니다. "
"같은 user의 여러 daemon이 동일 broker를 사용하고 있어 "
"(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. "
"수동으로 데이터를 정리한 후 downgrade하세요."
)
# PK 전환 역방향: (daemon_id, broker) → (user_id, broker)
bind.execute(text(
"ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey"
))
# daemon_id 컬럼을 nullable로 복원
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.alter_column("broker_credentials", "daemon_id", nullable=True)
# (user_id, broker) PK 복원
op.create_primary_key(
"broker_credentials_pkey",
"broker_credentials",
["user_id", "broker"],
)
@@ -0,0 +1,178 @@
"""BAT-020: REQ-046 BrokerCredential daemon_id 필드 단위 테스트."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from app.domain.models import BrokerCredential
# ---------------------------------------------------------------------------
# REQ-046 AC-1: daemon_id=None 기본값
# ---------------------------------------------------------------------------
def test_broker_credential_daemon_id_defaults_to_none() -> None:
"""REQ-046 AC-1: daemon_id 미지정 시 None이 기본값."""
cred = BrokerCredential(
user_id="user-1",
broker="kis",
account_ref="12345678",
encrypted_payload="encrypted",
)
assert hasattr(cred, "daemon_id")
assert cred.daemon_id is None
# ---------------------------------------------------------------------------
# REQ-046 AC-2: daemon_id 값 지정
# ---------------------------------------------------------------------------
def test_broker_credential_daemon_id_set() -> None:
"""REQ-046 AC-2: daemon_id="daemon-abc" 지정 시 해당 값으로 세팅."""
cred = BrokerCredential(
user_id="user-1",
broker="kis",
account_ref="12345678",
encrypted_payload="encrypted",
daemon_id="daemon-abc",
)
assert cred.daemon_id == "daemon-abc"
# ---------------------------------------------------------------------------
# REQ-046 AC-3: _row_to_broker_credential 7컬럼(daemon_id 포함) 처리
# ---------------------------------------------------------------------------
def test_row_to_broker_credential_with_daemon_id() -> None:
"""REQ-046 AC-3: 7컬럼 행(daemon_id 포함) 변환 시 daemon_id 세팅."""
from app.repositories.postgres import PostgresPortfolioRepository
now = datetime.now(timezone.utc)
row = ("user-1", "kis", "12345678", "encrypted", 2, now, "daemon-xyz")
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
assert cred.user_id == "user-1"
assert cred.broker == "kis"
assert cred.account_ref == "12345678"
assert cred.encrypted_payload == "encrypted"
assert cred.credential_version == 2
assert cred.last_rotated_at == now
assert cred.daemon_id == "daemon-xyz"
# ---------------------------------------------------------------------------
# REQ-046 AC-4: _row_to_broker_credential 6컬럼(daemon_id 없음) 하위호환
# ---------------------------------------------------------------------------
def test_row_to_broker_credential_without_daemon_id() -> None:
"""REQ-046 AC-4: 6컬럼 행(daemon_id 없음) 변환 시 daemon_id=None (하위호환)."""
from app.repositories.postgres import PostgresPortfolioRepository
now = datetime.now(timezone.utc)
row = ("user-1", "kis", "12345678", "encrypted", 1, now)
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
assert cred.daemon_id is None
# ---------------------------------------------------------------------------
# REQ-046 AC-5/6: list_broker_credentials daemon_id 필터 (mock 기반)
# ---------------------------------------------------------------------------
class _FakeCursor:
"""psycopg Cursor 최소 mock."""
def __init__(self, rows):
self._rows = rows
self.last_query = None
self.last_params = None
def execute(self, query, params=None):
self.last_query = query
self.last_params = params
def fetchall(self):
return self._rows
def __enter__(self):
return self
def __exit__(self, *args):
pass
class _FakeConn:
def __init__(self, rows):
self._rows = rows
def cursor(self):
return _FakeCursor(self._rows)
def _make_rows_for_user(user_id: str):
"""daemon_id "d1", "d2" 각각 1건씩."""
now = datetime.now(timezone.utc)
return [
(user_id, "kis", "ref-d1", "enc-d1", 1, now, "d1"),
(user_id, "kis2", "ref-d2", "enc-d2", 1, now, "d2"),
]
def test_list_broker_credentials_daemon_id_filter() -> None:
"""REQ-046 AC-5: daemon_id 필터 시 해당 daemon_id 행만 반환."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
rows = _make_rows_for_user("user-1")
# daemon_id="d1" 필터 시 d1 행만 반환되는 mock
d1_rows = [rows[0]]
config = PortfolioDBConfig(
host="localhost", port=5432, dbname="test",
user="test", password="test", sslmode="disable",
)
repo = PostgresPortfolioRepository(config)
mock_cursor = MagicMock()
mock_cursor.__enter__ = lambda s: mock_cursor
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_cursor.fetchall.return_value = d1_rows
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
repo._connection = mock_conn
# ensure_schema 무력화
with patch.object(repo, "ensure_schema"):
results = repo.list_broker_credentials("user-1", daemon_id="d1")
assert len(results) == 1
assert results[0].daemon_id == "d1"
assert results[0].broker == "kis"
def test_list_broker_credentials_no_daemon_id_filter_returns_all() -> None:
"""REQ-046 AC-6: daemon_id 미지정 시 전체 반환 (하위호환)."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
rows = _make_rows_for_user("user-1")
config = PortfolioDBConfig(
host="localhost", port=5432, dbname="test",
user="test", password="test", sslmode="disable",
)
repo = PostgresPortfolioRepository(config)
mock_cursor = MagicMock()
mock_cursor.__enter__ = lambda s: mock_cursor
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_cursor.fetchall.return_value = rows
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
repo._connection = mock_conn
with patch.object(repo, "ensure_schema"):
results = repo.list_broker_credentials("user-1")
assert len(results) == 2
+460
View File
@@ -0,0 +1,460 @@
"""BAT-020: REQ-050, REQ-048 — credential CLI --daemon-id + backfill 테스트."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
import pytest
from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.core.encryption import encrypt_payload
from app.core.security import hash_token, token_prefix
from app.domain.models import AuthToken, BrokerCredential, Daemon, User
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
# ---------------------------------------------------------------------------
# Minimal FakeRepo for credential CLI tests
# ---------------------------------------------------------------------------
class CredFakeRepo:
"""credential CLI 테스트용 최소 FakeRepo."""
def __init__(self) -> None:
self._users: dict[str, User] = {}
self._daemons: dict[str, Daemon] = {}
self._broker_credentials: dict[tuple, BrokerCredential] = {}
self._seq = 1
def close(self) -> None:
pass
def ping(self) -> None:
pass
def ensure_schema(self) -> None:
pass
def get_user(self, user_id: str) -> User:
u = self._users.get(user_id)
if u is None:
raise ValueError("사용자를 찾을 수 없습니다")
return u
def get_user_by_username(self, username: str) -> User | None:
return next((u for u in self._users.values() if u.username == username), None)
def create_user(self, username: str, **kwargs) -> User:
user = User(
id=f"user-{self._seq}",
username=username,
email=kwargs.get("email"),
permission=kwargs.get("permission", "read"),
is_active=kwargs.get("is_active", True),
)
self._seq += 1
self._users[user.id] = user
return user
def create_daemon(self, owner_user_id: str, *, daemon_slug: str, display_name: str | None = None) -> Daemon:
d = Daemon(
id=f"daemon-{self._seq}",
owner_user_id=owner_user_id,
daemon_slug=daemon_slug,
display_name=display_name,
is_active=True,
created_at=datetime.now(timezone.utc),
)
self._seq += 1
self._daemons[d.id] = d
return d
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None:
return next(
(d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug),
None,
)
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
return self._daemons.get(daemon_id)
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
daemon_id: str | None = None,
) -> BrokerCredential:
self.get_user(user_id)
key = (user_id, broker)
existing = self._broker_credentials.get(key)
cred = BrokerCredential(
user_id=user_id,
broker=broker,
account_ref=account_ref,
encrypted_payload=encrypted_payload,
credential_version=(existing.credential_version + 1) if existing else 1,
last_rotated_at=datetime.now(timezone.utc),
daemon_id=daemon_id,
)
self._broker_credentials[key] = cred
return cred
def list_broker_credentials(
self,
user_id: str,
daemon_id: str | None = None,
) -> list[BrokerCredential]:
creds = [c for (uid, _), c in self._broker_credentials.items() if uid == user_id]
if daemon_id is not None:
creds = [c for c in creds if c.daemon_id == daemon_id]
return sorted(creds, key=lambda c: c.broker)
def update_broker_credential_daemon_id(self, user_id: str, broker: str, daemon_id: str) -> bool:
key = (user_id, broker)
cred = self._broker_credentials.get(key)
if cred is None or cred.daemon_id is not None:
return False
self._broker_credentials[key] = BrokerCredential(
user_id=cred.user_id, broker=cred.broker, account_ref=cred.account_ref,
encrypted_payload=cred.encrypted_payload, credential_version=cred.credential_version,
last_rotated_at=cred.last_rotated_at, daemon_id=daemon_id,
)
return True
def delete_broker_credential(self, user_id: str, broker: str, daemon_id: str | None = None) -> bool:
if daemon_id is not None:
for key, cred in list(self._broker_credentials.items()):
if cred.daemon_id == daemon_id and cred.broker == broker:
del self._broker_credentials[key]
return True
return False
key = (user_id, broker)
if key not in self._broker_credentials:
return False
del self._broker_credentials[key]
return True
def _settings_factory() -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
credential_encryption_key=_TEST_ENC_KEY,
)
def _build_runner(repo: CredFakeRepo) -> Any:
app = create_app(settings_factory=_settings_factory, repository_factory=lambda settings: repo)
runner = CliRunner()
runner.app = app
return runner
def _make_encrypted(app_key: str = "k", app_secret: str = "s") -> str:
return encrypt_payload(_TEST_ENC_KEY, json.dumps({"app_key": app_key, "app_secret": app_secret}))
# ---------------------------------------------------------------------------
# REQ-050 AC-1: credential set --daemon-id 성공
# ---------------------------------------------------------------------------
def test_credential_set_with_daemon_id() -> None:
"""REQ-050 AC-1: set --daemon-id → broker_credentials에 daemon_id 포함 upsert."""
repo = CredFakeRepo()
user = repo.create_user("alice")
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "set",
"--user", "alice",
"--daemon-id", "orb-bot",
"--broker", "kis",
"--account-ref", "acc-123",
"--app-key", "mykey",
"--app-secret", "mysecret",
"--force",
])
assert result.exit_code == 0, result.output
payload = json.loads(result.stdout)
assert payload["broker"] == "kis"
assert payload["daemon_id"] == daemon.id
assert payload["account_ref"] == "acc-123"
# ---------------------------------------------------------------------------
# REQ-050 AC-2: credential list --daemon-id 필터
# ---------------------------------------------------------------------------
def test_credential_list_daemon_id_filter() -> None:
"""REQ-050 AC-2: list --daemon-id → 해당 daemon 인증정보만 출력."""
repo = CredFakeRepo()
user = repo.create_user("alice")
d1 = repo.create_daemon(user.id, daemon_slug="bot-d1")
d2 = repo.create_daemon(user.id, daemon_slug="bot-d2")
enc = _make_encrypted()
repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id)
repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id)
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "list",
"--user", "alice",
"--daemon-id", "bot-d1",
])
assert result.exit_code == 0, result.output
items = json.loads(result.stdout)
assert len(items) == 1
assert items[0]["daemon_id"] == d1.id
# ---------------------------------------------------------------------------
# REQ-050 AC-3: credential list 전체 (daemon-id 미지정)
# ---------------------------------------------------------------------------
def test_credential_list_no_daemon_id_returns_all() -> None:
"""REQ-050 AC-3: list --daemon-id 미지정 → 전체 인증정보."""
repo = CredFakeRepo()
user = repo.create_user("alice")
d1 = repo.create_daemon(user.id, daemon_slug="bot-d1")
d2 = repo.create_daemon(user.id, daemon_slug="bot-d2")
enc = _make_encrypted()
repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id)
repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id)
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "list",
"--user", "alice",
])
assert result.exit_code == 0, result.output
items = json.loads(result.stdout)
assert len(items) == 2
# ---------------------------------------------------------------------------
# REQ-050 AC-4: credential remove --daemon-id
# ---------------------------------------------------------------------------
def test_credential_remove_with_daemon_id() -> None:
"""REQ-050 AC-4: remove --daemon-id → (daemon_id, broker) 기반 삭제."""
repo = CredFakeRepo()
user = repo.create_user("alice")
d1 = repo.create_daemon(user.id, daemon_slug="orb-bot")
enc = _make_encrypted()
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=d1.id)
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "remove",
"--user", "alice",
"--daemon-id", "orb-bot",
"--broker", "kis",
"--force",
])
assert result.exit_code == 0, result.output
remaining = repo.list_broker_credentials(user.id)
assert len(remaining) == 0
# ---------------------------------------------------------------------------
# REQ-050 AC-5: 존재하지 않는 daemon_slug → exit 1
# ---------------------------------------------------------------------------
def test_credential_set_nonexistent_daemon_exits_1() -> None:
"""REQ-050 AC-5: 존재하지 않는 daemon_slug → 오류 + exit 1."""
repo = CredFakeRepo()
repo.create_user("alice")
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "set",
"--user", "alice",
"--daemon-id", "nonexistent-bot",
"--broker", "kis",
"--account-ref", "acc",
"--app-key", "k",
"--app-secret", "s",
"--force",
])
assert result.exit_code != 0
# ---------------------------------------------------------------------------
# REQ-050 AC-7: CLI 출력에 daemon_id 포함
# ---------------------------------------------------------------------------
def test_credential_set_output_includes_daemon_id() -> None:
"""REQ-050 AC-7: set 성공 출력에 daemon_id 포함."""
repo = CredFakeRepo()
user = repo.create_user("alice")
daemon = repo.create_daemon(user.id, daemon_slug="tsl-bot")
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "set",
"--user", "alice",
"--daemon-id", "tsl-bot",
"--broker", "kis",
"--account-ref", "acc-999",
"--app-key", "k2",
"--app-secret", "s2",
"--force",
])
assert result.exit_code == 0, result.output
payload = json.loads(result.stdout)
assert "daemon_id" in payload
assert "broker" in payload
assert "account_ref" in payload
assert "credential_version" in payload
assert "last_rotated_at" in payload
# ---------------------------------------------------------------------------
# REQ-048 AC-1/2: backfill-daemon-id 정상 백필
# ---------------------------------------------------------------------------
def test_backfill_daemon_id_updates_null_rows() -> None:
"""REQ-048 AC-1/2: daemon_id IS NULL 인증정보 → 백필 후 daemon_id 세팅."""
repo = CredFakeRepo()
user = repo.create_user("alice")
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
enc = _make_encrypted()
# daemon_id=None으로 등록 (기존 user-scoped 방식)
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None)
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "backfill-daemon-id",
"--user", "alice",
"--daemon-id", "orb-bot",
"--force",
])
assert result.exit_code == 0, result.output
# 백필 후 daemon_id 세팅 확인
updated = repo.list_broker_credentials(user.id)
assert len(updated) == 1
assert updated[0].daemon_id == daemon.id
# ---------------------------------------------------------------------------
# REQ-048 AC-3: --dry-run 시 DB 변경 없음
# ---------------------------------------------------------------------------
def test_backfill_daemon_id_dry_run_no_change() -> None:
"""REQ-048 AC-3: --dry-run → 실제 UPDATE 없음."""
repo = CredFakeRepo()
user = repo.create_user("alice")
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
enc = _make_encrypted()
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None)
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "backfill-daemon-id",
"--user", "alice",
"--daemon-id", "orb-bot",
"--dry-run",
])
assert result.exit_code == 0, result.output
# DB 변경 없음 (dry-run)
creds = repo.list_broker_credentials(user.id)
assert creds[0].daemon_id is None
# ---------------------------------------------------------------------------
# REQ-048 AC-5: daemon_id IS NULL 없으면 "이미 완료됨"
# ---------------------------------------------------------------------------
def test_backfill_daemon_id_already_complete() -> None:
"""REQ-048 AC-5: daemon_id IS NULL 없으면 이미 완료됨 + exit 0."""
repo = CredFakeRepo()
user = repo.create_user("alice")
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
enc = _make_encrypted()
# 이미 daemon_id 있음
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=daemon.id)
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "backfill-daemon-id",
"--user", "alice",
"--daemon-id", "orb-bot",
"--force",
])
assert result.exit_code == 0, result.output
assert "already_complete" in result.stdout or "이미 완료됨" in result.stdout
# ---------------------------------------------------------------------------
# REQ-048 AC-6: 존재하지 않는 user → exit 1
# ---------------------------------------------------------------------------
def test_backfill_daemon_id_nonexistent_user_exits_1() -> None:
"""REQ-048 AC-6: 존재하지 않는 user --user → exit 1."""
repo = CredFakeRepo()
repo.create_daemon.__doc__ # dummy
runner = _build_runner(repo)
result = runner.invoke(runner.app, [
"--format", "json", "credential", "backfill-daemon-id",
"--user", "nobody",
"--daemon-id", "orb-bot",
"--force",
])
assert result.exit_code != 0
# ---------------------------------------------------------------------------
# REQ-048 AC-4: 백필 전후 encrypted_payload 동등 (데이터 손실 없음)
# ---------------------------------------------------------------------------
def test_backfill_preserves_encrypted_payload() -> None:
"""REQ-048 AC-4: 백필 전후 encrypted_payload, account_ref 동등."""
repo = CredFakeRepo()
user = repo.create_user("alice")
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
enc = _make_encrypted("orig_key", "orig_secret")
repo.upsert_broker_credential(
user.id, "kis", account_ref="original-ref", encrypted_payload=enc, daemon_id=None
)
original_encrypted = enc
original_account_ref = "original-ref"
runner = _build_runner(repo)
runner.invoke(runner.app, [
"--format", "json", "credential", "backfill-daemon-id",
"--user", "alice",
"--daemon-id", "orb-bot",
"--force",
])
creds = repo.list_broker_credentials(user.id)
assert creds[0].encrypted_payload == original_encrypted
assert creds[0].account_ref == original_account_ref
@@ -0,0 +1,436 @@
"""BAT-020: REQ-049, REQ-051 — GET /v1/user_info daemon-scoped + 보안 격리 테스트."""
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.auth import AuthContext
from app.core.config import Settings, get_settings
from app.core.encryption import encrypt_payload
from app.domain.models import BrokerCredential, TokenPrincipal, User
from app.main import create_app
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
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 _svc_token(token: str = "svc-token") -> dict:
return {"Authorization": f"Bearer {token}"}
def _make_cred(user_id: str, daemon_id: str, broker: str = "kis", account_ref: str = "ref") -> BrokerCredential:
plain = json.dumps({"app_key": "k", "app_secret": "s"})
encrypted = encrypt_payload(_TEST_ENC_KEY, plain)
return BrokerCredential(
user_id=user_id, broker=broker, account_ref=account_ref,
encrypted_payload=encrypted, credential_version=1,
daemon_id=daemon_id,
)
# ---------------------------------------------------------------------------
# REQ-049 AC-1: daemon_id 있음 + broker 있음 → 200 + 단일 broker
# ---------------------------------------------------------------------------
def test_user_info_daemon_scoped_broker_returned() -> None:
"""REQ-049 AC-1: daemon_id 바인딩 token + 해당 daemon broker 있음 → 단일 BrokerItem."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
cred = _make_cred("u1", "d1", account_ref="acc-d1")
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = 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=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert body["broker"] is not None
assert body["broker"]["account_ref"] == "acc-d1"
assert body["broker"]["broker"] == "kis"
assert body["broker"]["credential_payload"] == {"app_key": "k", "app_secret": "s"}
# REQ-049 AC-6: brokers 키 없음
assert "brokers" not in body
# ---------------------------------------------------------------------------
# REQ-049 AC-2: daemon_id 있음 + broker 없음 → 200 + broker=null
# ---------------------------------------------------------------------------
def test_user_info_daemon_no_broker_returns_null() -> None:
"""REQ-049 AC-2: daemon_id 있음, 해당 daemon 인증정보 없음 → broker=null."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [] # 인증정보 없음
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=_svc_token())
assert resp.status_code == 200
assert resp.json()["broker"] is None
# ---------------------------------------------------------------------------
# REQ-049 AC-3, REQ-051 AC-3: daemon_id 없는 service token → 403
# ---------------------------------------------------------------------------
def test_user_info_no_daemon_id_returns_403() -> None:
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None service token → 403."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", 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=_svc_token())
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-049 AC-4/5: 같은 user 2데몬 격리
# ---------------------------------------------------------------------------
def test_user_info_daemon_isolation_two_daemons() -> None:
"""REQ-049 AC-4/5: daemon A, B가 각각 다른 account_ref를 반환."""
settings = _make_settings()
user = User(id="u1", username="alice", permission="read", is_active=True)
cred_a = _make_cred("u1", "d-a", account_ref="acc-A")
cred_b = _make_cred("u1", "d-b", account_ref="acc-B")
principal_a = TokenPrincipal(
token_id="ta", user_id="u1", permission="read",
token_type="service", daemon_id="d-a",
)
principal_b = TokenPrincipal(
token_id="tb", user_id="u1", permission="read",
token_type="service", daemon_id="d-b",
)
# daemon A 호출
mock_repo_a = MagicMock()
mock_repo_a.get_token_principal.return_value = principal_a
mock_repo_a.get_user.return_value = user
mock_repo_a.list_broker_credentials.return_value = [cred_a]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_a), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_a):
client = _make_client(settings, mock_repo_a)
resp_a = client.get("/v1/user_info", headers=_svc_token())
assert resp_a.status_code == 200
assert resp_a.json()["broker"]["account_ref"] == "acc-A"
# daemon B 호출
mock_repo_b = MagicMock()
mock_repo_b.get_token_principal.return_value = principal_b
mock_repo_b.get_user.return_value = user
mock_repo_b.list_broker_credentials.return_value = [cred_b]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_b), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_b):
client_b = _make_client(settings, mock_repo_b)
resp_b = client_b.get("/v1/user_info", headers=_svc_token())
assert resp_b.status_code == 200
assert resp_b.json()["broker"]["account_ref"] == "acc-B"
# 상호 반환 없음
assert resp_a.json()["broker"]["account_ref"] != resp_b.json()["broker"]["account_ref"]
# ---------------------------------------------------------------------------
# REQ-049 AC-6: 응답에 broker 단일 키 존재, brokers 리스트 없음
# ---------------------------------------------------------------------------
def test_user_info_response_has_broker_not_brokers() -> None:
"""REQ-049 AC-6: 응답 JSON에 broker 단일 키만 있고 brokers 키 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
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=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert "broker" in body
assert "brokers" not in body
# ---------------------------------------------------------------------------
# REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null
# ---------------------------------------------------------------------------
def test_user_info_decryption_failure_returns_null_payload() -> None:
"""REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null, 크래시 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
# 잘못된 encrypted_payload (복호화 실패)
cred = BrokerCredential(
user_id="u1", broker="kis", account_ref="ref",
encrypted_payload="INVALID_PAYLOAD",
credential_version=1, daemon_id="d1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = 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=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert body["broker"] is not None
assert body["broker"]["account_ref"] == "ref"
assert body["broker"]["credential_payload"] is None # 복호화 실패
# ---------------------------------------------------------------------------
# REQ-051 AC-1: web 토큰 → 403
# ---------------------------------------------------------------------------
def test_user_info_web_token_returns_403() -> None:
"""REQ-051 AC-1: web user 토큰(token_type!=service) → 403."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="user", # service 아님
daemon_id="d1",
)
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=_svc_token())
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-051 AC-2: 비인증 → 401
# ---------------------------------------------------------------------------
def test_user_info_unauthenticated_returns_401() -> None:
"""REQ-051 AC-2: Authorization 헤더 없음 → 401."""
settings = _make_settings()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = None # 토큰 없음
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info") # 헤더 없음
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# REQ-051 AC-4: 다른 daemon 인증정보 → null (데이터 격리)
# ---------------------------------------------------------------------------
def test_user_info_different_daemon_credential_not_returned() -> None:
"""REQ-051 AC-4: daemon_id="d1" token이 daemon_id="d2" 인증정보를 받지 않음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
# daemon_id="d1" 필터로 조회하면 빈 결과 (DB에는 d2만 있음)
mock_repo.list_broker_credentials.return_value = []
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=_svc_token())
assert resp.status_code == 200
assert resp.json()["broker"] is None
# ---------------------------------------------------------------------------
# REQ-051 AC-5: 타 엔드포인트 응답에 credential_payload 미포함
# ---------------------------------------------------------------------------
def test_other_endpoints_do_not_expose_credential_payload(monkeypatch) -> None:
"""REQ-051 AC-5: GET /health, /v1/portfolios 등에 credential 필드 없음."""
settings = _make_settings()
client = TestClient(create_app(settings_factory=lambda: settings))
# /health 응답 검사
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert "credential_payload" not in body
assert "app_secret" not in body
assert "app_key" not in body
# ---------------------------------------------------------------------------
# REQ-051 AC-6: DB 오류 → broker=null graceful degrade (500 없음)
# ---------------------------------------------------------------------------
def test_user_info_db_error_returns_null_broker() -> None:
"""REQ-051 AC-6: list_broker_credentials DB 오류 → broker=null, 500 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.side_effect = RuntimeError("DB connection error")
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=_svc_token())
assert resp.status_code == 200
assert resp.json()["broker"] is None
# ---------------------------------------------------------------------------
# REQ-051 AC-8: 복호화 실패 시 로그에 app_secret 미노출
# ---------------------------------------------------------------------------
def test_user_info_decryption_failure_log_does_not_expose_secret(caplog) -> None:
"""REQ-051 AC-8: 복호화 실패 로그에 app_key, app_secret 값 미포함."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
# 잘못된 encrypted_payload
cred = BrokerCredential(
user_id="u1", broker="kis", account_ref="ref",
encrypted_payload="INVALID_PAYLOAD_SECRET_VALUE",
credential_version=1, daemon_id="d1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [cred]
with caplog.at_level(logging.WARNING, logger="app.api.v1_user_info"), \
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)
client.get("/v1/user_info", headers=_svc_token())
# 로그에 app_secret, app_key 값이 없음 확인
log_text = " ".join(caplog.messages)
assert "mysecret" not in log_text
assert "mykey" not in log_text
# 로그에 INVALID_PAYLOAD 원문이 없음 (payload 노출 아님)
# 단지 오류 유형(class name)만 기록
assert "INVALID_PAYLOAD_SECRET_VALUE" not in log_text
# ---------------------------------------------------------------------------
# REQ-049 AC-9: 기존 필드들 회귀 없음
# ---------------------------------------------------------------------------
def test_user_info_regression_existing_fields_unchanged() -> None:
"""REQ-049 AC-9: broker 변경이 runtime_id, daemon_id, trading_enabled 등에 영향 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", runtime_id="rt-xyz", daemon_id="d1",
)
user = User(id="u1", username="alice", email="alice@example.com", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
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=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert body["runtime_id"] == "rt-xyz"
assert body["daemon_id"] == "d1"
assert body["trading_enabled"] is True
assert body["user_id"] == "u1"
assert body["username"] == "alice"
assert body["permission"] == "read"
+64 -13
View File
@@ -175,6 +175,7 @@ class FakeRepo:
*,
account_ref: str,
encrypted_payload: str,
daemon_id: str | None = None,
) -> BrokerCredential:
self.get_user(user_id)
key = (user_id, broker)
@@ -186,17 +187,65 @@ class FakeRepo:
encrypted_payload=encrypted_payload,
credential_version=(existing.credential_version + 1) if existing is not None else 1,
last_rotated_at=datetime.now(timezone.utc),
daemon_id=daemon_id,
)
self._broker_credentials[key] = credential
return credential
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
return sorted(
[credential for (owner_user_id, _), credential in self._broker_credentials.items() if owner_user_id == user_id],
key=lambda credential: credential.broker,
def list_broker_credentials(
self,
user_id: str,
daemon_id: str | None = None,
) -> list[BrokerCredential]:
creds = [
credential
for (owner_user_id, _), credential in self._broker_credentials.items()
if owner_user_id == user_id
]
if daemon_id is not None:
creds = [c for c in creds if c.daemon_id == daemon_id]
return sorted(creds, key=lambda credential: credential.broker)
def update_broker_credential_daemon_id(
self,
user_id: str,
broker: str,
daemon_id: str,
) -> bool:
key = (user_id, broker)
cred = self._broker_credentials.get(key)
if cred is None or cred.daemon_id is not None:
return False
self._broker_credentials[key] = BrokerCredential(
user_id=cred.user_id,
broker=cred.broker,
account_ref=cred.account_ref,
encrypted_payload=cred.encrypted_payload,
credential_version=cred.credential_version,
last_rotated_at=cred.last_rotated_at,
daemon_id=daemon_id,
)
return True
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str):
return next(
(d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug),
None,
)
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
def delete_broker_credential(
self,
user_id: str,
broker: str,
daemon_id: str | None = None,
) -> bool:
if daemon_id is not None:
# daemon_id 기반 삭제
for key, cred in list(self._broker_credentials.items()):
if cred.daemon_id == daemon_id and cred.broker == broker:
del self._broker_credentials[key]
return True
return False
key = (user_id, broker)
if key not in self._broker_credentials:
return False
@@ -986,8 +1035,10 @@ def test_cli_daemon_create_and_list_render_entries() -> None:
def test_cli_credential_set_list_and_remove_flow() -> None:
"""REQ-050: --daemon-id 포함 credential set/list/remove 플로우 (BAT-020 갱신)."""
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
runner = build_runner(repo)
set_result = runner.invoke(
@@ -999,6 +1050,8 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
"set",
"--user",
"alice",
"--daemon-id",
"orb-bot",
"--broker",
"kis",
"--account-ref",
@@ -1023,18 +1076,16 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
set_payload = json.loads(set_result.stdout)
assert set_payload["user_id"] == user.id
assert set_payload["broker"] == "kis"
assert set_payload["daemon_id"] == daemon.id
assert set_payload["credential_version"] == 1
assert list_result.exit_code == 0
list_payload = json.loads(list_result.stdout)
assert list_payload == [
{
"broker": "kis",
"account_ref": "123-45",
"credential_version": 1,
"last_rotated_at": list_payload[0]["last_rotated_at"],
}
]
assert len(list_payload) == 1
assert list_payload[0]["broker"] == "kis"
assert list_payload[0]["account_ref"] == "123-45"
assert list_payload[0]["daemon_id"] == daemon.id
assert list_payload[0]["credential_version"] == 1
assert remove_result.exit_code == 0
remove_payload = json.loads(remove_result.stdout)
+4 -3
View File
@@ -120,7 +120,8 @@ def test_user_info_returns_daemon_id_in_response(monkeypatch) -> None:
assert resp.json()["daemon_id"] == "d-1"
def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
def test_user_info_null_daemon_id_returns_403(monkeypatch) -> None:
"""REQ-049 AC-3: daemon_id=None service token → 403 Forbidden (BAT-020 정책 변경)."""
client = _app_with_db(monkeypatch)
principal = _principal(daemon_id=None)
user = User(id="user-1", username="alice", permission="read", is_active=True)
@@ -134,8 +135,8 @@ def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.get("/v1/user_info", headers=_svc_header())
assert resp.status_code == 200
assert resp.json()["daemon_id"] is None
# REQ-049: daemon_id 없는 service token은 user_info 403 (daemon-scoped 인가)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
+63 -24
View File
@@ -118,7 +118,7 @@ class _RuntimeIdRoundTripRepo:
daemon_id=token.daemon_id,
)
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
def list_broker_credentials(self, user_id: str, daemon_id: str | None = None) -> list[BrokerCredential]:
return []
@@ -144,6 +144,9 @@ def test_health_returns_full_response() -> None:
# ---------------------------------------------------------------------------
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
# REQ-049: daemon_id 없는 service token → user_info 403
# 이 테스트는 service token 발급의 runtime_id 생성을 검증하고,
# daemon_id 없는 token으로 user_info 호출 시 403을 확인한다.
repo = _RuntimeIdRoundTripRepo()
settings = _make_settings(
db_enabled=True,
@@ -168,15 +171,13 @@ def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
assert issue_body["runtime_id"] is not None
assert "token" in issue_body
# REQ-049 AC-3: daemon_id 없는 service token → user_info 403
user_info_resp = client.get(
"/v1/user_info",
headers=_service_token_header(issue_body["token"]),
)
assert user_info_resp.status_code == 200
user_info_body = user_info_resp.json()
assert user_info_body["runtime_id"] == issue_body["runtime_id"]
assert user_info_body["username"] == "alice"
assert user_info_resp.status_code == 403
def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None:
@@ -214,11 +215,12 @@ def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monke
# ---------------------------------------------------------------------------
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
# REQ-049: daemon_id 있음 + runtime_id=None → 200 반환
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id
token_type="service", runtime_id=None, daemon_id="daemon-1",
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
@@ -237,11 +239,12 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None
def test_v1_user_info_returns_user(monkeypatch) -> None:
# REQ-049: daemon_id 있음 + broker=None → 200 반환
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-abc"
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
)
user = User(
id="user-1", username="alice", email="alice@example.com",
@@ -264,15 +267,39 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
assert body["username"] == "alice"
assert body["trading_enabled"] is True
assert body["disabled_reason"] is None
assert body["brokers"] == []
# REQ-049 AC-6: broker 단일 필드 존재, brokers 리스트 미존재
assert "broker" in body
assert "brokers" not in body
assert body["broker"] is None # 인증정보 없음 → null
def test_v1_user_info_no_daemon_id_returns_403(monkeypatch) -> None:
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None 서비스 토큰 → 403."""
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-abc", 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_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 403
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
# daemon_id 있음 + 비활성 user → disabled_reason
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-2", permission="read",
token_type="service", runtime_id="rt-xyz"
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
)
user = User(
id="user-2", username="bob", email=None,
@@ -295,8 +322,8 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
assert body["disabled_reason"] == "account_inactive"
def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
"""REQ-007 AC5: brokers[]에 복호화된 credential_payload 반환."""
def test_v1_user_info_returns_decrypted_broker_credential(monkeypatch) -> None:
"""REQ-049 AC-1: daemon_id 있음 + broker 인증정보 있음 → 단일 broker 반환."""
from app.core.encryption import encrypt_payload
import json as _json
@@ -307,12 +334,13 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-abc"
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
cred = BrokerCredential(
user_id="user-1", broker="kis", account_ref="12345678",
encrypted_payload=encrypted, credential_version=1,
daemon_id="daemon-1",
)
mock_repo = MagicMock()
@@ -331,8 +359,11 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
assert resp.status_code == 200
body = resp.json()
assert len(body["brokers"]) == 1
broker = body["brokers"][0]
# REQ-049 AC-6: broker 단일 필드, brokers 리스트 없음
assert "broker" in body
assert "brokers" not in body
broker = body["broker"]
assert broker is not None
assert broker["broker"] == "kis"
assert broker["credential_payload"] == {"app_key": "mykey", "app_secret": "mysecret"}
@@ -793,12 +824,13 @@ def test_v1_assets_read_token_200(monkeypatch) -> None:
# GET /v1/user_info
# ---------------------------------------------------------------------------
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
def test_v1_user_info_null_runtime_id_returns_200_with_null_b(monkeypatch) -> None:
# REQ-049: daemon_id 있음 + runtime_id=None → 200
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id=None, # no runtime_id
token_type="service", runtime_id=None, daemon_id="daemon-1",
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
@@ -816,12 +848,13 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None
assert resp.json()["runtime_id"] is None
def test_v1_user_info_returns_user(monkeypatch) -> None:
def test_v1_user_info_returns_user_b(monkeypatch) -> None:
# REQ-049 AC-9 회귀: daemon_id 있음 → 기존 필드들 정상 반환
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-abc"
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
)
user = User(
id="user-1", username="alice", email="alice@example.com",
@@ -831,6 +864,7 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
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):
@@ -842,12 +876,13 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
assert body["runtime_id"] == "rt-abc"
assert body["username"] == "alice"
assert body["disabled_reason"] is None
# REQ-049 AC-6: broker 단일 필드, brokers 키 없음
assert "broker" in body
assert "brokers" not in body
def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch) -> None:
"""issue #9 regression: broker_credentials lookup failure (missing table / DB error)
must NOT crash /v1/user_info with 500. daemon_id/user_id/permission do not depend on
broker credentials — degrade gracefully with empty brokers."""
"""REQ-049 AC-2, REQ-051 AC-6: broker_credentials lookup failure → broker=null graceful degrade."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
@@ -872,15 +907,18 @@ def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch)
body = resp.json()
assert body["daemon_id"] == "daemon-9"
assert body["user_id"] == "user-1"
assert body["brokers"] == []
# REQ-049 AC-2: broker=null (graceful degrade, 크래시 없음)
assert body["broker"] is None
assert "brokers" not in body
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
def test_v1_user_info_inactive_user_has_disabled_reason_b(monkeypatch) -> None:
# daemon_id 있음 + 비활성 user
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-2", permission="read",
token_type="service", runtime_id="rt-xyz"
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
)
user = User(
id="user-2", username="bob", email=None,
@@ -890,6 +928,7 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
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):