Skip to content

Close the statement that emptied the audit trail in silence - #148

Merged
davidmckayv merged 2 commits into
mainfrom
fix/truncate-is-not-a-way-around-the-audit-trail
Aug 22, 2026
Merged

Close the statement that emptied the audit trail in silence#148
davidmckayv merged 2 commits into
mainfrom
fix/truncate-is-not-a-way-around-the-audit-trail

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

Closes #138. Thanks to @beardthelion for the report, which also named the failure mode of the obvious fix and saved this from shipping as one.

What was open

audit_events is append-only, and that is enforced in the database rather than the application because the application is not the only thing that can reach the table. The enforcement was audit_events_append_only, a row-level trigger.

A row-level trigger cannot fire on TRUNCATE. So every UPDATE and DELETE was refused while TRUNCATE audit_events took every row and returned success. "We deleted the rows about the incident" had a one-statement way around it, and the statement raised nothing.

The fix

A statement-level BEFORE TRUNCATE trigger, sharing the existing function so the refusal is written once and the two cannot drift apart.

The function now answers TG_OP before it reads anything else. That ordering is the fix, not decoration: a statement trigger has no OLD record, so a guard that reaches OLD.created_at >= now() - ... compares against NULL, the IF does not fire, and control falls through to RETURN OLD and the truncate proceeds. That path is open exactly while a retention sweep has the window set — the one state a happy-path test does not check.

Retention is unaffected. Retention deletes: it names a window and removes what is older than it, so the rows about last night survive by being newer. TRUNCATE cannot name a window, so it is refused outright.

Proof

Against a real database, before the migration:

(fail) the whole table cannot be truncated
Expected: true   Received: false

258 audit rows before, 258 after — because the test rolls back either way (see below).

After the migration, by hand as the bootstrap superuser, with the retention window set to the value that opens the fail-open path:

begin;
select set_config('openbot.audit_retention_days', '30', true);
truncate table audit_events;
ERROR:  Audit events are append-only
CONTEXT:  PL/pgSQL function prevent_audit_event_mutation() line 7 at RAISE

Full retention suite: 10 pass. drizzle-kit check: clean.

About the tests

Both new tests run their truncate inside a transaction that always throws, so it always rolls back.

That is not tidiness. If this regresses, a committed truncate in the suite would destroy the audit trail of whatever database it is pointed at, and the run that discovers the bug must not also be the run that demonstrates how bad it is. A truncate that goes through leaves the sentinel on the cause chain instead of the refusal, so the assertion fails with the table still whole. Verified above: it failed, and every row was still there.

Still out of scope

Running the application as a non-owner role with INSERT and SELECT only. Without it an owner can still DROP TRIGGER, so this closes the one-statement path rather than making the guarantee absolute. Worth its own conversation, as the issue says.

The trail is append-only, enforced in the database because the
application is not the only thing that can reach the table. That
enforcement was a row-level trigger, and a row-level trigger cannot fire
on TRUNCATE. So UPDATE and DELETE were refused while
`TRUNCATE audit_events` removed every row and returned success. The
guarantee that "we deleted the rows about the incident" stays impossible
had a one-statement way around it.

Retention is untouched, because retention deletes: it names a window and
removes what is older, and the rows about last night survive by being
newer than it. TRUNCATE cannot name a window. It takes the whole table,
recent rows included, which is the thing the trail exists to prevent, so
it is refused in every session whatever any setting says.

The operation is answered before anything else is read. A statement-level
trigger has no OLD record, so a guard that reaches `OLD.created_at` is
comparing against NULL and falls through to the return that lets the
statement proceed. That path is open exactly while a retention sweep has
set the window, which is the state a happy-path check does not look at.

Both states are covered by tests against a real database. Each one runs
its truncate in a transaction that always throws, so a regression here
cannot destroy the trail of whatever database the suite is pointed at:
the run that finds the bug must not also be the run that demonstrates it.

Reported in #138, including the failure mode of the obvious fix.
@davidmckayv
davidmckayv merged commit 3fc187f into main Aug 22, 2026
8 checks passed
@davidmckayv
davidmckayv deleted the fix/truncate-is-not-a-way-around-the-audit-trail branch August 22, 2026 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TRUNCATE erases the audit trail, and the append-only trigger never runs

1 participant