"""audit_log append-only: reject UPDATE and DELETE (round-2 hardening) Revision ID: 0004 Revises: 0003 Create Date: 2026-10-28 An audit trail that can be edited is not an audit trail. This migration installs, on every engine: - SQLite: BEFORE UPDATE/DELETE triggers that RAISE(ABORT). - Postgres: BEFORE UPDATE/DELETE triggers that RAISE EXCEPTION. The writer roles never had UPDATE/DELETE grants in roles.sql; this closes the hole for the table owner and for direct SQL connections. """ from __future__ import annotations from alembic import op revision = "0004" down_revision = "0003" branch_labels = None depends_on = None SQLITE_TRIGGERS = [ """ CREATE TRIGGER IF NOT EXISTS audit_log_no_update BEFORE UPDATE ON audit_log BEGIN SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (UPDATE rejected)'); END; """, """ CREATE TRIGGER IF NOT EXISTS audit_log_no_delete BEFORE DELETE ON audit_log BEGIN SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (DELETE rejected)'); END; """, ] PG_TRIGGERS = """ CREATE OR REPLACE FUNCTION cayce_ops_audit_append_only() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'cayce-ops: audit_log is append-only (% rejected)', TG_OP; END; $$ LANGUAGE plpgsql; """ def upgrade() -> None: bind = op.get_bind() if bind.dialect.name == "postgresql": op.execute(PG_TRIGGERS) op.execute( "DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;" ) op.execute( "CREATE TRIGGER audit_log_no_update BEFORE UPDATE ON audit_log " "FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();" ) op.execute( "DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;" ) op.execute( "CREATE TRIGGER audit_log_no_delete BEFORE DELETE ON audit_log " "FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();" ) else: for ddl in SQLITE_TRIGGERS: bind.execute(sa_text(ddl)) def sa_text(sql: str): import sqlalchemy as sa return sa.text(sql) def downgrade() -> None: bind = op.get_bind() if bind.dialect.name == "postgresql": op.execute("DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;") op.execute("DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;") op.execute("DROP FUNCTION IF EXISTS cayce_ops_audit_append_only();") else: bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_update;")) bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_delete;"))