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:
+63
-24
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user