Skip to content

Keep the audit trail from being truncated away - #139

Closed
beardthelion wants to merge 2 commits into
CopilotKit:mainfrom
beardthelion:fix/audit-truncate-guard
Closed

Keep the audit trail from being truncated away#139
beardthelion wants to merge 2 commits into
CopilotKit:mainfrom
beardthelion:fix/audit-truncate-guard

Conversation

@beardthelion

Copy link
Copy Markdown
Contributor

What this changes

TRUNCATE audit_events erased the whole trail without raising, because audit_events_append_only is FOR EACH ROW and a row-level trigger cannot fire on truncate. A statement-level trigger now refuses it.

The TG_OP = 'TRUNCATE' branch is answered before the retention setting is read, and that ordering is the fix rather than an implementation detail. Reusing the existing function unchanged fails open: a statement trigger has no OLD, so OLD.created_at >= now() - ... is NULL, the IF does not fire, and control reaches RETURN OLD. Measured both ways: with the setting unset it refused, with it set to '30' it truncated the table.

Closes #138.

Where it runs

  • New state that outlives a request? None. One trigger and a replaced function.
  • What happens on the second replica? Identical. The guard is in the database, so every replica gets it from the same schema.
  • Anything serialised? Yes, and this change is that answer. Refusal is a database trigger rather than a check-then-write in the application, so it holds against any connection, including psql.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • Every acting call still goes through the gateway: unchanged.
  • New refusals and new failures each write a row: not applicable. This refuses at the database, and a refused truncate raises rather than proceeding.
  • Nothing new is trusted from the client: unchanged.

Changelog

  • Added under Unreleased -> Fixed, including the limitation that the shipped compose file connects as the database owner, so an owner can still drop the trigger.

Proof

Full migration chain applied on pgvector/pgvector:pg17, the image CI uses.

case before after
TRUNCATE, setting unset wiped refused
TRUNCATE, setting '30' wiped refused
TRUNCATE, '99999' / 'garbage' / CASCADE wiped refused
UPDATE refused refused
DELETE recent row, '30' refused refused
DELETE old row, '30' allowed allowed
INSERT allowed allowed

Retention still works, which is what a careless guard would break.

Both new tests fail without the fix. Dropping the new trigger reddens them, and so does keeping the trigger while removing the TG_OP branch, which is the naive fix in full.

audit.test.ts's mirror assertion is repaired rather than extended. It read 0000_schema.sql and asserted three substrings, but 0007 replaced the function and this migration replaces it again, so it described a definition no database runs. It also passed with the trigger dropped outright, which I checked by dropping it. It now reads the whole chain and asserts the guard is installed and not later dropped.

This adds two tests and repairs one. The rest of the suite behaves the same on this branch as on clean main: I ran both against the same database, and the only failures are identical on either side, the agent-bot startup tests and a missing generated module, neither touched by this change.

`audit_events_append_only` is BEFORE UPDATE OR DELETE ... FOR EACH ROW, and a
row-level trigger cannot fire on TRUNCATE. So `TRUNCATE audit_events` removes
every row and raises nothing. Confirmed against Postgres before writing the fix:
five rows in, TRUNCATE, zero rows out, while UPDATE and DELETE were refused in
the same session.

That is the case the schema comment has in mind. It says the guarantee is
enforced in the database "because the application is not the only thing that can
reach this table", and TRUNCATE is the other thing reaching it.

The obvious fix is worse than the gap, so this does not use it. Pointing a
BEFORE TRUNCATE trigger at the existing function looks right and fails open: a
statement trigger has no OLD record, `OLD.created_at >= now() - ...` is NULL, the
IF does not fire, and control reaches RETURN OLD. Measured both ways -- with the
retention setting unset it refused, with it set to '30' it truncated the table.
It refuses only in the state a happy-path test would check, and permits the
truncate exactly while a retention sweep is in flight. So the TRUNCATE branch is
answered before the setting is read.

Retention still works: a row older than the window is still deleted, a recent
one is still refused, and the sweep's own setting is untouched.

Two tests alongside it, both proven to fail without the fix:

`the trail cannot be truncated away` drives TRUNCATE with the setting unset and
again with it set to '30', which is the case that separates this fix from the
naive one. Dropping the new trigger reddens it; so does keeping the trigger and
removing the TG_OP branch, which is the naive fix in full.

`the database refuses a malformed window even when the caller does not` covers
the trigger's own zero/negative/empty/non-numeric branch. The existing test named
for this loops [0, -1, NaN] through sweepAuditTrail, which returns before it
opens a connection, so it proves the TypeScript guard and never reaches the
plpgsql one. That test is left as it is, since the caller's guard is worth
holding; this one sets the value the sweep would set and deletes directly.

audit.test.ts's mirror assertion is repaired rather than extended. It read
0000_schema.sql and asserted three substrings, but 0007 replaced the function and
this migration replaces it again, so it described a definition no database runs.
It also passed with the trigger dropped outright, which I checked by dropping it:
the old assertion returned 1 pass, 0 fail. It now reads the whole chain and
asserts the guard is installed and not later dropped.
A deployment behaves differently afterwards: a statement that used to empty the
audit trail now raises. The entry says so in the operator's terms, and states the
limit rather than overselling the fix, since the shipped compose file connects as
the database owner and an owner can still drop the trigger.
@davidmckayv

Copy link
Copy Markdown
Contributor

Thank you for this, and an apology for how it played out: I built the same fix in #148 without checking the open PRs first, and it merged. That was my miss, not a judgement on this.

The fix that landed is the same one you wrote — the statement-level BEFORE TRUNCATE trigger sharing the function, with TG_OP answered before the setting is read. Your issue laid out both the hole and the failure mode of the obvious version, which is the part that mattered: pointing a statement trigger at a function that reaches OLD.created_at fails open exactly while a retention sweep has the window set, and I would have shipped that if you had not measured it and written it down.

This PR also covered ground #148 did not, and I have taken both pieces in #154 with credit to you:

  • audit.test.ts described a definition nothing runs. Reading 0000_schema.sql stopped being true at 0007 and again at 0012, so it would have gone on passing if the live guard were edited out from under it. It now reads the whole chain and requires both triggers.
  • The trigger's malformed-window branch was never reached. sweepAuditTrail returns before it opens a connection, so the existing test proves the caller's guard and never touches the trigger — the branch anything not going through the sweep would actually meet.

Your changelog entry is in too, including the caveat about the application connecting as the database owner, which was the right thing to state plainly rather than let the entry overclaim.

Closing this as superseded. Please do keep them coming.

davidmckayv added a commit that referenced this pull request Aug 22, 2026
* Test the append-only guard the database actually runs

The immutability test read `0000_schema.sql` and asserted on the function
defined there. 0007 replaced that function and 0012 replaced it again and
added the truncate trigger, so the assertions described a definition no
database runs, and would have gone on passing if the live one were edited
out from under them. A mirror test pinned to one file is a test of that
file. It now reads the whole migration chain, requires both triggers, and
fails if a later migration drops either.

Also covers the trigger's own branch for a malformed retention window.
`sweepAuditTrail` returns before it opens a connection, so the existing
test proves the caller's guard and never reaches the trigger, which is
the branch anything not going through the sweep would meet.

Both reported by @beardthelion in #138 and written in their PR #139,
which arrived before the fix that merged and covered ground it did not.

Catches the changelog up with tonight's fixes, including the truncate
hole with the caveat about the owner role that the report was careful to
state.

* Say who routed a conversation in the third person

The mention row's reason read "you chose them yourself". True in the
conversation and false on the audit page, where every row belongs to
somebody else and an administrator is the one reading it. The person is
already on the row as actorUserId; the reason only has to say what kind
of decision it was.

@zopeVaibhav had this right in #134.
@beardthelion

Copy link
Copy Markdown
Contributor Author

Superseded by #148, which ships the same guard: the operation answered before the retention setting is read, so the missing OLD record on a statement-level trigger cannot fall through. Verified against a database built from the current migration chain, with rows present: UPDATE and an unscoped DELETE refused, TRUNCATE refused both with the retention window unset and with it set, and a sweep inside its window still removing only the rows older than it.

Closing in favour of it. One thing #148 does not carry, which I will send separately: audit.test.ts asserts the guard by reading 0000_schema.sql alone, and the function has been replaced twice since, so that test now describes a definition no database runs and says nothing about the truncate trigger.

@beardthelion

Copy link
Copy Markdown
Contributor Author

Ignore the follow-up I promised above, our comments crossed. #154 had already landed both pieces, so there is nothing separate coming from me.

No apology needed on the duplicate. Appreciated all the same.

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

2 participants