test(form-builder): IDOR-class route-level security for form-failures admin (WS-6)

RFC §4 V3 compliance — cross-tenant access to FormSubmissionActionFailure
endpoints returns 404, not 403, to prevent resource-existence
enumeration. The FormSubmissionActionFailurePolicy is the single tenant
gate; these tests assert the route-level integration end-to-end.

Production-code finding (in scope per "security gaps zijn altijd urgent"):
the orgIndex endpoint had a real IDOR gap. Original implementation called
`Gate::authorize('viewAny', ...)` which permits any org_admin in any org,
then filtered the result set by the URL's `{organisation}` param. orgB's
admin hitting `/organisations/{orgA}/form-failures` would get back orgA's
failures — leakage.

Fix:
- New policy method `viewAnyInOrganisation(User, Organisation)` that
  requires super_admin OR org_admin on THIS specific organisation.
- Controller `orgIndex` calls `authorizeViewAnyInOrgOrNotFound()` which
  translates a denied policy → 404 (matches the show/retry/resolve/dismiss
  pattern).
- viewAny on the class level stays as the platformIndex gate (super_admin
  + any-org_admin enumeration is acceptable on the platform endpoint
  because the role middleware already restricts to super_admin).

Test coverage (24 tests, all passing):
- 5 org-scoped endpoints × cross-tenant scenarios (all return 404)
- 5 platform endpoints × role-class scenarios (org_admin gets 403, never 404)
- Edge cases: soft-deleted parent submission, invalid ULID format,
  non-existent ID, unauthenticated, authenticated-without-role on org

The 403 vs 404 distinction matters: role-gated endpoints return 403
(auth-class — "not allowed in this room"); ownership-gated endpoints
return 404 (IDOR-class — "this room doesn't exist for you").

Refs: RFC-WS-6.md §4 V3, ARCH-BINDINGS.md §8.2 (Task 3 of this session)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 16:13:40 +02:00
parent 212e8754d3
commit 21c042d93f
3 changed files with 420 additions and 1 deletions

View File

@@ -31,7 +31,13 @@ final class FormSubmissionActionFailureController extends Controller
{
public function orgIndex(Organisation $organisation): AnonymousResourceCollection
{
Gate::authorize('viewAny', FormSubmissionActionFailure::class);
// RFC V3 IDOR-class — the user must be super_admin OR an
// org_admin on THIS specific organisation. Viewing any org's
// index without role on it would let orgB's admin enumerate
// orgA's failure rows. Treat denied → 404 to keep the "this
// doesn't exist for you" contract; same pattern as show/retry/
// resolve/dismiss.
$this->authorizeViewAnyInOrgOrNotFound($organisation);
$failures = FormSubmissionActionFailure::query()
->whereHas('submission', function ($q) use ($organisation): void {
@@ -193,4 +199,15 @@ final class FormSubmissionActionFailureController extends Controller
throw new ModelNotFoundException;
}
}
/**
* Tenant gate for orgIndex: caller must be super_admin OR an
* org_admin on THIS specific organisation. Denied 404 (RFC V3).
*/
private function authorizeViewAnyInOrgOrNotFound(Organisation $organisation): void
{
if (Gate::denies('viewAnyInOrganisation', [FormSubmissionActionFailure::class, $organisation])) {
throw new ModelNotFoundException;
}
}
}