An audit trail writes before it knows the answer

A trail recording only what happened cannot audit. Catching the refused attempt moves the writer in front of the permission gate, and changes it.

Three stacked glass ledger plates lead toward a gate, with the first plate glowing orange

A trail that records only what happened cannot audit anything, because the line worth reading is the attempt somebody was refused. Catching that attempt means writing it before the handler decides, which puts the log’s writer in front of the permission gate and makes the logger itself a write surface.

What the canon says to log, and what it leaves out

OWASP’s logging guidance puts authorization failures in its always-log list, and every compliance checklist repeats it. What the guidance gives you is a vocabulary: which events matter, which fields a record carries, which values never appear in one.

Where the recorder sits in the request is left to the implementer, and the same page’s practical advice points at the arrangement that cannot work: build an application-wide log handler and call it from the other modules. A handler called by the code that decided can only record decisions that code reached. The refusal produced by the authorization layer one frame above is invisible from down there, and that refusal is what an auditor came for.

So the recorder moves ahead of the handler and writes the attempt before it runs, on every transport, without asking its opinion. That is where the security properties invert, and no logging checklist mentions it, because a checklist about content has nothing to say about position.

A logger in front of the gate is a write surface

Once the wrapper runs first, whatever it accepts is durable before any role check the handler performs. It has stopped being an observer of the request and become a write endpoint, reachable by every caller who can reach the route, carrying an authorization question of its own.

Skipping that question has a specific shape. The wrapper works out which tenant an attempt belongs to, and one branch of that work covers actions aimed at the installation rather than at a tenant. It returned a scope unconditionally, on the reasoning that nothing had been authorized yet. Any member holding read-only access to any project could then call the plan endpoint and have two rows filed under their own name: the attempt, and its failure. The handler refused the write itself, correctly, every time. The rows stayed, in the one log an instance administrator reads to review the most dangerous write on the system. That branch is closed: it now authorizes at read level before it returns a scope, and refuses on privilege alone.

That is worse than a log with a hole in it, because a hole is visible. Rows a stranger chose to write look exactly like rows the system chose to write, and they sit in the record an investigator trusts precisely because nothing outside the system was supposed to put anything there.

Refuse on privilege, never on the outcome

Authorizing the mutation before recording the attempt is the obvious repair, and it puts the trail back to recording only what happened. The rule that works is narrower: the writer refuses on privilege alone, at a rank below the one the mutation demands, never on whether the mutation will succeed.

The attempt namesWhat the writer demands before it writesWhat it refuses to ask
a projectread access on itwhether the caller may make the change
an account, no project in betweenread access on it, and a gate wiring no account check is refused rather than waved throughwhether the account’s state permits it
the installationinstance administrator, the only rank that scope haswhether the patch is valid
nothing that resolves to a tenantno scope, so no rowanything

An administrator whose write then fails, on a bad patch or an unknown plan, still produces the attempted row and the failed one, because that pair is the line the log exists to carry. A writer anticipating the handler’s answer would delete exactly the records worth having, and would be a second copy of the permission model sitting where it can drift from the first.

The gap between the two ranks is the design. Low enough that a member the handler refuses still leaves a record of having tried. High enough that somebody with no relationship to the tenant writes nothing.

The trail ends up choosing the arguments of a public API

Building the event from the arguments before the handler runs reaches all the way out to callers: the argument list has to carry everything the record needs, whether or not the operation needs it.

One tool on the agent-facing API pays that bill in the open. It sets whether one ticket is visible in the client portal, and asks for the project’s full identifier and the ticket’s long id, where every other ticket tool takes the short reference a person reads off the board. Without the project identifier the attempt resolves to no tenant and reaches no log at all, so the refusal an auditor wants would never be written. A short reference would file the change against a different target than the panel’s own route files for it, so one ticket would appear in its own history twice, under two names, with nothing tying them together.

Neither reason has anything to do with what the operation does. That is the part worth carrying elsewhere: put the recorder ahead of the gate and the audit requirement stops being a cross-cutting concern, showing up instead in signatures, permanently, as arguments the handler never reads.

An action with no tenant needs a destination no tenant can hold

A trail partitioned by tenant still has to file the actions belonging to no tenant: editing a plan, reconciling billing, everything an operator does to the installation itself.

Filing such an action under the acting administrator’s own tenants is the tempting answer, and it fails in both directions at once. An operator belonging to none writes zero rows, the silent failure and the worse one. An operator belonging to three writes three, each attributed to a tenant that had nothing to do with the change, and each readable by that tenant’s admins, because a tenant’s read path resolves its scope from a project the caller can already see.

The action gets filed instead under a reserved identifier no tenant can occupy. The property that matters is not that the value looks empty. It is that the generator minting real identifiers cannot produce it and no row claims it, so one row is written every time and no tenant’s read path can resolve to it, while the operator path, which already demands the instance rank, can. A value the allocator can still hand out eventually gives a future tenant somebody else’s history.

What a row carries, and the promise the trail will not make

The record is deliberately thin, in a way that survives the row outliving the accounts it names.

On every rowWhat it holds
Operationone id shared by both rows of an operation, so an attempt and its outcome read as one event
Actionone of 60 names in a closed vocabulary, validated on the way in
Target typeone of 18, likewise closed
Metadataonly the keys allowlisted for that action, with 34 of the 60 allowing none; the encoded object is dropped whole past 2 KiB rather than truncated
Actorthe id and display name as they were at the time, snapshotted rather than joined, so a rename does not rewrite history
Addressa salted one-way hash cut to 32 hexadecimal characters, or nothing when no address was captured

The allowlist runs per action rather than as a global denylist of dangerous words, which is the choice worth stealing. A denylist has to anticipate the next field somebody adds; an allowlist admits nothing it was not told about, so a new key on a request payload reaches the log and is dropped until somebody decides it belongs. The security page states the resulting contract, and a ticket export applies the same reasoning on the way out.

One asymmetry survives all of this, and somebody reconciling the log has to know it. The two writes of an operation do not share a failure policy. The attempt is fail-closed: a row that cannot be stored refuses the mutation outright, and the caller is told why rather than served. The completion is best-effort, because once the business change has happened an audit storage failure must never invite a destructive retry, so it is reported and never swallowed while nothing is rolled back to keep the pair intact.

The trail can therefore hold an attempt with no terminal row, and anybody reconciling an export has to read a lone attempt as an outcome nobody recorded. Not as a failure, and not as a change that never landed, because it may well have landed. No retry fixes that without being worse, since the alternative undoes real work to tidy up bookkeeping.

Common questions

Should an audit log record attempts that were refused?

A trail carrying only completed changes cannot answer the question an audit asks, which is who tried. Recording the refused attempt means writing before the handler decides, so the record exists whatever that decision turns out to be.

Why does writing the audit record first create a security problem?

Anything writing before the permission check is reachable by every caller who can reach the route. Unless that writer authorizes on its own account, a read-only member can cause rows to be filed under their name in the log an administrator trusts.

What should an audit writer check before it records an attempt?

Privilege alone, at a rank below the one the mutation itself demands. Checking whether the mutation will succeed would delete the refusal records the trail exists to hold, and would put a second copy of the permission model somewhere it can drift.

Where should an audit trail file an action that belongs to no tenant?

Under a reserved identifier that the allocator of real tenant identifiers cannot produce. Filing it under the acting administrator's own tenants writes nothing when they belong to none and duplicates when they belong to several, each copy readable by an uninvolved tenant.

Can an audit trail be missing the outcome of an operation it recorded?

Yes, when the write that records the outcome fails after the change has already been made. That failure is reported rather than retried, so a lone attempt means the outcome was never recorded, which is different from the change not happening.

Does recording an attempt before the handler runs change how an API is designed?

Building the event from the arguments forces every argument the record needs into the call signature, including ones the handler ignores. That is why one tool here asks for a project identifier its own work does not need, and a long id where every other ticket tool takes a short reference.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">