feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -0,0 +1,233 @@
"""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",
)