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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user