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:
@@ -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"],
|
||||
)
|
||||
Reference in New Issue
Block a user