S2b Phase 1 per ARCH-FORM-BUILDER.md §20.2. Ten services + supporting
exceptions, jobs, and the organisations.default_locale column needed by
FormLocaleResolver. All services log via spatie/laravel-activitylog, write
operations are transactional, queued jobs are idempotent.
- FormSchemaService: CRUD, slug, version bump, duplicate, edit-lock,
public_token rotation (7-day grace window), typed-confirmation delete.
- FormFieldService: CRUD, reorder, insertFromLibrary, binding-change guard
(§6.5), conditional_logic + section cycle detection (§8, §4.8.1),
is_filterable toggle triggers BackfillFormValueIndexedJob (§7.2, §22.10).
- FormSubmissionService: createDraft with idempotency, saveDraft (auto-save),
submit with schema snapshot + signature hash computation (§9), review,
delegate/revoke, soft delete. Fires S1 domain events (§17.1).
- FormValueService: bulk upsert with FieldAccessService RBAC (§24.2),
Pattern A/C entity mirror writes (§6.1, §6.6) with cross-entity graceful
skip for person.user_id=null.
- FieldAccessService: canRead/canWrite/filterVisibleFields honouring
role_restrictions + subject-self (§18.3, §24.1).
- FormLocaleResolver: submitter → schema → org.default_locale → 'nl' (§16.2).
- FormTagSyncService: rebuildForPerson — replaces legacy TagSyncService
deleted in S2a (§31.10).
- FilterQueryBuilder: generic filter applier for entity_column / tags /
form_field sources (§7.4–§7.5).
- FormWebhookDispatcher + DeliverFormWebhookJob: HMAC-signed delivery with
SSRF protection, exponential backoff {1m,5m,30m,2h,8h}, max 5 attempts,
dead-letter on exhaustion (§17.5).
- FormSubmissionAnonymisationService: per-field anonymisation with separate
activity log entries (§13.3, §23.4).
MigrationRollbackTest: pin the S2a drop migration by filename so future
migrations don't shift the step offset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
4.5 KiB
PHP
164 lines
4.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\FormBuilder;
|
|
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormSubmission;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* Per-field RBAC for form rendering, value writes, and filter access.
|
|
* Implements ARCH §24 (role_restrictions matrix) + §18.3 (subject-self
|
|
* always has access).
|
|
*/
|
|
final class FieldAccessService
|
|
{
|
|
public function canRead(?User $user, FormField $field, ?FormSubmission $submission = null): bool
|
|
{
|
|
if ($this->isSubjectSelf($user, $submission)) {
|
|
return true;
|
|
}
|
|
|
|
$restrictions = $this->effectiveRestrictions($field);
|
|
$readRule = $restrictions['read'] ?? true;
|
|
|
|
return $this->matches($user, $readRule);
|
|
}
|
|
|
|
public function canWrite(?User $user, FormField $field, ?FormSubmission $submission = null): bool
|
|
{
|
|
if ($this->isSubjectSelf($user, $submission)) {
|
|
return true;
|
|
}
|
|
|
|
$restrictions = $this->effectiveRestrictions($field);
|
|
$writeRule = $restrictions['write'] ?? ['any_of_roles' => ['org_admin', 'event_manager']];
|
|
|
|
return $this->matches($user, $writeRule);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, FormField> $fields
|
|
* @return Collection<int, FormField>
|
|
*/
|
|
public function filterVisibleFields(?User $user, Collection $fields, ?FormSubmission $submission = null): Collection
|
|
{
|
|
return $fields->filter(fn (FormField $f) => $this->canRead($user, $f, $submission))->values();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function effectiveRestrictions(FormField $field): array
|
|
{
|
|
$r = $field->role_restrictions ?? null;
|
|
if (is_array($r) && $r !== []) {
|
|
return $r;
|
|
}
|
|
|
|
if ($field->is_admin_only) {
|
|
return [
|
|
'read' => ['any_of_roles' => ['org_admin']],
|
|
'write' => ['any_of_roles' => ['org_admin']],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'read' => true,
|
|
'write' => ['any_of_roles' => ['org_admin', 'event_manager']],
|
|
];
|
|
}
|
|
|
|
private function isSubjectSelf(?User $user, ?FormSubmission $submission): bool
|
|
{
|
|
if ($user === null || $submission === null) {
|
|
return false;
|
|
}
|
|
|
|
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 === 'user_profile' && $submission->subject_id !== null) {
|
|
$profile = \App\Models\UserProfile::query()->find($submission->subject_id);
|
|
if ($profile !== null && $profile->user_id === $user->id) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ($submission->subject_type === 'person' && $submission->subject_id !== null) {
|
|
$personUserId = \App\Models\Person::withoutGlobalScopes()
|
|
->whereKey($submission->subject_id)
|
|
->value('user_id');
|
|
if ($personUserId !== null && $personUserId === $user->id) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $rule
|
|
*/
|
|
private function matches(?User $user, $rule): bool
|
|
{
|
|
if ($rule === true) {
|
|
return true;
|
|
}
|
|
if ($rule === false) {
|
|
return false;
|
|
}
|
|
if (! is_array($rule)) {
|
|
return false;
|
|
}
|
|
|
|
if ($user === null) {
|
|
return false;
|
|
}
|
|
|
|
if (isset($rule['subject_self']) && $rule['subject_self'] === true) {
|
|
return true;
|
|
}
|
|
|
|
if (isset($rule['any_of_roles']) && is_array($rule['any_of_roles'])) {
|
|
foreach ($rule['any_of_roles'] as $role) {
|
|
if ($user->hasRole((string) $role)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (isset($rule['all_of_roles']) && is_array($rule['all_of_roles'])) {
|
|
foreach ($rule['all_of_roles'] as $role) {
|
|
if (! $user->hasRole((string) $role)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (isset($rule['not_roles']) && is_array($rule['not_roles'])) {
|
|
foreach ($rule['not_roles'] as $role) {
|
|
if ($user->hasRole((string) $role)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|