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>
114 lines
3.6 KiB
PHP
114 lines
3.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Policies\FormBuilder;
|
|
|
|
use App\Models\FormBuilder\FormSubmissionActionFailure;
|
|
use App\Models\Organisation;
|
|
use App\Models\User;
|
|
|
|
/**
|
|
* RFC-WS-6 §4 (V3) — IDOR-class FK-chain enforcement.
|
|
*
|
|
* The `form_submission_action_failures` table has no `organisation_id`
|
|
* column by design. Tenant scope flows via
|
|
* failure.submission.organisation_id. Cross-tenant access returns
|
|
* `false` here; controllers (sessions 2/3) translate to 404 to prevent
|
|
* resource-existence enumeration.
|
|
*/
|
|
final class FormSubmissionActionFailurePolicy
|
|
{
|
|
public function viewAny(User $user): bool
|
|
{
|
|
if ($user->hasRole('super_admin')) {
|
|
return true;
|
|
}
|
|
|
|
// Org admin in any organisation. Controllers in sessions 2/3
|
|
// restrict the result set per role.
|
|
return $user->organisations()
|
|
->wherePivot('role', 'org_admin')
|
|
->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);
|
|
}
|
|
|
|
public function retry(User $user, FormSubmissionActionFailure $failure): bool
|
|
{
|
|
return $this->canAccess($user, $failure);
|
|
}
|
|
|
|
public function resolve(User $user, FormSubmissionActionFailure $failure): bool
|
|
{
|
|
return $this->canAccess($user, $failure);
|
|
}
|
|
|
|
public function dismiss(User $user, FormSubmissionActionFailure $failure): bool
|
|
{
|
|
return $this->canAccess($user, $failure);
|
|
}
|
|
|
|
private function canAccess(User $user, FormSubmissionActionFailure $failure): bool
|
|
{
|
|
// Load the submission without global scopes so cross-tenant
|
|
// resolution works for super_admin and so the policy itself
|
|
// does the tenant gating (RFC V3 — single source of truth for
|
|
// tenant resolution, not OrganisationScope).
|
|
$submission = \App\Models\FormBuilder\FormSubmission::query()
|
|
->withoutGlobalScopes()
|
|
->find($failure->form_submission_id);
|
|
if ($submission === null) {
|
|
return false; // parent submission deleted
|
|
}
|
|
if ($submission->deleted_at !== null) {
|
|
return false; // soft-deleted parent — treat as gone
|
|
}
|
|
|
|
$orgId = (string) $submission->organisation_id;
|
|
if ($orgId === '') {
|
|
return false;
|
|
}
|
|
|
|
if ($user->hasRole('super_admin')) {
|
|
return true;
|
|
}
|
|
|
|
// Tenant scope: user must be an org_admin in the failure's
|
|
// organisation. RFC V3 — IDOR-class FK-chain enforcement.
|
|
$organisation = \App\Models\Organisation::query()
|
|
->withoutGlobalScopes()
|
|
->find($orgId);
|
|
if (! $organisation instanceof Organisation) {
|
|
return false;
|
|
}
|
|
|
|
return $organisation->users()
|
|
->where('user_id', $user->id)
|
|
->wherePivot('role', 'org_admin')
|
|
->exists();
|
|
}
|
|
}
|