234 lines
8.1 KiB
Python
234 lines
8.1 KiB
Python
|
|
"""add typed risk disposition state and append-only events
|
||
|
|
|
||
|
|
Revision ID: 20260716_0011
|
||
|
|
Revises: 20260716_0010
|
||
|
|
Create Date: 2026-07-16 16:20:00
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections.abc import Sequence
|
||
|
|
|
||
|
|
import sqlalchemy as sa
|
||
|
|
|
||
|
|
from alembic import op
|
||
|
|
|
||
|
|
revision: str = "20260716_0011"
|
||
|
|
down_revision: str | None = "20260716_0010"
|
||
|
|
branch_labels: str | Sequence[str] | None = None
|
||
|
|
depends_on: str | Sequence[str] | None = None
|
||
|
|
|
||
|
|
|
||
|
|
def _require_postgresql() -> None:
|
||
|
|
dialect_name = op.get_bind().dialect.name
|
||
|
|
if dialect_name != "postgresql":
|
||
|
|
raise RuntimeError(
|
||
|
|
"20260716_0011 only supports PostgreSQL; "
|
||
|
|
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _require_empty_audit_chain_for_downgrade() -> None:
|
||
|
|
bind = op.get_bind()
|
||
|
|
disposition_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_dispositions")) or 0)
|
||
|
|
event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_disposition_events")) or 0)
|
||
|
|
if disposition_count or event_count:
|
||
|
|
raise RuntimeError(
|
||
|
|
"cannot downgrade risk dispositions: audit chain is not empty "
|
||
|
|
f"({disposition_count} dispositions, {event_count} events)"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def upgrade() -> None:
|
||
|
|
_require_postgresql()
|
||
|
|
op.create_unique_constraint(
|
||
|
|
"uq_risk_observations_tenant_id",
|
||
|
|
"risk_observations",
|
||
|
|
["tenant_id", "id"],
|
||
|
|
)
|
||
|
|
op.create_table(
|
||
|
|
"risk_dispositions",
|
||
|
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("observation_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column(
|
||
|
|
"adjudication",
|
||
|
|
sa.String(length=24),
|
||
|
|
nullable=False,
|
||
|
|
server_default="unreviewed",
|
||
|
|
),
|
||
|
|
sa.Column(
|
||
|
|
"lifecycle_status",
|
||
|
|
sa.String(length=32),
|
||
|
|
nullable=False,
|
||
|
|
server_default="open",
|
||
|
|
),
|
||
|
|
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
|
||
|
|
sa.Column("assignee", sa.String(length=120), nullable=True),
|
||
|
|
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||
|
|
sa.Column("resolution", sa.Text(), nullable=True),
|
||
|
|
sa.Column(
|
||
|
|
"created_at",
|
||
|
|
sa.DateTime(timezone=True),
|
||
|
|
nullable=False,
|
||
|
|
server_default=sa.func.now(),
|
||
|
|
),
|
||
|
|
sa.Column(
|
||
|
|
"updated_at",
|
||
|
|
sa.DateTime(timezone=True),
|
||
|
|
nullable=False,
|
||
|
|
server_default=sa.func.now(),
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"adjudication IN ('unreviewed', 'confirmed', 'false_positive')",
|
||
|
|
name="ck_risk_dispositions_adjudication",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"lifecycle_status IN ('open', 'supplement_requested', "
|
||
|
|
"'remediation_in_progress', 'waiver_requested', 'resolved')",
|
||
|
|
name="ck_risk_dispositions_lifecycle",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint("version >= 0", name="ck_risk_dispositions_version"),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "observation_id"],
|
||
|
|
["risk_observations.tenant_id", "risk_observations.id"],
|
||
|
|
name="fk_risk_dispositions_tenant_observation",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.PrimaryKeyConstraint("id"),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"tenant_id",
|
||
|
|
"id",
|
||
|
|
name="uq_risk_dispositions_tenant_id",
|
||
|
|
),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"tenant_id",
|
||
|
|
"observation_id",
|
||
|
|
name="uq_risk_dispositions_tenant_observation",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_risk_dispositions_tenant_lifecycle_due",
|
||
|
|
"risk_dispositions",
|
||
|
|
["tenant_id", "lifecycle_status", "due_at"],
|
||
|
|
unique=False,
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_risk_dispositions_assignee",
|
||
|
|
"risk_dispositions",
|
||
|
|
["tenant_id", "assignee"],
|
||
|
|
unique=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
op.create_table(
|
||
|
|
"risk_disposition_events",
|
||
|
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("disposition_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("observation_id", sa.String(length=36), nullable=False),
|
||
|
|
sa.Column("version", sa.Integer(), nullable=False),
|
||
|
|
sa.Column("action", sa.String(length=32), nullable=False),
|
||
|
|
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||
|
|
sa.Column("actor_name", sa.String(length=120), nullable=False),
|
||
|
|
sa.Column("request_id", sa.String(length=120), nullable=False),
|
||
|
|
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
|
||
|
|
sa.Column("comment", sa.Text(), nullable=True),
|
||
|
|
sa.Column("before_json", sa.JSON(), nullable=False),
|
||
|
|
sa.Column("after_json", sa.JSON(), nullable=False),
|
||
|
|
sa.Column(
|
||
|
|
"created_at",
|
||
|
|
sa.DateTime(timezone=True),
|
||
|
|
nullable=False,
|
||
|
|
server_default=sa.func.now(),
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"action IN ('confirm', 'false_positive', 'request_supplement', "
|
||
|
|
"'start_remediation', 'resolve', 'request_waiver')",
|
||
|
|
name="ck_risk_disposition_events_action",
|
||
|
|
),
|
||
|
|
sa.CheckConstraint(
|
||
|
|
"version > 0",
|
||
|
|
name="ck_risk_disposition_events_version",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "disposition_id"],
|
||
|
|
["risk_dispositions.tenant_id", "risk_dispositions.id"],
|
||
|
|
name="fk_risk_disposition_events_tenant_disposition",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.ForeignKeyConstraint(
|
||
|
|
["tenant_id", "observation_id"],
|
||
|
|
["risk_observations.tenant_id", "risk_observations.id"],
|
||
|
|
name="fk_risk_disposition_events_tenant_observation",
|
||
|
|
ondelete="RESTRICT",
|
||
|
|
),
|
||
|
|
sa.PrimaryKeyConstraint("id"),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"tenant_id",
|
||
|
|
"request_id",
|
||
|
|
name="uq_risk_disposition_events_tenant_request",
|
||
|
|
),
|
||
|
|
sa.UniqueConstraint(
|
||
|
|
"disposition_id",
|
||
|
|
"version",
|
||
|
|
name="uq_risk_disposition_events_version",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_risk_disposition_events_disposition_id",
|
||
|
|
"risk_disposition_events",
|
||
|
|
["disposition_id"],
|
||
|
|
unique=False,
|
||
|
|
)
|
||
|
|
op.create_index(
|
||
|
|
"ix_risk_disposition_events_tenant_observation_time",
|
||
|
|
"risk_disposition_events",
|
||
|
|
["tenant_id", "observation_id", "created_at"],
|
||
|
|
unique=False,
|
||
|
|
)
|
||
|
|
op.execute(
|
||
|
|
"CREATE FUNCTION reject_risk_disposition_event_mutation() "
|
||
|
|
"RETURNS trigger AS $$ "
|
||
|
|
"BEGIN "
|
||
|
|
"RAISE EXCEPTION 'risk_disposition_events is append-only'; "
|
||
|
|
"RETURN OLD; "
|
||
|
|
"END; "
|
||
|
|
"$$ LANGUAGE plpgsql"
|
||
|
|
)
|
||
|
|
op.execute(
|
||
|
|
"CREATE TRIGGER trg_risk_disposition_events_append_only "
|
||
|
|
"BEFORE UPDATE OR DELETE ON risk_disposition_events "
|
||
|
|
"FOR EACH ROW EXECUTE FUNCTION reject_risk_disposition_event_mutation()"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def downgrade() -> None:
|
||
|
|
_require_postgresql()
|
||
|
|
# 审计链不可静默销毁;数据归档或迁移必须由显式运维流程完成。
|
||
|
|
_require_empty_audit_chain_for_downgrade()
|
||
|
|
op.execute("DROP TRIGGER trg_risk_disposition_events_append_only ON risk_disposition_events")
|
||
|
|
op.execute("DROP FUNCTION reject_risk_disposition_event_mutation()")
|
||
|
|
op.drop_index(
|
||
|
|
"ix_risk_disposition_events_tenant_observation_time",
|
||
|
|
table_name="risk_disposition_events",
|
||
|
|
)
|
||
|
|
op.drop_index(
|
||
|
|
"ix_risk_disposition_events_disposition_id",
|
||
|
|
table_name="risk_disposition_events",
|
||
|
|
)
|
||
|
|
op.drop_table("risk_disposition_events")
|
||
|
|
op.drop_index(
|
||
|
|
"ix_risk_dispositions_assignee",
|
||
|
|
table_name="risk_dispositions",
|
||
|
|
)
|
||
|
|
op.drop_index(
|
||
|
|
"ix_risk_dispositions_tenant_lifecycle_due",
|
||
|
|
table_name="risk_dispositions",
|
||
|
|
)
|
||
|
|
op.drop_table("risk_dispositions")
|
||
|
|
op.drop_constraint(
|
||
|
|
"uq_risk_observations_tenant_id",
|
||
|
|
"risk_observations",
|
||
|
|
type_="unique",
|
||
|
|
)
|