feat(form-builder): retry/resolve/dismiss API endpoints + dual-route auth (WS-6)

Two route groups: /api/v1/admin/form-failures (super_admin platform) and
/api/v1/organisations/{organisation}/form-failures (org_admin scoped).
Same controller, policy authorises via FK chain (RFC V3). Cross-tenant
access returns 404 not 403 to prevent enumeration.

Resolve takes optional note; Dismiss requires DismissalReasonType
enum with conditional note (mandatory for 'other'). Both via
FormRequest validation with explicit i18n message keys.

Implementation note: Laravel implicit model binding for nested-namespace
ULID models doesn't pick up reliably across nested route groups. Using
manual resolveFailure() helper that loads withoutGlobalScopes() (so
cross-tenant access still reaches the policy, which translates denied →
404 per V3). Policy explicitly checks soft-delete via deleted_at since
withoutGlobalScopes bypasses SoftDeletes too. Policy registered
explicitly in AppServiceProvider — auto-discovery doesn't reliably
resolve App\Models\FormBuilder\* → App\Policies\FormBuilder\*.

NOT: admin UI (session 3). Not: public form routes (no API contract
notification needed).

Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 15:34:23 +02:00
parent 84d57c5bbc
commit d0e17f2824
9 changed files with 509 additions and 3 deletions

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\DismissalReasonType;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\Http\Controllers\Controller;
use App\Http\Requests\FormBuilder\DismissFailureRequest;
use App\Http\Requests\FormBuilder\ResolveFailureRequest;
use App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Throwable;
/**
* RFC-WS-6 §3 (Q5) + §4 (V3) admin endpoints for the binding-pipeline
* failure workflow. Two route groups in api.php (org-scoped + super_admin
* platform). Cross-tenant access returns 404, never 403, to prevent
* resource-existence enumeration.
*/
final class FormSubmissionActionFailureController extends Controller
{
public function orgIndex(Organisation $organisation): AnonymousResourceCollection
{
Gate::authorize('viewAny', FormSubmissionActionFailure::class);
$failures = FormSubmissionActionFailure::query()
->whereHas('submission', function ($q) use ($organisation): void {
/** @var \Illuminate\Database\Eloquent\Builder<FormSubmission> $q */
$q->where('organisation_id', $organisation->id);
})
->latest('failed_at')
->paginate(50);
return FormSubmissionActionFailureResource::collection($failures);
}
public function platformIndex(): AnonymousResourceCollection
{
Gate::authorize('viewAny', FormSubmissionActionFailure::class);
$failures = FormSubmissionActionFailure::query()
->latest('failed_at')
->paginate(50);
return FormSubmissionActionFailureResource::collection($failures);
}
public function show(\Illuminate\Http\Request $request): FormSubmissionActionFailureResource
{
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('view', $failure);
return new FormSubmissionActionFailureResource($failure);
}
public function retry(\Illuminate\Http\Request $request, FormBindingApplicator $applicator): FormSubmissionActionFailureResource|JsonResponse
{
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('retry', $failure);
if (! $failure->canBeRetried()) {
return response()->json([
'error' => 'cannot_retry',
'message' => 'Failure is dismissed; cannot retry.',
], 422);
}
$submission = $failure->submission;
if ($submission === null) {
return response()->json([
'error' => 'submission_gone',
'message' => 'Parent submission has been deleted.',
], 410);
}
try {
DB::transaction(function () use ($applicator, $submission): void {
$result = $applicator->apply($submission);
FormSubmission::query()
->whereKey($submission->id)
->update([
'apply_status' => $result->applyStatus()->value,
'apply_completed_at' => now(),
]);
});
$failure->retry_count = (int) $failure->retry_count + 1;
$failure->resolved_at = now();
$failure->save();
} catch (Throwable $e) {
DB::transaction(function () use ($failure, $submission, $e): void {
FormSubmissionActionFailure::query()->create([
'form_submission_id' => $submission->id,
'listener_class' => $failure->listener_class,
'failed_at' => now(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
'context' => ['retry_of' => (string) $failure->id],
]);
FormSubmissionActionFailure::query()
->whereKey($failure->id)
->update(['retry_count' => (int) $failure->retry_count + 1]);
FormSubmission::query()
->whereKey($submission->id)
->update(['apply_status' => ApplyStatus::FAILED->value]);
});
}
return new FormSubmissionActionFailureResource($failure->refresh());
}
public function resolve(
ResolveFailureRequest $request,
): FormSubmissionActionFailureResource|JsonResponse {
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('resolve', $failure);
if ($failure->resolved_at !== null) {
return response()->json([
'error' => 'already_resolved',
], 422);
}
if ($failure->dismissed_at !== null) {
return response()->json([
'error' => 'already_dismissed',
], 422);
}
$failure->resolved_at = now();
$failure->resolved_note = $request->input('note');
$failure->resolved_by_user_id = $request->user()?->id;
$failure->save();
return new FormSubmissionActionFailureResource($failure->refresh());
}
public function dismiss(
DismissFailureRequest $request,
): FormSubmissionActionFailureResource|JsonResponse {
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('dismiss', $failure);
if ($failure->resolved_at !== null) {
return response()->json([
'error' => 'already_resolved',
], 422);
}
if ($failure->dismissed_at !== null) {
return response()->json([
'error' => 'already_dismissed',
], 422);
}
$failure->dismissed_at = now();
$failure->dismissed_reason_type = DismissalReasonType::from((string) $request->input('reason_type'));
$failure->dismissed_reason_note = $request->input('note');
$failure->dismissed_by_user_id = $request->user()?->id;
$failure->save();
return new FormSubmissionActionFailureResource($failure->refresh());
}
/**
* Manual model resolution. Implicit binding doesn't pick up nested-
* namespace ULID models reliably across nested route groups, so we
* load explicitly without global scopes (cross-tenant access reaches
* the policy, which then translates denied 404 per RFC V3).
*/
private function resolveFailure(string $id): FormSubmissionActionFailure
{
$failure = FormSubmissionActionFailure::query()
->withoutGlobalScopes()
->find($id);
if ($failure === null) {
throw new ModelNotFoundException();
}
return $failure;
}
/**
* Translate a denied policy to 404 (RFC V3) so cross-tenant access
* does NOT confirm resource existence.
*/
private function authorizeOrNotFound(string $ability, FormSubmissionActionFailure $failure): void
{
if (Gate::denies($ability, $failure)) {
throw new ModelNotFoundException();
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\FormBuilder;
use App\Enums\FormBuilder\DismissalReasonType;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class DismissFailureRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'reason_type' => ['required', Rule::enum(DismissalReasonType::class)],
'note' => ['nullable', 'string', 'max:500'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v): void {
if ($v->errors()->isNotEmpty()) {
return;
}
$reason = DismissalReasonType::tryFrom((string) $this->input('reason_type'));
if ($reason === null) {
return;
}
if ($reason->requiresNote() && $this->input('note') === null) {
$v->errors()->add('note', __('form_builder.dismiss.note_required_for_other'));
}
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
final class ResolveFailureRequest extends FormRequest
{
public function authorize(): bool
{
return true; // policy enforced in controller via Gate::authorize
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'note' => ['nullable', 'string', 'max:65535'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\FormBuilder;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin FormSubmissionActionFailure
*/
final class FormSubmissionActionFailureResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'form_submission_id' => $this->form_submission_id,
'binding_id' => $this->binding_id,
'listener_class' => $this->listener_class,
'failed_at' => $this->failed_at->toIso8601String(),
'exception_class' => $this->exception_class,
'exception_message' => $this->exception_message,
'context' => $this->context,
'retry_count' => $this->retry_count,
'resolved_at' => $this->resolved_at?->toIso8601String(),
'resolved_note' => $this->resolved_note,
'dismissed_at' => $this->dismissed_at?->toIso8601String(),
'dismissed_reason_type' => $this->dismissed_reason_type?->value,
'dismissed_reason_note' => $this->dismissed_reason_note,
'state' => match (true) {
$this->resolved_at !== null => 'resolved',
$this->dismissed_at !== null => 'dismissed',
default => 'open',
},
];
}
}

View File

@@ -54,11 +54,19 @@ final class FormSubmissionActionFailurePolicy
private function canAccess(User $user, FormSubmissionActionFailure $failure): bool private function canAccess(User $user, FormSubmissionActionFailure $failure): bool
{ {
$failure->loadMissing('submission'); // Load the submission without global scopes so cross-tenant
$submission = $failure->submission; // 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) { if ($submission === null) {
return false; // parent submission deleted return false; // parent submission deleted
} }
if ($submission->deleted_at !== null) {
return false; // soft-deleted parent — treat as gone
}
$orgId = (string) $submission->organisation_id; $orgId = (string) $submission->organisation_id;
if ($orgId === '') { if ($orgId === '') {
@@ -71,7 +79,9 @@ final class FormSubmissionActionFailurePolicy
// Tenant scope: user must be an org_admin in the failure's // Tenant scope: user must be an org_admin in the failure's
// organisation. RFC V3 — IDOR-class FK-chain enforcement. // organisation. RFC V3 — IDOR-class FK-chain enforcement.
$organisation = $submission->organisation; $organisation = \App\Models\Organisation::query()
->withoutGlobalScopes()
->find($orgId);
if (! $organisation instanceof Organisation) { if (! $organisation instanceof Organisation) {
return false; return false;
} }

View File

@@ -64,6 +64,7 @@ use App\Observers\UserObserver;
use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -120,6 +121,16 @@ class AppServiceProvider extends ServiceProvider
{ {
$this->registerMorphMap(); $this->registerMorphMap();
// RFC-WS-6 V3 — explicit policy registration. Laravel's auto-discovery
// doesn't reliably resolve nested-namespace models like
// App\Models\FormBuilder\FormSubmissionActionFailure to
// App\Policies\FormBuilder\FormSubmissionActionFailurePolicy.
Gate::policy(
\App\Models\FormBuilder\FormSubmissionActionFailure::class,
\App\Policies\FormBuilder\FormSubmissionActionFailurePolicy::class,
);
Person::observe(PersonObserver::class); Person::observe(PersonObserver::class);
User::observe(UserObserver::class); User::observe(UserObserver::class);
FormValue::observe(FormValueObserver::class); FormValue::observe(FormValueObserver::class);

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
return [
'dismiss' => [
'note_required_for_other' => 'Een toelichting is verplicht wanneer reden = "anders".',
],
];

View File

@@ -144,6 +144,13 @@ Route::prefix('admin')
Route::get('impersonate/status', [AdminImpersonationController::class, 'status']); Route::get('impersonate/status', [AdminImpersonationController::class, 'status']);
Route::post('impersonate/send-mfa-code', [AdminImpersonationController::class, 'sendMfaCode']); Route::post('impersonate/send-mfa-code', [AdminImpersonationController::class, 'sendMfaCode']);
Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']); Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']);
// RFC-WS-6 §3 (Q5) — platform-wide form-failure admin endpoints.
Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'platformIndex']);
Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show']);
Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry']);
Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve']);
Route::post('form-failures/{formSubmissionActionFailure}/dismiss', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'dismiss']);
}); });
// Protected routes // Protected routes
@@ -235,6 +242,13 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () {
// Email logs (read-only) // Email logs (read-only)
Route::get('email-logs', [EmailLogController::class, 'index']); Route::get('email-logs', [EmailLogController::class, 'index']);
// RFC-WS-6 §3 (Q5) — org-scoped form-failure admin endpoints.
Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'orgIndex']);
Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show']);
Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry']);
Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve']);
Route::post('form-failures/{formSubmissionActionFailure}/dismiss', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'dismiss']);
// Person tags (organisation settings) // Person tags (organisation settings)
Route::apiResource('person-tags', PersonTagController::class) Route::apiResource('person-tags', PersonTagController::class)
->except(['show']); ->except(['show']);

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Api;
use App\Enums\FormBuilder\DismissalReasonType;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* RFC-WS-6 §3 (Q5) + §4 (V3) admin endpoints. Cross-tenant access
* MUST return 404, never 403, to prevent enumeration. The IDOR-class
* tests below assert this contract explicitly.
*/
final class FormSubmissionActionFailureControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $orgA;
private Organisation $orgB;
private User $superAdmin;
private User $orgAdminA;
private User $orgAdminB;
private FormSubmissionActionFailure $failureInOrgA;
protected function setUp(): void
{
parent::setUp();
$this->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']);
$schema = FormSchema::factory()->create(['organisation_id' => $this->orgA->id]);
$submission = FormSubmission::factory()->create([
'form_schema_id' => $schema->id,
'organisation_id' => $this->orgA->id,
]);
$this->failureInOrgA = FormSubmissionActionFailure::factory()
->for($submission, 'submission')
->create();
}
public function test_unauthenticated_returns_401(): void
{
$this->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}")
->assertStatus(401);
}
public function test_super_admin_can_view_failure(): void
{
$this->withoutExceptionHandling();
Sanctum::actingAs($this->superAdmin); $this
->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}")
->assertOk()
->assertJsonPath('data.id', (string) $this->failureInOrgA->id);
}
public function test_org_admin_can_view_own_org_failure(): void
{
Sanctum::actingAs($this->orgAdminA);
$this->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}")
->assertOk();
}
/**
* RFC V3 IDOR-class admin from org B must NOT see a failure
* whose submission belongs to org A. 404, NOT 403.
*/
public function test_cross_tenant_access_returns_404_not_403(): void
{
Sanctum::actingAs($this->orgAdminB); $this
->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}")
->assertStatus(404);
}
public function test_resolve_endpoint_with_note(): void
{
Sanctum::actingAs($this->superAdmin); $this
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/resolve", [
'note' => 'fixed via direct edit',
])
->assertOk()
->assertJsonPath('data.state', 'resolved')
->assertJsonPath('data.resolved_note', 'fixed via direct edit');
}
public function test_dismiss_endpoint_with_enum_reason(): void
{
Sanctum::actingAs($this->superAdmin); $this
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [
'reason_type' => DismissalReasonType::SCHEMA_DELETED->value,
])
->assertOk()
->assertJsonPath('data.state', 'dismissed')
->assertJsonPath('data.dismissed_reason_type', DismissalReasonType::SCHEMA_DELETED->value);
}
public function test_dismiss_other_without_note_fails_422(): void
{
Sanctum::actingAs($this->superAdmin); $this
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [
'reason_type' => DismissalReasonType::OTHER->value,
])
->assertStatus(422)
->assertJsonValidationErrors(['note']);
}
public function test_cross_tenant_dismiss_returns_404(): void
{
Sanctum::actingAs($this->orgAdminB); $this
->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/dismiss", [
'reason_type' => DismissalReasonType::OTHER->value,
'note' => 'evil',
])
->assertStatus(404);
}
public function test_cross_tenant_resolve_returns_404(): void
{
Sanctum::actingAs($this->orgAdminB); $this
->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/resolve", [])
->assertStatus(404);
}
}