Close the statement that emptied the audit trail in silence - #148
Merged
davidmckayv merged 2 commits intoAug 22, 2026
Conversation
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
requested review from
MikeRyanDev,
guidovizoso and
tylerslaton
as code owners
August 22, 2026 04:00
davidmckayv
deleted the
fix/truncate-is-not-a-way-around-the-audit-trail
branch
August 22, 2026 04:18
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_eventsis 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 wasaudit_events_append_only, a row-level trigger.A row-level trigger cannot fire on
TRUNCATE. So everyUPDATEandDELETEwas refused whileTRUNCATE audit_eventstook 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 TRUNCATEtrigger, sharing the existing function so the refusal is written once and the two cannot drift apart.The function now answers
TG_OPbefore it reads anything else. That ordering is the fix, not decoration: a statement trigger has noOLDrecord, so a guard that reachesOLD.created_at >= now() - ...compares againstNULL, theIFdoes not fire, and control falls through toRETURN OLDand 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.
TRUNCATEcannot name a window, so it is refused outright.Proof
Against a real database, before the migration:
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:
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
INSERTandSELECTonly. Without it an owner can stillDROP TRIGGER, so this closes the one-statement path rather than making the guarantee absolute. Worth its own conversation, as the issue says.