ci / lint-test-leaks (pull_request) Canceled after 0s
- verify_audit_chain(): rows with entry_hash IS NULL are only tolerated BEFORE the first hashed row (documented legacy prefix). A NULL hash AFTER the chain started is tampering and fails verification at that row id — previously any NULL row anywhere was silently skipped, so an attacker with UPDATE could erase intermediate evidence undetected. - migration 0004: audit_log becomes append-only. SQLite: BEFORE UPDATE/ DELETE triggers RAISE(ABORT). Postgres: BEFORE triggers RAISE EXCEPTION. Closes the hole for the table owner and direct SQL connections. - monitor.verify_chain_against_anchors(): compares the chain tail against anchors published to Nostr. Explicit statuses: no_anchors (never anchored / notes missing on relays), local_only (no fetcher), ok (relay note attests the tail or the last local anchor), mismatch. - tests: NULL-before-genesis ok, NULL-in-middle fails at that id, NULL late fails, UPDATE rejected, DELETE rejected, INSERT still allowed, no-anchors explicit, local-only explicit, mismatch detected, match ok. Gate: ruff 0 · 81 unit + 1 PG integration · alembic 0001→0004 on PG · gitleaks clean · make check exit 0.
90 lines
2.6 KiB
Python
90 lines
2.6 KiB
Python
"""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;"))
|