feat(form-builder): policies and form requests with scoped exists rules

Phase 3 of S2b. Six policies and fifteen form requests for the universal
form builder. Every exists: rule is scoped to the route's organisation
or form_schema to close the A01-5..18 findings from SECURITY_AUDIT.md.

Policies (api/app/Policies/FormBuilder/):
- FormSchemaPolicy, FormFieldPolicy, FormFieldLibraryPolicy,
  FormTemplatePolicy, FormSubmissionPolicy, FormSchemaWebhookPolicy.
- FormSubmissionPolicy honours subject-self (user / person.user_id
  match / submitted_by_user_id) and active delegations, per §18.3.
- No `return true` placeholders — each method checks org membership and
  role via Spatie's hasRole().

Form Requests (api/app/Http/Requests/Api/V1/FormBuilder/):
- Schema: Store/UpdateFormSchemaRequest, RotatePublicTokenRequest.
- Fields: Store/UpdateFormFieldRequest, ReorderFormFieldsRequest (field
  ids scoped to the route schema), InsertLibraryFieldRequest (library
  scoped to the route organisation).
- Templates: Store/UpdateFormTemplateRequest.
- Field library: Store/UpdateFormFieldLibraryRequest.
- Submissions: CreateFormSubmissionRequest, UpsertFormValuesRequest
  (slug allow-list derived from schema), SubmitFormSubmissionRequest,
  ReviewFormSubmissionRequest, DelegateFormSubmissionRequest (delegatee
  scoped to organisation pivot).
- Webhooks: Store/UpdateFormSchemaWebhookRequest.
- Public: PublicSubmissionRequest (captcha_token collected here,
  enforcement in controller per config('form_builder.captcha')).

All enum validation routes through the existing PHP enums from S1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 21:08:49 +02:00
parent 4495ab017e
commit ab84850089
25 changed files with 1195 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class CreateFormSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$allowedSubjects = array_keys((array) config('form_subjects', []));
return [
'subject_type' => ['nullable', Rule::in($allowedSubjects)],
'subject_id' => ['nullable', 'string', 'max:30', 'required_with:subject_type'],
'idempotency_key' => ['nullable', 'string', 'max:30'],
'is_test' => ['boolean'],
'opened_at' => ['nullable', 'date'],
'public_submitter_name' => ['nullable', 'string', 'max:150'],
'public_submitter_email' => ['nullable', 'email', 'max:255'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Models\Organisation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class DelegateFormSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$organisation = $this->route('organisation');
$orgId = $organisation instanceof Organisation ? $organisation->id : (string) $organisation;
return [
'delegated_to_user_id' => [
'required', 'string',
Rule::exists('organisation_user', 'user_id')->where('organisation_id', $orgId),
],
'message' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Models\Organisation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class InsertLibraryFieldRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$organisation = $this->route('organisation');
$orgId = $organisation instanceof Organisation ? $organisation->id : (string) $organisation;
return [
'library_field_id' => [
'required', 'string',
Rule::exists('form_field_library', 'id')->where('organisation_id', $orgId),
],
'overrides' => ['nullable', 'array'],
'overrides.label' => ['sometimes', 'string', 'max:255'],
'overrides.slug' => ['sometimes', 'string', 'max:100'],
'overrides.is_required' => ['sometimes', 'boolean'],
'overrides.sort_order' => ['sometimes', 'integer'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
final class PublicSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'values' => ['required', 'array'],
'public_submitter_name' => ['nullable', 'string', 'max:150'],
'public_submitter_email' => ['nullable', 'email', 'max:255'],
'captcha_token' => ['nullable', 'string', 'max:2000'],
'idempotency_key' => ['nullable', 'string', 'max:30'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Models\FormBuilder\FormSchema;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class ReorderFormFieldsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$schema = $this->route('form_schema');
$schemaId = $schema instanceof FormSchema ? $schema->id : (string) $schema;
return [
'field_ids' => ['required', 'array', 'min:1'],
'field_ids.*' => [
'string',
Rule::exists('form_fields', 'id')->where('form_schema_id', $schemaId),
],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormSubmissionReviewStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class ReviewFormSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['required', Rule::in(array_map(fn ($c) => $c->value, FormSubmissionReviewStatus::cases()))],
'review_notes' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
final class RotatePublicTokenRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'grace_days' => ['nullable', 'integer', 'min:0', 'max:30'],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreFormFieldLibraryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$types = array_merge(
array_map(fn ($c) => $c->value, FormFieldType::cases()),
array_keys((array) config('form_builder.custom_field_types', [])),
);
return [
'name' => ['required', 'string', 'max:150'],
'slug' => ['nullable', 'string', 'max:150'],
'field_type' => ['required', Rule::in($types)],
'label' => ['required', 'string', 'max:255'],
'help_text' => ['nullable', 'string'],
'options' => ['nullable', 'array'],
'validation_rules' => ['nullable', 'array'],
'default_is_required' => ['boolean'],
'default_is_filterable' => ['boolean'],
'default_binding' => ['nullable', 'array'],
'translations' => ['nullable', 'array'],
'description' => ['nullable', 'string'],
'is_active' => ['boolean'],
'organisation_id' => ['prohibited'],
'is_system' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormFieldDisplayWidth;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormValueStorageHint;
use App\Models\FormBuilder\FormSchema;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreFormFieldRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$schema = $this->route('form_schema');
$schemaId = $schema instanceof FormSchema ? $schema->id : (string) $schema;
$types = array_merge(
array_map(fn ($c) => $c->value, FormFieldType::cases()),
array_keys((array) config('form_builder.custom_field_types', [])),
);
return [
'field_type' => ['required', Rule::in($types)],
'slug' => ['required', 'string', 'max:100'],
'label' => ['required', 'string', 'max:255'],
'help_text' => ['nullable', 'string'],
'section' => ['nullable', 'string', 'max:100'],
'form_schema_section_id' => [
'nullable',
Rule::exists('form_schema_sections', 'id')->where('form_schema_id', $schemaId),
],
'library_field_id' => [
'nullable',
Rule::exists('form_field_library', 'id'),
],
'options' => ['nullable', 'array'],
'validation_rules' => ['nullable', 'array'],
'is_required' => ['boolean'],
'is_filterable' => ['boolean'],
'is_portal_visible' => ['boolean'],
'is_admin_only' => ['boolean'],
'is_unique' => ['boolean'],
'is_pii' => ['boolean'],
'display_width' => ['nullable', Rule::in(array_map(fn ($c) => $c->value, FormFieldDisplayWidth::cases()))],
'binding' => ['nullable', 'array'],
'conditional_logic' => ['nullable', 'array'],
'role_restrictions' => ['nullable', 'array'],
'translations' => ['nullable', 'array'],
'value_storage_hint' => ['nullable', Rule::in(array_map(fn ($c) => $c->value, FormValueStorageHint::cases()))],
'review_required' => ['boolean'],
'sort_order' => ['nullable', 'integer'],
'form_schema_id' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\Models\Organisation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreFormSchemaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$organisation = $this->route('organisation');
$orgId = $organisation instanceof Organisation ? $organisation->id : (string) $organisation;
return [
'name' => ['required', 'string', 'max:150'],
'slug' => ['nullable', 'string', 'max:150'],
'purpose' => ['required', Rule::in(FormPurpose::values())],
'custom_purpose_slug' => ['nullable', 'string', 'max:150', 'required_if:purpose,custom'],
'description' => ['nullable', 'string'],
'is_published' => ['boolean'],
'submission_mode' => ['nullable', Rule::in(FormSubmissionMode::cases() ? array_map(fn ($c) => $c->value, FormSubmissionMode::cases()) : [])],
'locale' => ['nullable', 'string', 'max:10'],
'settings' => ['nullable', 'array'],
'snapshot_mode' => ['nullable', Rule::in(array_map(fn ($c) => $c->value, FormSchemaSnapshotMode::cases()))],
'freeze_on_submit' => ['boolean'],
'retention_days' => ['nullable', 'integer', 'min:1'],
'consent_version' => ['nullable', 'string', 'max:50'],
'section_level_submit' => ['boolean'],
'auto_save_enabled' => ['boolean'],
'max_submissions' => ['nullable', 'integer', 'min:1'],
'owner_type' => ['nullable', 'string', 'max:50'],
'owner_id' => ['nullable', 'string', 'max:30'],
'organisation_id' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreFormSchemaWebhookRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$triggers = [
'submission_created', 'submission_submitted', 'submission_reviewed',
'section_submitted', 'section_approved', 'section_rejected',
];
return [
'name' => ['required', 'string', 'max:150'],
'trigger_event' => ['required', Rule::in($triggers)],
'url' => ['required', 'string', 'max:500', 'url'],
'secret' => ['nullable', 'string', 'max:200'],
'is_active' => ['boolean'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreFormTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:150'],
'slug' => ['nullable', 'string', 'max:150'],
'purpose' => ['required', Rule::in(FormPurpose::values())],
'description' => ['nullable', 'string'],
'schema_snapshot' => ['required', 'array'],
'is_active' => ['boolean'],
'organisation_id' => ['prohibited'],
'is_system' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
final class SubmitFormSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'idempotency_key' => ['nullable', 'string', 'max:30'],
'values' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateFormFieldLibraryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$types = array_merge(
array_map(fn ($c) => $c->value, FormFieldType::cases()),
array_keys((array) config('form_builder.custom_field_types', [])),
);
return [
'name' => ['sometimes', 'string', 'max:150'],
'slug' => ['sometimes', 'string', 'max:150'],
'field_type' => ['sometimes', Rule::in($types)],
'label' => ['sometimes', 'string', 'max:255'],
'help_text' => ['sometimes', 'nullable', 'string'],
'options' => ['sometimes', 'nullable', 'array'],
'validation_rules' => ['sometimes', 'nullable', 'array'],
'default_is_required' => ['sometimes', 'boolean'],
'default_is_filterable' => ['sometimes', 'boolean'],
'default_binding' => ['sometimes', 'nullable', 'array'],
'translations' => ['sometimes', 'nullable', 'array'],
'description' => ['sometimes', 'nullable', 'string'],
'is_active' => ['sometimes', 'boolean'],
'organisation_id' => ['prohibited'],
'is_system' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormFieldDisplayWidth;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormValueStorageHint;
use App\Models\FormBuilder\FormSchema;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateFormFieldRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$schema = $this->route('form_schema');
$schemaId = $schema instanceof FormSchema ? $schema->id : (string) $schema;
$types = array_merge(
array_map(fn ($c) => $c->value, FormFieldType::cases()),
array_keys((array) config('form_builder.custom_field_types', [])),
);
return [
'field_type' => ['sometimes', Rule::in($types)],
'slug' => ['sometimes', 'string', 'max:100'],
'label' => ['sometimes', 'string', 'max:255'],
'help_text' => ['sometimes', 'nullable', 'string'],
'section' => ['sometimes', 'nullable', 'string', 'max:100'],
'form_schema_section_id' => [
'sometimes', 'nullable',
Rule::exists('form_schema_sections', 'id')->where('form_schema_id', $schemaId),
],
'options' => ['sometimes', 'nullable', 'array'],
'validation_rules' => ['sometimes', 'nullable', 'array'],
'is_required' => ['sometimes', 'boolean'],
'is_filterable' => ['sometimes', 'boolean'],
'is_portal_visible' => ['sometimes', 'boolean'],
'is_admin_only' => ['sometimes', 'boolean'],
'is_unique' => ['sometimes', 'boolean'],
'is_pii' => ['sometimes', 'boolean'],
'display_width' => ['sometimes', Rule::in(array_map(fn ($c) => $c->value, FormFieldDisplayWidth::cases()))],
'binding' => ['sometimes', 'nullable', 'array'],
'conditional_logic' => ['sometimes', 'nullable', 'array'],
'role_restrictions' => ['sometimes', 'nullable', 'array'],
'translations' => ['sometimes', 'nullable', 'array'],
'value_storage_hint' => ['sometimes', Rule::in(array_map(fn ($c) => $c->value, FormValueStorageHint::cases()))],
'review_required' => ['sometimes', 'boolean'],
'sort_order' => ['sometimes', 'integer'],
'force_binding_change' => ['sometimes', 'boolean'],
'form_schema_id' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Enums\FormBuilder\FormSubmissionMode;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateFormSchemaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:150'],
'slug' => ['sometimes', 'string', 'max:150'],
'purpose' => ['sometimes', Rule::in(FormPurpose::values())],
'custom_purpose_slug' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'is_published' => ['sometimes', 'boolean'],
'submission_mode' => ['sometimes', Rule::in(array_map(fn ($c) => $c->value, FormSubmissionMode::cases()))],
'locale' => ['sometimes', 'string', 'max:10'],
'settings' => ['sometimes', 'array'],
'snapshot_mode' => ['sometimes', Rule::in(array_map(fn ($c) => $c->value, FormSchemaSnapshotMode::cases()))],
'freeze_on_submit' => ['sometimes', 'boolean'],
'retention_days' => ['sometimes', 'nullable', 'integer', 'min:1'],
'consent_version' => ['sometimes', 'nullable', 'string', 'max:50'],
'section_level_submit' => ['sometimes', 'boolean'],
'auto_save_enabled' => ['sometimes', 'boolean'],
'max_submissions' => ['sometimes', 'nullable', 'integer', 'min:1'],
'submission_deadline' => ['sometimes', 'nullable', 'date'],
'organisation_id' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateFormSchemaWebhookRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$triggers = [
'submission_created', 'submission_submitted', 'submission_reviewed',
'section_submitted', 'section_approved', 'section_rejected',
];
return [
'name' => ['sometimes', 'string', 'max:150'],
'trigger_event' => ['sometimes', Rule::in($triggers)],
'url' => ['sometimes', 'string', 'max:500', 'url'],
'secret' => ['sometimes', 'nullable', 'string', 'max:200'],
'is_active' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
final class UpdateFormTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:150'],
'slug' => ['sometimes', 'string', 'max:150'],
'description' => ['sometimes', 'nullable', 'string'],
'schema_snapshot' => ['sometimes', 'array'],
'is_active' => ['sometimes', 'boolean'],
'organisation_id' => ['prohibited'],
'is_system' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Foundation\Http\FormRequest;
/**
* Accepts { values: { <slug>: <value|[value...]> } }. Slugs must exist on
* the submission's schema. Per-type validation is driven by
* FormField.validation_rules.
*/
final class UpsertFormValuesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$submission = $this->route('form_submission');
$schemaId = $submission instanceof FormSubmission ? $submission->form_schema_id : null;
$allowedSlugs = [];
if ($schemaId !== null) {
$allowedSlugs = FormField::query()
->where('form_schema_id', $schemaId)
->pluck('slug')
->all();
}
return [
'values' => ['required', 'array', function ($attribute, $value, $fail) use ($allowedSlugs): void {
if (! is_array($value)) {
return;
}
foreach (array_keys($value) as $slug) {
if (! in_array($slug, $allowedSlugs, true)) {
$fail(sprintf('values.%s is not a known field slug for this submission.', $slug));
}
}
}],
'values.*' => ['nullable'],
];
}
}