docs: ARCH-BINDINGS.md § 8.2 IDOR class tests (WS-6)
Documents the IDOR-class threat model and the 404-vs-403
enforcement strategy implemented in WS-6 sessions 1-3a.
Two-axis policy enforcement:
- Role-class (super_admin platform endpoints): 403 for unauthorised
roles — endpoint exists; "you're not allowed in this room"
- Ownership-class (org-scoped endpoints): 404 for cross-tenant
access — resource indistinguishable from absence; "this room
doesn't exist for you"
Includes:
- Threat model: enumeration via ID sweeping
- Policy implementation (canAccess + viewAnyInOrganisation,
sessie 3a addition that closed the orgIndex gap)
- Test coverage map: 24 tests in
FormSubmissionActionFailureRouteSecurityTest
- Edge case enumeration: soft-deleted parent, invalid ULID,
non-existent ID, authenticated-without-role, unauthenticated
- Forward pointer to sessie 3b for the frontend authorisation model
Refs: RFC-WS-6.md §4 V3, sessie 3a Tasks 1-2 commits
6b22c8d (security tests) and 842cb01 (per-purpose pipeline)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user