diff --git a/dev-docs/ARCH-BINDINGS.md b/dev-docs/ARCH-BINDINGS.md index a573ef32..52bb2fca 100644 --- a/dev-docs/ARCH-BINDINGS.md +++ b/dev-docs/ARCH-BINDINGS.md @@ -3,6 +3,7 @@ ## Status - v0.1 (skeleton) — 2026-04-25 +- v0.4 — 2026-04-28 — § 8.2 IDOR class tests (sessie 3a backend hardening) - Owner: Bert - Authoritative for the binding pipeline architecture, complementing ARCH-FORM-BUILDER.md §17 and §31. @@ -431,7 +432,123 @@ because Laravel's auto-discovery doesn't reliably resolve ### 8.2 IDOR class tests -> Populated in session 3 — see RFC-WS-6.md §4 V3. +#### Threat model + +An org_admin from organisation A attempts to access organisation B's +failure resources via crafted URLs: + +``` +GET /api/v1/organisations/{orgB}/form-failures/{failure-from-orgA-id} +POST /api/v1/organisations/{orgA}/form-failures/{failure-from-orgA-id}/dismiss +``` + +Even if the policy correctly denies the action, the response status code +itself is information leakage: + +- **403 Forbidden** confirms the resource exists; only the caller's + authorisation is missing. An attacker can enumerate which IDs exist + on other tenants by sweeping the namespace and recording 403 vs 404. +- **404 Not Found** makes existence indistinguishable from absence — + the attacker can't distinguish a real-but-forbidden resource from a + random non-existent ID. + +RFC §4 V3 mandates 404 for this endpoint family. Confirm-by-existence +(403) is replaced with deny-by-invisibility (404). + +#### Two-axis policy enforcement + +Two distinct denial axes, each with its own correct status code: + +- **Role-class** — the `super_admin` platform endpoints + (`/api/v1/admin/form-failures/...`) are gated by Laravel's + `role:super_admin` middleware. An authenticated org_admin who hits + these endpoints gets **403** because the role gate fails. The endpoint + exists; the user is just forbidden ("you're not allowed in this + room"). Enumeration via this axis is moot — the URL set is fixed and + documented; failing role check on a known endpoint reveals nothing. +- **Ownership-class** — the org-scoped endpoints + (`/api/v1/organisations/{org}/form-failures/...`) are gated by + `FormSubmissionActionFailurePolicy`'s FK-chain resolution. A denied + policy translates to **404** in the controller helpers + (`authorizeOrNotFound` / `authorizeViewAnyInOrgOrNotFound`). Cross- + tenant access becomes "this room doesn't exist for you" rather than + "this room exists but you can't enter." + +The distinction is the prompt: in role-class, the endpoint URL itself +is the universe under test; in ownership-class, individual resource IDs +are the universe — and that universe must remain unobservable to +unauthorised callers. + +#### Implementation + +`FormSubmissionActionFailurePolicy` is the single tenant gate. Two +abilities for the IDOR-class enforcement: + +- `view` / `retry` / `resolve` / `dismiss(User, FormSubmissionActionFailure)` + — calls `canAccess()` which loads the parent submission with + `withoutGlobalScopes()`, returns false on absent or soft-deleted + parent (sessie 2 deviation #7), and otherwise checks that the user + is `super_admin` OR an `org_admin` on the failure's organisation + (resolved via `submission.organisation_id`). +- `viewAnyInOrganisation(User, Organisation)` — sessie 3a addition. + The bare `viewAny(User)` permits any org_admin in any org, which + was a real IDOR gap on the `orgIndex` endpoint: orgB's admin hitting + `/organisations/{orgA}/form-failures` would receive orgA's failure + list because `viewAny` passed and the query's `whereHas` filtered + to orgA. `viewAnyInOrganisation` requires the user to have the + `org_admin` role on the URL's specific organisation; denied → 404. + +The controller's two-helper pattern keeps the 404-translation explicit: + +```php +private function authorizeOrNotFound(string $ability, FormSubmissionActionFailure $failure): void; +private function authorizeViewAnyInOrgOrNotFound(Organisation $organisation): void; +``` + +`->withoutScopedBindings()` on the org-scoped routes prevents Laravel's +implicit-binding scoped-relation lookup (Organisation has no +`formSubmissionActionFailures` relation; the policy is the gate). + +#### Test coverage + +`Tests\Feature\FormBuilder\Api\Security\FormSubmissionActionFailureRouteSecurityTest` +exercises the contract end-to-end (24 tests, all passing on the +schema-dump fast path): + +- 5 org-scoped endpoints (index/show/retry/resolve/dismiss) × cross- + tenant scenarios → 404 for every endpoint +- 5 platform endpoints × role-class scenarios → 401 unauthenticated, + 403 for org_admin without super_admin role, 200/204 for super_admin +- Edge cases: + - **Soft-deleted parent submission** — failure exists but its + `form_submission_id` points to a row with `deleted_at IS NOT NULL`. + Policy treats parent-gone as resource-gone → 404. + - **Invalid ULID format** in the URL → Laravel's route binding fails + cleanly, returns 404 (not 500). + - **Non-existent ID** → 404 regardless of role. + - **Authenticated but no role on org** → 404 (IDOR-class: a non-org + user enumerating IDs on a real org's URL must not be able to + distinguish real vs fabricated IDs). + - **Unauthenticated** → 401 on every endpoint. + +The 403-vs-404 distinction is documented in the test class docblock +and exercised explicitly by the platform-endpoint tests +(`test_platform_*_org_admin_returns_403`) — those tests would fail if +a future refactor accidentally translated role-class denials to 404 +"to be consistent," because that would actually weaken the role-gate's +clarity for legitimate UX (an org_admin should know they're forbidden, +not be misled into thinking the platform endpoint doesn't exist). + +#### Frontend implications + +Frontend admin UI in WS-6 sessie 3b applies the same authorisation +model client-side: org-scoped views are rendered only for authenticated +users with the appropriate role on that organisation, and platform +admin views only for `super_admin`. Backend remains the source of +truth — the frontend's role check is a UX optimisation (avoid showing +links the user can't follow), not a security boundary. Direct API +hits without going through the SPA must still hit the backend gates +documented above. ## 9. Listener chain