Files
crewli/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php
bert.hausmans 6b22c8d763 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>
2026-04-28 16:13:40 +02:00

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();
}
}