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>
128 lines
3.6 KiB
PHP
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');
|
|
}
|
|
}
|