feat(audit): record the allow path, not just the denial (RB-07)

All five authorization gates audited only their deny branch, so /beheer/audit
could answer "who was turned away" but never "who changed this" — for a
register whose integrity is the product, the wrong half. Nothing recorded the
flag toggle, either org-template write, the admin case or upload delete, the
three brief transitions, or the besluit; the comment claiming endpoints log
their own effect held for two of the eight.

Each gate now computes the decision once, audits it, and then acts. The row
is written by the gate rather than the endpoint, so a new admin endpoint
cannot be added that forgets to audit itself. Same reasoning for the brief:
every transition already funnelled through LogBrief for its log line, so the
audit row goes there too — submit/approve/reject/send in one place, with the
transition's own outcome as the decision, so a 403 or 409 is as visible as a
success.

FlagsAdmin gained a per-call resource, the one deviation from BIO-007's
minimal remediation: the toggle endpoint writes no log line of its own, so a
constant "feature-flags" row would say a flag changed without saying which.
It now records feature-flags/<key>=<value>. OrgAdmin and CasesAdmin keep
coarse refs because those endpoints do log the specific object.

The besluit gets a second row: the gate records that a behandelaar was
allowed to act, aanvraag:besluit records what they decided.

Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads
now write rows. That is what auditing the allow path means; it is also what
would make retention on AuthzAuditStore necessary later.

Closes CQ-004's outstanding half and unblocks signing ADR-C-009.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 13:12:22 +02:00
co-authored by Claude Opus 5
parent d682f91b54
commit e89525eef6
5 changed files with 161 additions and 24 deletions
@@ -0,0 +1,63 @@
# RB-07 — audit the allow path, not just the denial
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-007 (+ the outstanding half of CQ-004) · `99-backlog.md` RB-07
## What was wrong
All five authorization gates called `AuditAuthz(..., allowed: false, ...)` only on the deny
branch; the allow branch called `action()` and returned. So `/beheer/audit` — the queryable
trail the product ships as its audit surface — could answer "who was turned away" but never
"who changed this".
Nothing recorded: `PUT /admin/flags/{key}`, `PUT /admin/org-template/{subOrgId}`,
`POST /admin/org-template/{subOrgId}/rollback/{version}`, `DELETE /admin/cases/{id}`,
`DELETE /admin/uploads/{documentId}`, `POST /brief/approve|reject|send`, and
`POST /beoordeling/{id}/besluit`. The comment above `OrgAdmin` claimed the endpoints logged
their own effect instead; publish and admin case delete do, the other six did not log at all.
## What changed
| File | Change |
| ------------------------- | ----------------------------------------------------------------------------- |
| `Program.cs` × 5 gates | `var ok = Authz.CanX(p); AuditAuthz(ctx, …, ok, p); if (ok) return action();` |
| `Program.cs` `FlagsAdmin` | takes a per-call `resource` (see below) |
| `Program.cs` `LogBrief` | takes `HttpContext`, writes the audit row alongside the log line |
| `Program.cs` besluit | one `aanvraag:besluit` row recording **what** was decided |
| `AuthzAuditTests.cs` | allow-path row; the flag key + value; a refused brief transition |
| `BriefEndpointTests.cs` | the allow side of `brief:submit` |
| `BeoordelingTests.cs` | the `aanvraag:besluit` row |
**The row is written by the gate, not the endpoint.** That is the point: a new admin
endpoint cannot be added that forgets to audit itself. Same reasoning for the brief — every
transition already funnelled through `LogBrief` for its log line, so the audit call went
there too, which covers `submit`/`approve`/`reject`/`send` in one place and any fifth
transition automatically. The decision recorded is the transition's own outcome, so a 403 or
a 409 is as visible as a success.
**`FlagsAdmin` gained a `resource` parameter** — the one deviation from BIO-007's minimal
remediation, and the reason is in the finding itself: the toggle endpoint writes no log line
of its own, so a constant `"feature-flags"` row would record that a flag changed without
recording _which_. It now writes `feature-flags/<key>=<value>`. One call site.
`OrgAdmin`/`CasesAdmin` keep their coarse refs because those endpoints do log the specific
object; **that asymmetry is deliberate, not an oversight.**
**The besluit gets a second row.** The `Beoordelen` gate records that a behandelaar was
_allowed to act_; `aanvraag:besluit` records _what they decided_
(`aanvraag/<id>/Goedkeuren`). Only the first would leave "who rejected this aanvraag"
unanswerable, which is the question the trail exists for.
## Consequences worth knowing
- **Row volume goes up.** `StamdataAdmin` gates read endpoints, so every admin page load now
writes rows. That is what "audit the allow path" means and BIO-007 asks for it explicitly;
if `AuthzAuditStore` ever needs retention or sampling, this is the change that made it
necessary.
- **This unblocks ADR-C-009.** Clause (4) of agent 06's four-part test is "writes are
admin-capability-gated **and** audited". Both surfaces now are, so the amendment can be
signed without ratifying a control the code does not implement.
- **CQ-004's outstanding half is closed.** `PUT /admin/flags/{key}` writes an audit row.
## Verification
`dotnet test`: **252 passed, 1 failed** — the pre-existing
`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.