From 78a8016e01ed2e85ba11b28e2fe5e7cba63ceff6 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 26 Apr 2026 00:03:21 +0200 Subject: [PATCH] feat(form-builder): add FormSubmissionActionFailurePolicy with FK-chain auth (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenant scope verified via failure.submission.organisation_id, NOT route binding. Cross-tenant access returns false (controllers in sessions 2/3 will translate to 404 to prevent enumeration). Five abilities: viewAny, view, retry, resolve, dismiss. Laravel 12 auto-discovers App\Policies\FormBuilder\FormSubmissionActionFailurePolicy for App\Models\FormBuilder\FormSubmissionActionFailure — no explicit registration needed (pattern matches the existing FormSubmissionPolicy). IDOR-class security tests included with explicit RFC V3 cross-reference in the test class docblock. Refs: RFC-WS-6.md §4 (V3), ARCH-FORM-BUILDER.md §22.9 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FormSubmissionActionFailurePolicy.php | 84 +++++++++++ .../FormSubmissionActionFailurePolicyTest.php | 142 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php create mode 100644 api/tests/Unit/Policies/FormBuilder/FormSubmissionActionFailurePolicyTest.php diff --git a/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php b/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php new file mode 100644 index 00000000..7d2bc0fd --- /dev/null +++ b/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php @@ -0,0 +1,84 @@ +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(); + } + + 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 + { + $failure->loadMissing('submission'); + $submission = $failure->submission; + if ($submission === null) { + return false; // parent submission deleted + } + + $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 = $submission->organisation; + if (! $organisation instanceof Organisation) { + return false; + } + + return $organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + } +} diff --git a/api/tests/Unit/Policies/FormBuilder/FormSubmissionActionFailurePolicyTest.php b/api/tests/Unit/Policies/FormBuilder/FormSubmissionActionFailurePolicyTest.php new file mode 100644 index 00000000..9397c81d --- /dev/null +++ b/api/tests/Unit/Policies/FormBuilder/FormSubmissionActionFailurePolicyTest.php @@ -0,0 +1,142 @@ +seed(RoleSeeder::class); + + $this->orgA = Organisation::factory()->create(); + $this->orgB = Organisation::factory()->create(); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->orgAdminA = User::factory()->create(); + $this->orgA->users()->attach($this->orgAdminA, ['role' => 'org_admin']); + + $this->orgAdminB = User::factory()->create(); + $this->orgB->users()->attach($this->orgAdminB, ['role' => 'org_admin']); + + $this->regular = User::factory()->create(); + } + + public function test_super_admin_can_perform_all_abilities(): void + { + $failure = $this->failureFor($this->orgA); + + $this->assertTrue($this->policy()->view($this->superAdmin, $failure)); + $this->assertTrue($this->policy()->retry($this->superAdmin, $failure)); + $this->assertTrue($this->policy()->resolve($this->superAdmin, $failure)); + $this->assertTrue($this->policy()->dismiss($this->superAdmin, $failure)); + } + + public function test_org_admin_in_correct_org_can_perform_all_abilities(): void + { + $failure = $this->failureFor($this->orgA); + + $this->assertTrue($this->policy()->view($this->orgAdminA, $failure)); + $this->assertTrue($this->policy()->retry($this->orgAdminA, $failure)); + $this->assertTrue($this->policy()->resolve($this->orgAdminA, $failure)); + $this->assertTrue($this->policy()->dismiss($this->orgAdminA, $failure)); + } + + /** + * IDOR — RFC V3. An org admin in B must NOT see a failure in A. + */ + public function test_org_admin_of_other_org_cannot_access_cross_tenant(): void + { + $failure = $this->failureFor($this->orgA); + + $this->assertFalse($this->policy()->view($this->orgAdminB, $failure)); + $this->assertFalse($this->policy()->retry($this->orgAdminB, $failure)); + $this->assertFalse($this->policy()->resolve($this->orgAdminB, $failure)); + $this->assertFalse($this->policy()->dismiss($this->orgAdminB, $failure)); + } + + public function test_regular_user_without_admin_role_denied_all(): void + { + $failure = $this->failureFor($this->orgA); + + $this->assertFalse($this->policy()->view($this->regular, $failure)); + $this->assertFalse($this->policy()->retry($this->regular, $failure)); + $this->assertFalse($this->policy()->resolve($this->regular, $failure)); + $this->assertFalse($this->policy()->dismiss($this->regular, $failure)); + } + + public function test_view_any_super_admin_yes_org_admin_yes_regular_no(): void + { + $this->assertTrue($this->policy()->viewAny($this->superAdmin)); + $this->assertTrue($this->policy()->viewAny($this->orgAdminA)); + $this->assertFalse($this->policy()->viewAny($this->regular)); + } + + public function test_view_returns_false_when_parent_submission_soft_deleted(): void + { + $failure = $this->failureFor($this->orgA); + // Soft-delete the submission. The failure row is preserved (no + // cascade on soft-delete); the policy's submission relation + // honours the SoftDeletes scope and returns null. + FormSubmission::query()->withoutGlobalScopes()->where('id', $failure->form_submission_id)->delete(); + + $reloaded = FormSubmissionActionFailure::query()->find($failure->id); + $this->assertNotNull($reloaded); + + $this->assertFalse($this->policy()->view($this->superAdmin, $reloaded)); + } + + private function policy(): FormSubmissionActionFailurePolicy + { + return new FormSubmissionActionFailurePolicy(); + } + + private function failureFor(Organisation $organisation): FormSubmissionActionFailure + { + $schema = FormSchema::factory()->create(['organisation_id' => $organisation->id]); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $schema->id, + 'organisation_id' => $organisation->id, + ]); + + return FormSubmissionActionFailure::factory() + ->for($submission, 'submission') + ->create(); + } +}