Redacting the payload and not the diff means you redacted nothing
The audit trail in this system stores two things for a change: the request body that caused it, and a field-level diff of what actually changed. Sensitive fields in the request body were masked before storage. That had been reviewed, tested and signed off.
The diff was not masked. So for any change to a sensitive field, the trail held the old value and the new value in clear text, in a table designed to be readable by anyone with the audit permission, sitting beside a request body that had been carefully scrubbed of exactly those values.
The redaction was real. It was applied to one of the two places the data was written.
When the same data has two representations, redaction applied to one of them is not redaction. That sounds obvious written down. It was not obvious in review, because the review was of the redaction function, and the redaction function was correct.
The fix was to redact on write for the diff as well, and also on read — both ends, so that rows written before the fix do not leak on their way out. Redacting only on read leaves the data in the table for anyone with database access. Redacting only on write leaves the historical rows exposed forever. Neither alone is sufficient and each is individually easy to mistake for the whole job.
The second failure was in the viewer
Fixing the storage created a display problem that was, in its own way, worse than the leak.
A diff for a redacted field now came back with the masking sentinel in both the old position and the new position. The viewer compared them, found them equal, and rendered the field as unchanged. So the audit detail panel for a password change, or for a change to any other masked field, said that nothing about that field had changed.
That is the most damaging thing an audit trail can say. A missing entry is a gap and gaps get noticed. An entry that positively asserts no change occurred, on the exact event a security review is looking at, is a false statement presented with the authority of a system of record.
The corrected display distinguishes three states rather than two:
- A field where both sides are withheld reads as changed, value withheld. It happened. You may not see what it was.
- A field where one side is withheld and the other is not gets a redaction marker on the side that is withheld, so that a partial view is visibly partial.
- The panel states how many fields on the event were redacted, so the reader knows the view they are looking at is incomplete without having to count.
The general rule, and it applies well beyond audit trails: withheld is a distinct state from absent and from unchanged, and a viewer that has only two states will map it onto the wrong one. Empty is not zero. Redacted is not equal. Unknown is not false. Every one of those collapses happens at the rendering layer, after all the careful work upstream is finished.
The request body had the same problem in a milder form. It was displayed as a formatted dump of the whole structure, which is fine until half of it is masked and the reader is left interpreting a wall of sentinels. It became a per-field list with the same three-state treatment, which is more code and is the only version that can be read at speed.
An audit entry has to name the actor
Alongside the redaction work, the viewer was missing most of the context that makes an entry actionable.
An entry that says a record changed, with a timestamp, is nearly useless during an investigation. What is needed is who, from where, and under what authority: the acting user, the plane they were acting on — staff, platform administrator, or external — the address and client they came from, and whether they were impersonating somebody else. All of that was being recorded and none of it was being displayed.
Displaying the actor’s name rather than an identifier turned out to need an endpoint that did not exist. There was no way to fetch a single member’s details, so the viewer either shows an identifier or downloads a page of the directory to resolve names. That was logged as required work rather than solved by downloading a hundred records to render one label — which is the shortcut that was already present in two other screens and was removed in the same period.
What the trail cannot see on its own
The automatic part of the trail is a hook on the persistence layer: objects being written produce audit rows. That covers ordinary creates, updates and deletes without anybody remembering.
It does not cover two categories, and both are the interesting ones.
Actions that change no rows. Exporting a report is the clearest case. Nothing is written, so the hook fires for nothing, and yet a user taking a copy of the customer list out of the system is precisely the event a review wants. Those are recorded by an explicit call in the service that performs the action.
Changes made through the wrong layer. Rewriting a role’s permission set is naturally expressed as a bulk delete followed by a bulk insert issued straight to the database. Done that way, the persistence hook never sees the objects and the change is invisible. It was deliberately implemented through the object layer instead — slower, and audited — with an explicit sensitive-event record alongside it naming the role and the change.
That is a genuine trade and it should be made consciously. A bulk statement is faster and silent. An object-by-object write is slower and observed. For a permission change, observed wins, and the reason is written next to the code so the next person optimising queries does not quietly remove the audit trail while improving a benchmark.
Rules
Find every representation of a sensitive value before you claim it is redacted. A payload and a diff are two copies. So are a log line and a database column.
Redact on write and on read. On write for the future, on read for the history you already have.
Give withheld its own rendering. A viewer with two states will show it as unchanged, and unchanged is a lie on a security event.
Show the actor, the plane, the address and any impersonation. A timestamp and an entity name do not support an investigation.
Record sensitive actions explicitly, because the automatic hook only sees row changes. Exports, permission changes and rejected attempts all change nothing and all matter.
The limit I would state honestly: everything above makes the trail truthful to a reader who has the audit permission. It does nothing about someone with direct database access, and it was never intended to. The threat it addresses is an ordinary user of the product, and the failure it prevents is a system of record that quietly says the wrong thing.