feat(bat-020): daemon-scoped 브로커 인증정보 (REQ-046~051)

- REQ-046: BrokerCredential.daemon_id 필드 추가, repository 시그니처 갱신
- REQ-047: alembic revision Step1(daemon_id NULL 컬럼 추가), Step2(PK 전환, dev/test DB 한정)
- REQ-048: backfill-daemon-id CLI subcommand (--dry-run/--force/--broker 지원)
- REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 (brokers→broker, daemon_id 없으면 403)
- REQ-050: credential set/list/remove --daemon-id 옵션 추가 (daemon_slug 해석)
- REQ-051: 보안 격리 - service-token 전용, web 토큰 차단, 로그 app_secret 비노출, graceful degrade

비가역 결재 게이트: REQ-047 Step2(PK 전환)는 사장님 결재 후 실DB 적용 예정(이 batch는 revision 코드만)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-28 00:45:46 +09:00
co-authored by Claude Sonnet 4.6
parent fc99f507a5
commit 51b7cbee35
16 changed files with 1878 additions and 86 deletions
+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):