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
+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)