Files
crewli/api/app/Policies/FormBuilder/FormSubmissionPolicy.php
bert.hausmans ab84850089 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>
2026-04-17 21:08:49 +02:00

128 lines
3.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Policies\FormBuilder;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionDelegation;
use App\Models\Organisation;
use App\Models\User;
final class FormSubmissionPolicy
{
public function viewAny(User $user, FormSchema $schema): bool
{
return app(FormSchemaPolicy::class)->view($user, $schema);
}
public function view(User $user, FormSubmission $submission): bool
{
if ($this->isSubjectSelf($user, $submission)) {
return true;
}
if ($this->isActiveDelegatee($user, $submission)) {
return true;
}
return $this->isOrgStaff($user, $submission->schema?->organisation);
}
public function create(User $user, FormSchema $schema): bool
{
return app(FormSchemaPolicy::class)->view($user, $schema);
}
public function update(User $user, FormSubmission $submission): bool
{
if ($submission->status !== \App\Enums\FormBuilder\FormSubmissionStatus::DRAFT) {
return false;
}
if ($this->isSubjectSelf($user, $submission)) {
return true;
}
return $this->isActiveDelegatee($user, $submission);
}
public function submit(User $user, FormSubmission $submission): bool
{
return $this->update($user, $submission);
}
public function review(User $user, FormSubmission $submission): bool
{
return $this->isOrgStaff($user, $submission->schema?->organisation);
}
public function delegate(User $user, FormSubmission $submission): bool
{
return $this->isSubjectSelf($user, $submission);
}
public function revokeDelegation(User $user, FormSubmissionDelegation $delegation): bool
{
$submission = $delegation->submission;
if ($submission === null) {
return false;
}
return $this->isSubjectSelf($user, $submission) || $delegation->delegated_by_user_id === $user->id;
}
public function delete(User $user, FormSubmission $submission): bool
{
return $this->isOrgStaff($user, $submission->schema?->organisation, adminOnly: true);
}
private function isSubjectSelf(User $user, FormSubmission $submission): bool
{
if ($submission->submitted_by_user_id === $user->id) {
return true;
}
if ($submission->subject_type === 'user' && $submission->subject_id === $user->id) {
return true;
}
if ($submission->subject_type === 'person' && $submission->subject_id !== null) {
$userId = \App\Models\Person::withoutGlobalScopes()
->whereKey($submission->subject_id)
->value('user_id');
return $userId === $user->id;
}
return false;
}
private function isActiveDelegatee(User $user, FormSubmission $submission): bool
{
return FormSubmissionDelegation::query()
->where('form_submission_id', $submission->id)
->where('delegated_to_user_id', $user->id)
->whereNull('revoked_at')
->exists();
}
private function isOrgStaff(User $user, ?Organisation $organisation, bool $adminOnly = false): bool
{
if ($user->hasRole('super_admin')) {
return true;
}
if ($organisation === null) {
return false;
}
$query = $organisation->users()->where('user_id', $user->id);
if ($adminOnly) {
$query->wherePivot('role', 'org_admin');
}
return $query->exists() || $user->hasRole('event_manager');
}
}