Files
ModelForge/backend/alembic/versions/20260830_0024_audit_chain_checkpoint.py
T

718 lines
29 KiB
Python

"""Version and checkpoint the tamper-evident audit chain.
Revision ID: 20260830_0024
Revises: 20260830_0023
"""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from datetime import UTC, datetime
from typing import Any
import sqlalchemy as sa
from sqlalchemy.engine import Connection
from alembic import op
revision = "20260830_0024"
down_revision = "20260830_0023"
branch_labels = None
depends_on = None
_LEGACY_PREFIX_DOMAIN = b"modelforge:audit:legacy-prefix:v1\n"
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_AUDIT_CHAIN_LOCK_KEY = int.from_bytes(b"MF_AUDIT", byteorder="big", signed=False)
_AUDIT_OWNER_ROLE = "modelforge"
_AUDIT_RUNTIME_ROLE = "modelforge_runtime"
_POSTGRES_AUDIT_BOUNDARY_SQL = r"""
create schema if not exists modelforge_audit authorization modelforge;
alter schema modelforge_audit owner to modelforge;
revoke all on schema modelforge_audit from public;
revoke create on schema public from modelforge_runtime;
grant usage on schema public, modelforge_audit to modelforge_runtime;
create or replace function modelforge_audit.enforce_owner_mutation()
returns trigger
language plpgsql
security invoker
set search_path = pg_catalog
as $guard$
begin
if current_user <> 'modelforge' then
raise exception 'audit tables are writable only through the canonical append function'
using errcode = '42501';
end if;
if tg_op = 'DELETE' then
return old;
elsif tg_op = 'TRUNCATE' then
return null;
end if;
return new;
end
$guard$;
alter function modelforge_audit.enforce_owner_mutation() owner to modelforge;
revoke all on function modelforge_audit.enforce_owner_mutation() from public;
revoke all on function modelforge_audit.enforce_owner_mutation() from modelforge_runtime;
drop trigger if exists trg_modelforge_audit_events_owner on public.audit_events;
create trigger trg_modelforge_audit_events_owner
before insert or update or delete on public.audit_events
for each row execute function modelforge_audit.enforce_owner_mutation();
drop trigger if exists trg_modelforge_audit_events_truncate_owner on public.audit_events;
create trigger trg_modelforge_audit_events_truncate_owner
before truncate on public.audit_events
for each statement execute function modelforge_audit.enforce_owner_mutation();
drop trigger if exists trg_modelforge_audit_head_owner on public.audit_chain_heads;
create trigger trg_modelforge_audit_head_owner
before insert or update or delete on public.audit_chain_heads
for each row execute function modelforge_audit.enforce_owner_mutation();
drop trigger if exists trg_modelforge_audit_head_truncate_owner on public.audit_chain_heads;
create trigger trg_modelforge_audit_head_truncate_owner
before truncate on public.audit_chain_heads
for each statement execute function modelforge_audit.enforce_owner_mutation();
create or replace function modelforge_audit.append_event_v2(
p_event_id uuid,
p_occurred_at timestamptz,
p_correlation_id text,
p_actor_type text,
p_actor_id text,
p_action text,
p_resource_type text,
p_resource_id text,
p_outcome text,
p_details jsonb,
p_expected_event_count bigint,
p_expected_last_sequence bigint,
p_expected_last_event_hash text,
p_expected_hash_format text,
p_expected_v2_start_sequence bigint,
p_expected_legacy_prefix_count bigint,
p_expected_legacy_prefix_seal text
)
returns table(event_id uuid, sequence bigint, event_hash text, occurred_at timestamptz)
language plpgsql
security definer
set search_path = pg_catalog
as $append$
declare
v_head public.audit_chain_heads%rowtype;
v_tail public.audit_events%rowtype;
v_max_sequence bigint;
v_predecessor_hash text;
v_sequence bigint;
v_payload text;
v_hash text;
v_updated bigint;
begin
perform pg_catalog.pg_advisory_xact_lock(5568242723498248532);
if p_event_id is null or p_occurred_at is null or p_correlation_id is null
or p_actor_type is null or p_actor_id is null or p_action is null
or p_resource_type is null or p_outcome is null or p_details is null then
raise exception 'canonical audit append arguments must not be null'
using errcode = '23502';
end if;
if not pg_catalog.isfinite(p_occurred_at) then
raise exception 'canonical audit occurred_at must be finite' using errcode = '22008';
end if;
if pg_catalog.jsonb_typeof(p_details) <> 'object' then
raise exception 'canonical audit details must be a JSON object'
using errcode = '22023';
end if;
select head.* into v_head
from public.audit_chain_heads as head
where head.singleton_id = 1
for update;
if not found then
raise exception 'audit checkpoint is missing' using errcode = '23514';
end if;
if v_head.event_count is distinct from p_expected_event_count
or v_head.last_sequence is distinct from p_expected_last_sequence
or v_head.last_event_hash is distinct from p_expected_last_event_hash
or v_head.hash_format is distinct from p_expected_hash_format
or v_head.v2_start_sequence is distinct from p_expected_v2_start_sequence
or v_head.legacy_prefix_count is distinct from p_expected_legacy_prefix_count
or v_head.legacy_prefix_seal is distinct from p_expected_legacy_prefix_seal then
raise exception 'audit checkpoint changed before canonical append'
using errcode = '40001';
end if;
if v_head.hash_format <> 'v2'
or v_head.event_count <> v_head.last_sequence
or v_head.event_count < 0
or v_head.v2_start_sequence < 1
or v_head.legacy_prefix_count <> v_head.v2_start_sequence - 1
or v_head.legacy_prefix_count > v_head.event_count
or v_head.legacy_prefix_seal !~ '^[0-9a-f]{64}$' then
raise exception 'audit checkpoint invariants are invalid' using errcode = '23514';
end if;
select events.sequence into v_max_sequence
from public.audit_events as events
order by events.sequence desc, events.id desc
limit 1;
if v_head.event_count = 0 then
if v_max_sequence is not null or v_head.last_event_hash is not null then
raise exception 'empty audit checkpoint has retained events' using errcode = '23514';
end if;
else
if v_max_sequence is distinct from v_head.last_sequence
or v_head.last_event_hash is null then
raise exception 'audit checkpoint does not identify the retained tail'
using errcode = '23514';
end if;
select events.* into v_tail
from public.audit_events as events
where events.sequence = v_head.last_sequence;
if not found or v_tail.event_hash is distinct from v_head.last_event_hash
or v_tail.event_hash !~ '^[0-9a-f]{64}$' then
raise exception 'audit retained tail is missing or does not match the checkpoint'
using errcode = '23514';
end if;
if v_tail.hash_format = 'v2' then
if v_tail.canonical_payload is null
or pg_catalog.encode(
pg_catalog.sha256(pg_catalog.convert_to(v_tail.canonical_payload, 'UTF8')),
'hex'
) <> v_tail.event_hash
or v_tail.canonical_payload::jsonb is distinct from pg_catalog.jsonb_build_object(
'hash_format', 'v2',
'id', v_tail.id::text,
'occurred_at', pg_catalog.to_char(
v_tail.occurred_at at time zone 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'
),
'correlation_id', v_tail.correlation_id,
'actor_type', v_tail.actor_type,
'actor_id', v_tail.actor_id,
'action', v_tail.action,
'resource_type', v_tail.resource_type,
'resource_id', v_tail.resource_id,
'outcome', v_tail.outcome,
'details', v_tail.details::jsonb,
'previous_event_hash', v_tail.previous_event_hash
) then
raise exception 'v2 audit retained tail payload is invalid'
using errcode = '23514';
end if;
elsif v_tail.hash_format <> 'v1'
or v_tail.sequence <> v_head.v2_start_sequence - 1 then
raise exception 'audit retained tail hash format is invalid' using errcode = '23514';
end if;
if v_tail.sequence = 1 then
if v_tail.previous_event_hash is not null then
raise exception 'first audit event has a previous hash' using errcode = '23514';
end if;
else
select events.event_hash into v_predecessor_hash
from public.audit_events as events
where events.sequence = v_tail.sequence - 1;
if not found or v_tail.previous_event_hash is distinct from v_predecessor_hash then
raise exception 'audit retained tail link is invalid' using errcode = '23514';
end if;
end if;
end if;
v_sequence := v_head.last_sequence + 1;
v_payload := pg_catalog.jsonb_build_object(
'hash_format', 'v2',
'id', p_event_id::text,
'occurred_at', pg_catalog.to_char(
p_occurred_at at time zone 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'
),
'correlation_id', p_correlation_id,
'actor_type', p_actor_type,
'actor_id', p_actor_id,
'action', p_action,
'resource_type', p_resource_type,
'resource_id', p_resource_id,
'outcome', p_outcome,
'details', p_details,
'previous_event_hash', v_head.last_event_hash
)::text;
v_hash := pg_catalog.encode(
pg_catalog.sha256(pg_catalog.convert_to(v_payload, 'UTF8')), 'hex'
);
insert into public.audit_events (
id, sequence, occurred_at, correlation_id, actor_type, actor_id, action,
resource_type, resource_id, outcome, details, previous_event_hash, event_hash,
hash_format, canonical_payload
) values (
p_event_id, v_sequence, p_occurred_at, p_correlation_id, p_actor_type, p_actor_id,
p_action, p_resource_type, p_resource_id, p_outcome, p_details,
v_head.last_event_hash, v_hash, 'v2', v_payload
);
update public.audit_chain_heads as head
set event_count = v_head.event_count + 1,
last_sequence = v_sequence,
last_event_hash = v_hash,
updated_at = p_occurred_at
where head.singleton_id = 1
and head.event_count = v_head.event_count
and head.last_sequence = v_head.last_sequence
and head.last_event_hash is not distinct from v_head.last_event_hash
and head.hash_format = v_head.hash_format
and head.v2_start_sequence = v_head.v2_start_sequence
and head.legacy_prefix_count = v_head.legacy_prefix_count
and head.legacy_prefix_seal = v_head.legacy_prefix_seal;
get diagnostics v_updated = row_count;
if v_updated <> 1 then
raise exception 'audit checkpoint compare-and-set failed' using errcode = '40001';
end if;
return query select p_event_id, v_sequence, v_hash, p_occurred_at;
end
$append$;
alter function modelforge_audit.append_event_v2(
uuid, timestamptz, text, text, text, text, text, text, text, jsonb,
bigint, bigint, text, text, bigint, bigint, text
) owner to modelforge;
revoke all on function modelforge_audit.append_event_v2(
uuid, timestamptz, text, text, text, text, text, text, text, jsonb,
bigint, bigint, text, text, bigint, bigint, text
) from public;
grant execute on function modelforge_audit.append_event_v2(
uuid, timestamptz, text, text, text, text, text, text, text, jsonb,
bigint, bigint, text, text, bigint, bigint, text
) to modelforge_runtime;
grant select, insert, update, delete on all tables in schema public to modelforge_runtime;
grant usage, select on all sequences in schema public to modelforge_runtime;
revoke execute on all functions in schema public from public, modelforge_runtime;
revoke insert, update, delete, truncate, references, trigger
on public.audit_events, public.audit_chain_heads from modelforge_runtime;
grant select on public.audit_events, public.audit_chain_heads to modelforge_runtime;
alter default privileges for role modelforge in schema public
grant select, insert, update, delete on tables to modelforge_runtime;
alter default privileges for role modelforge in schema public
grant usage, select on sequences to modelforge_runtime;
alter default privileges for role modelforge in schema public
revoke execute on functions from public;
do $database_privileges$
begin
execute pg_catalog.format(
'revoke create, temporary on database %I from modelforge_runtime',
pg_catalog.current_database()
);
end
$database_privileges$;
"""
def _normalise_timestamp(value: datetime | str) -> str:
if isinstance(value, datetime):
moment = value
elif isinstance(value, str):
candidate = value.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
moment = datetime.fromisoformat(candidate)
except ValueError as error:
raise RuntimeError("legacy audit event has an invalid occurred_at") from error
else:
raise RuntimeError("legacy audit event has an invalid occurred_at")
if moment.tzinfo is None:
moment = moment.replace(tzinfo=UTC)
return moment.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
def _details(value: Any) -> dict[str, Any]:
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as error:
raise RuntimeError("legacy audit event details are not valid JSON") from error
if not isinstance(value, dict):
raise RuntimeError("legacy audit event details must be a JSON object")
return value
def _legacy_hash(row: sa.RowMapping) -> str:
payload = {
"correlation_id": row["correlation_id"],
"actor_type": row["actor_type"],
"actor_id": row["actor_id"],
"action": row["action"],
"resource_type": row["resource_type"],
"resource_id": row["resource_id"],
"outcome": row["outcome"],
"details": _details(row["details"]),
"previous_event_hash": row["previous_event_hash"],
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _prefix_entry(row: sa.RowMapping) -> bytes:
try:
event_id = str(uuid.UUID(str(row["id"])))
except (AttributeError, TypeError, ValueError) as error:
raise RuntimeError("legacy audit event id is not a UUID") from error
payload = {
"sequence": int(row["sequence"]),
"id": event_id,
"occurred_at": _normalise_timestamp(row["occurred_at"]),
"event_hash": str(row["event_hash"]),
}
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
def _validate_legacy_rows(rows: list[sa.RowMapping]) -> dict[str, Any]:
"""Validate the production-shaped v1 chain and return its immutable cutover state."""
previous_hash: str | None = None
prefix = hashlib.sha256()
prefix.update(_LEGACY_PREFIX_DOMAIN)
for expected_sequence, row in enumerate(rows, start=1):
try:
sequence = int(row["sequence"])
except (TypeError, ValueError) as error:
raise RuntimeError("legacy audit event sequence is not an integer") from error
if sequence != expected_sequence:
raise RuntimeError(
f"legacy audit chain has sequence {sequence}; expected {expected_sequence}"
)
event_hash = str(row["event_hash"])
if _SHA256.fullmatch(event_hash) is None:
raise RuntimeError(f"legacy audit event {sequence} has a malformed event hash")
if row["previous_event_hash"] != previous_hash:
raise RuntimeError(f"legacy audit event {sequence} has an invalid previous hash")
if _legacy_hash(row) != event_hash:
raise RuntimeError(f"legacy audit event {sequence} content hash is invalid")
prefix.update(_prefix_entry(row))
previous_hash = event_hash
count = len(rows)
return {
"event_count": count,
"last_sequence": count,
"last_event_hash": previous_hash,
"v2_start_sequence": count + 1,
"legacy_prefix_count": count,
"legacy_prefix_seal": prefix.hexdigest(),
}
def _read_and_validate_legacy_chain(connection: Connection) -> dict[str, Any]:
rows = list(
connection.execute(
sa.text(
"select id, sequence, occurred_at, correlation_id, actor_type, actor_id, "
"action, resource_type, resource_id, outcome, details, previous_event_hash, "
"event_hash from audit_events order by sequence, id"
)
).mappings()
)
return _validate_legacy_rows(rows)
def _lock_legacy_audit_chain(connection: Connection) -> None:
"""Serialize validation and checkpoint seed against every legacy writer.
The shared advisory key coordinates with 0024-aware writers. ``ACCESS EXCLUSIVE`` also blocks
pre-0024 applications, which do not know that key, until this migration transaction commits.
SQLite is test-only; a no-op write upgrades its deferred transaction to a writer before the
validation read so another test connection cannot append into the validation/seed window.
"""
dialect = connection.dialect.name
if dialect == "postgresql":
connection.execute(
sa.text("select pg_advisory_xact_lock(:lock_key)"),
{"lock_key": _AUDIT_CHAIN_LOCK_KEY},
)
connection.execute(sa.text("lock table audit_events in access exclusive mode"))
return
if dialect == "sqlite":
connection.execute(sa.text("update audit_events set event_hash = event_hash where 1 = 0"))
return
raise RuntimeError(f"audit-chain migration does not support the {dialect!r} dialect")
def _validate_postgres_role_preflight(connection: Connection) -> None:
"""Require the separately provisioned non-superuser owner/runtime roles before DDL.
Existing 1.2.1 installations commonly made ``modelforge`` the cluster bootstrap superuser.
That credential cannot be converted into the API boundary implicitly by an application
migration. Operators must first run the documented admin-owned provisioning step; failure is
deliberately before this migration changes a column or seeds a checkpoint.
"""
if connection.dialect.name != "postgresql":
return
roles = list(
connection.execute(
sa.text(
"select rolname, rolsuper, rolinherit, rolcreaterole, rolcreatedb, "
"rolcanlogin, rolreplication, rolbypassrls from pg_catalog.pg_roles "
"where rolname in (:owner_role, :runtime_role) order by rolname"
),
{"owner_role": _AUDIT_OWNER_ROLE, "runtime_role": _AUDIT_RUNTIME_ROLE},
).mappings()
)
by_name = {str(row["rolname"]): row for row in roles}
if set(by_name) != {_AUDIT_OWNER_ROLE, _AUDIT_RUNTIME_ROLE}:
raise RuntimeError(
"audit migration preflight requires separately provisioned modelforge owner and "
"modelforge_runtime roles; run the v1.2.1-to-schema-0024 role provisioning step"
)
current_role = str(connection.scalar(sa.text("select current_user")))
if current_role != _AUDIT_OWNER_ROLE:
raise RuntimeError(
"audit migration must run with the non-superuser modelforge owner credential"
)
session_role = str(connection.scalar(sa.text("select session_user")))
if session_role != _AUDIT_OWNER_ROLE:
raise RuntimeError(
"audit migration must authenticate directly as modelforge, not SET ROLE from admin"
)
for role_name, require_noinherit in (
(_AUDIT_OWNER_ROLE, False),
(_AUDIT_RUNTIME_ROLE, True),
):
role = by_name[role_name]
forbidden = any(
bool(role[field])
for field in (
"rolsuper",
"rolcreaterole",
"rolcreatedb",
"rolreplication",
"rolbypassrls",
)
)
if forbidden or not bool(role["rolcanlogin"]):
raise RuntimeError(f"database role {role_name} has forbidden administrative powers")
if require_noinherit and bool(role["rolinherit"]):
raise RuntimeError("modelforge_runtime must be provisioned NOINHERIT")
app_role_membership_count = int(
connection.scalar(
sa.text(
"select count(*) from pg_catalog.pg_auth_members as membership "
"join pg_catalog.pg_roles as member on member.oid = membership.member "
"where member.rolname in (:runtime_role, :owner_role)"
),
{"runtime_role": _AUDIT_RUNTIME_ROLE, "owner_role": _AUDIT_OWNER_ROLE},
)
or 0
)
if app_role_membership_count:
raise RuntimeError(
"modelforge and modelforge_runtime must have no SET ROLE-capable memberships"
)
def _install_postgres_audit_boundary(connection: Connection) -> None:
if connection.dialect.name == "postgresql":
for statement in _postgres_sql_statements(_POSTGRES_AUDIT_BOUNDARY_SQL):
_exec_postgres_sql(connection, statement)
def _exec_postgres_sql(connection: Connection, statement: str) -> None:
"""Execute trusted static SQL without exposing PostgreSQL percent syntax to DBAPI parsing."""
paramstyle = getattr(connection.dialect, "paramstyle", None)
driver_statement = (
statement.replace("%", "%%")
if paramstyle in {"format", "pyformat"}
else statement
)
connection.exec_driver_sql(driver_statement)
def _postgres_sql_statements(script: str) -> list[str]:
"""Split this migration's trusted static SQL without splitting function bodies."""
statements: list[str] = []
start = 0
index = 0
quote: str | None = None
while index < len(script):
if quote is not None:
if quote == "'" and script.startswith("''", index):
index += 2
continue
if script.startswith(quote, index):
index += len(quote)
quote = None
continue
index += 1
continue
character = script[index]
if character == "'":
quote = "'"
index += 1
continue
if character == "$":
delimiter = re.match(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$", script[index:])
if delimiter is not None:
quote = delimiter.group(0)
index += len(quote)
continue
if character == ";":
statement = script[start:index].strip()
if statement:
statements.append(statement)
start = index + 1
index += 1
trailing = script[start:].strip()
if quote is not None:
raise RuntimeError("generated PostgreSQL audit boundary SQL has an unterminated literal")
if trailing:
statements.append(trailing)
return statements
def _remove_postgres_audit_boundary(connection: Connection) -> None:
if connection.dialect.name != "postgresql":
return
script = (
"drop trigger if exists trg_modelforge_audit_events_owner on public.audit_events; "
"drop trigger if exists trg_modelforge_audit_events_truncate_owner "
"on public.audit_events; "
"drop trigger if exists trg_modelforge_audit_head_owner on public.audit_chain_heads; "
"drop trigger if exists trg_modelforge_audit_head_truncate_owner "
"on public.audit_chain_heads; "
"drop function if exists modelforge_audit.append_event_v2("
"uuid, timestamptz, text, text, text, text, text, text, text, jsonb, "
"bigint, bigint, text, text, bigint, bigint, text); "
"drop function if exists modelforge_audit.enforce_owner_mutation(); "
"drop schema if exists modelforge_audit; "
"grant select, insert on public.audit_events to modelforge_runtime"
)
for statement in _postgres_sql_statements(script):
_exec_postgres_sql(connection, statement)
def upgrade() -> None:
connection = op.get_bind()
_validate_postgres_role_preflight(connection)
_lock_legacy_audit_chain(connection)
# Validation deliberately precedes every schema mutation. In particular, legacy recovery
# markers written with a random hash stop the migration instead of being blessed by a seal.
legacy = _read_and_validate_legacy_chain(connection)
op.add_column(
"audit_events",
sa.Column("hash_format", sa.String(length=16), nullable=True),
)
op.add_column(
"audit_events",
sa.Column("canonical_payload", sa.Text(), nullable=True),
)
connection.execute(sa.text("update audit_events set hash_format = 'v1'"))
with op.batch_alter_table("audit_events") as batch:
batch.alter_column(
"hash_format",
existing_type=sa.String(length=16),
nullable=False,
)
batch.create_check_constraint(
"ck_audit_event_hash_format", "hash_format IN ('v1', 'v2')"
)
batch.create_check_constraint(
"ck_audit_event_canonical_payload",
"((hash_format = 'v1' AND canonical_payload IS NULL) OR "
"(hash_format = 'v2' AND canonical_payload IS NOT NULL))",
)
op.create_table(
"audit_chain_heads",
sa.Column("singleton_id", sa.Integer(), nullable=False),
sa.Column("event_count", sa.BigInteger(), nullable=False),
sa.Column("last_sequence", sa.BigInteger(), nullable=False),
sa.Column("last_event_hash", sa.String(length=64), nullable=True),
sa.Column("hash_format", sa.String(length=16), nullable=False),
sa.Column("v2_start_sequence", sa.BigInteger(), nullable=False),
sa.Column("legacy_prefix_count", sa.BigInteger(), nullable=False),
sa.Column("legacy_prefix_seal", sa.String(length=64), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.CheckConstraint("singleton_id = 1", name="ck_audit_chain_head_singleton"),
sa.CheckConstraint("event_count >= 0", name="ck_audit_chain_head_count"),
sa.CheckConstraint("last_sequence >= 0", name="ck_audit_chain_head_sequence"),
sa.CheckConstraint(
"event_count = last_sequence",
name="ck_audit_chain_head_count_sequence",
),
sa.CheckConstraint("v2_start_sequence >= 1", name="ck_audit_chain_head_cutover"),
sa.CheckConstraint(
"legacy_prefix_count = v2_start_sequence - 1",
name="ck_audit_chain_head_prefix_count",
),
sa.CheckConstraint(
"legacy_prefix_count <= event_count",
name="ck_audit_chain_head_prefix_within_chain",
),
sa.CheckConstraint("hash_format = 'v2'", name="ck_audit_chain_head_hash_format"),
sa.CheckConstraint(
"length(legacy_prefix_seal) = 64",
name="ck_audit_chain_head_prefix_seal",
),
sa.CheckConstraint(
"((event_count = 0 AND last_sequence = 0 AND last_event_hash IS NULL) OR "
"(event_count > 0 AND last_sequence > 0 AND last_event_hash IS NOT NULL))",
name="ck_audit_chain_head_shape",
),
sa.PrimaryKeyConstraint("singleton_id"),
)
connection.execute(
sa.text(
"insert into audit_chain_heads (singleton_id, event_count, last_sequence, "
"last_event_hash, hash_format, v2_start_sequence, legacy_prefix_count, "
"legacy_prefix_seal) values (1, :event_count, :last_sequence, :last_event_hash, "
"'v2', :v2_start_sequence, :legacy_prefix_count, :legacy_prefix_seal)"
),
legacy,
)
_install_postgres_audit_boundary(connection)
def downgrade() -> None:
connection = op.get_bind()
_validate_postgres_role_preflight(connection)
_lock_legacy_audit_chain(connection)
non_legacy = int(
connection.scalar(
sa.text("select count(*) from audit_events where hash_format <> 'v1'")
)
or 0
)
if non_legacy:
raise RuntimeError(
"cannot downgrade audit hash format after v2 events exist without rewriting history"
)
legacy = _read_and_validate_legacy_chain(connection)
head = connection.execute(
sa.text(
"select event_count, last_sequence, last_event_hash, hash_format, "
"v2_start_sequence, legacy_prefix_count, legacy_prefix_seal "
"from audit_chain_heads where singleton_id = 1"
)
).mappings().one_or_none()
if head is None or head["hash_format"] != "v2":
raise RuntimeError("cannot downgrade a missing or malformed audit checkpoint")
for key, expected in legacy.items():
if head[key] != expected:
raise RuntimeError(f"cannot downgrade: audit checkpoint {key} is inconsistent")
_remove_postgres_audit_boundary(connection)
op.drop_table("audit_chain_heads")
with op.batch_alter_table("audit_events") as batch:
batch.drop_constraint("ck_audit_event_canonical_payload", type_="check")
batch.drop_constraint("ck_audit_event_hash_format", type_="check")
batch.drop_column("canonical_payload")
batch.drop_column("hash_format")