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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,25 @@ final class FormSubmissionActionFailurePolicy
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC V3 — tenant gate for the org-scoped index endpoint.
|
||||
* The caller must be super_admin OR an org_admin on THIS specific
|
||||
* organisation; without this check the broader `viewAny` would let
|
||||
* orgB's admin enumerate orgA's failure rows via orgA's URL.
|
||||
* Denied → controller translates to 404 to keep the IDOR contract.
|
||||
*/
|
||||
public function viewAnyInOrganisation(User $user, Organisation $organisation): bool
|
||||
{
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'org_admin')
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function view(User $user, FormSubmissionActionFailure $failure): bool
|
||||
{
|
||||
return $this->canAccess($user, $failure);
|
||||
|
||||
Reference in New Issue
Block a user