DELETE /admin/uploads/{documentId} was gated by a standalone
`X-Admin: true` header check (`IsAdmin`), outside the `Authz` module
entirely and outside the `CasesAdmin`/`StamdataAdmin`/`OrgAdmin`/
`FlagsAdmin` wrappers the four sibling admin surfaces use. It wrote no
AuthzAuditStore row, so a destructive cross-owner document delete never
appeared on /beheer/audit. A repo-wide grep confirmed the only sender of
X-Admin was the backend test itself — no frontend or e2e path depends on
it — so the gate was safe to delete outright.
Routed the endpoint through CasesAdmin (Authz.CanManageCases), the same
wrapper the other admin-cases endpoints use. RB-07 already moved
AuditAuthz onto every *Admin wrapper's allow path, so this gets the
missing audit row for free with no second AuditAuthz call. Deleted the
now-unused IsAdmin function and updated the two comments that referenced
the old X-Admin seam.
Updated EndpointTests.cs's Admin_delete_requires_admin_role to send
X-Role: admin instead of X-Admin: true, and added
AuthzAuditTests.An_admin_upload_delete_is_recorded, which asserts the
cases:manage/allow row count increases by exactly one (a plain
Contains would already be satisfied by this test class's other
cases:manage calls). Verified both tests fail red against the
pre-fix gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.0 KiB
RB-08 — route DELETE /admin/uploads/{documentId} through CasesAdmin; delete the orphaned IsAdmin gate
Status: implemented · 2026-08-27 · Source findings: 07-bio2-compliance.md BIO-003 · 99-backlog.md RB-08
What was wrong
Program.cs:790 (pre-change) had static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
and DELETE /admin/uploads/{documentId} (:274) was gated by IsAdmin alone — outside
Authz, outside every wrapper the four sibling admin surfaces use, and writing no
AuthzAuditStore row at all. DocumentStore.AdminDelete bypasses ownership and deletes the
row and its bytes; the only record left behind was a DocumentStore.Audit("delete-admin", …)
metadata row, which never surfaces on /beheer/audit.
grep -rn "X-Admin" over apps, libs, backend, e2e (re-verified before deleting the
gate, as the ticket asked) confirmed the finding: the only sender was
backend/tests/BigRegister.Tests/EndpointTests.cs:231. No frontend or e2e path uses this
header — it was an orphaned gate, not a live seam.
What changed
| File | Change |
|---|---|
Program.cs DELETE /admin/uploads/{id} |
now CasesAdmin(ctx, () => DocumentStore.AdminDelete(...) ? NoContent : NotFound) — same wrapper the other four admin-cases endpoints use; response doc changed Produces(403) → ProducesProblem(403) to match CasesAdmin's Results.Problem |
Program.cs IsAdmin |
deleted |
Program.cs two stale comments |
the endpoint's own comment rewritten to describe the new gate; the brief-section banner comment ("mirrors the X-Admin seam") no longer references a gate that doesn't exist |
tests/BigRegister.Tests/EndpointTests.cs |
Admin_delete_requires_admin_role sends X-Role: admin instead of X-Admin: true |
tests/BigRegister.Tests/AuthzAuditTests.cs |
new An_admin_upload_delete_is_recorded — asserts the cases:manage/allow/Admin row count increases by exactly one after the delete |
No new Authz capability was added — CasesAdmin/Authz.CanManageCases is the wrapper the
ticket named as the expected outcome, and nothing about this endpoint needed a narrower
capability than "manage cases" already provides.
RB-07 already moved AuditAuthz onto the allow path for every *Admin wrapper, so
routing through CasesAdmin gives BIO-003's missing audit row for free. No second
AuditAuthz call was added — confirmed by reading CasesAdmin's body (Program.cs): it
calls AuditAuthz(ctx, "cases:manage", "cases", ok, principal) unconditionally before
branching on ok.
Judgement calls
- Test asserts a count delta, not mere presence.
CasesAdminaudits under a fixed"cases"resource literal shared by everycases:managecall (GET /admin/cases,DELETE /admin/cases/{id},GET /admin/audititself, and now this endpoint), soAssert.Contains(rows, cases:manage/allow/Admin)would already be satisfied by this test class's other tests even without the fix. The new test counts matching rows before and after the delete and asserts the count grew by exactly one. It readsAuthzAuditStore.List()in-process rather than throughGET /admin/audit— that endpoint is itself aCasesAdminread, so calling it to take the "before" measurement would have written its owncases:manage/allowrow and silently inflated the count by one every time it was called (caught this by running the test once against the fix with an HTTP-based baseline: it failed with an off-by-one before switching to the in-process read). - Two comments referencing the old gate were also updated, not just the endpoint mapping itself — one directly above the endpoint, one in the brief-section banner comment ("dev-only stand-in via X-Role, mirrors the X-Admin seam") that would otherwise describe a gate that no longer exists.
Known residual
None new. RB-01's implementation note already records that GET /uploads/{id}/content is
reached with no identity header via plain browser navigation — that residual is RB-09's
territory, not this ticket's, and is untouched here.
Verification
- Reverted
Program.cs's endpoint change only (git stash pushon that one file, tests left in place) and re-randotnet test --filter "AuthzAuditTests|EndpointTests": bothEndpointTests.Admin_delete_requires_admin_roleandAuthzAuditTests.An_admin_upload_delete_is_recordedfailed red (403 Forbidden — the old gate rejectsX-Role: admin, and the count-delta test throws onEnsureSuccessStatusCodebefore it can assert). Restored the fix (git stash pop) and re-ran: both green. dotnet build: clean.dotnet test(full suite): 253 passed, 1 failed — the pre-existingOpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT, which needs a live OpenZaak container and fails identically on a clean tree; not touched by this ticket.