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 names | What the writer demands before it writes | What it refuses to ask |
|---|---|---|
| a project | read access on it | whether the caller may make the change |
| an account, no project in between | read access on it, and a gate wiring no account check is refused rather than waved through | whether the account’s state permits it |
| the installation | instance administrator, the only rank that scope has | whether the patch is valid |
| nothing that resolves to a tenant | no scope, so no row | anything |
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 row | What it holds |
|---|---|
| Operation | one id shared by both rows of an operation, so an attempt and its outcome read as one event |
| Action | one of 60 names in a closed vocabulary, validated on the way in |
| Target type | one of 18, likewise closed |
| Metadata | only 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 |
| Actor | the id and display name as they were at the time, snapshotted rather than joined, so a rename does not rewrite history |
| Address | a 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.