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
@@ -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"],
)