From b688ec26f03e2f980e5a70cce256860a7cb20049 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 17:08:33 +0200 Subject: [PATCH] feat(scope): declarative FK-chain strategy for OrganisationScope, register on 14 models per addendum Q2 + D-03/D-04 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors OrganisationScope to support a declarative, recursive FK-chain resolver and registers the scope on 14 models that previously relied on caller-discipline for tenant isolation. Scope resolver (app/Models/Scopes/OrganisationScope.php): Models now declare their strategy via: public static function tenantScopeStrategy(): array { return ['column' => 'organisation_id']; // terminal // OR return ['via' => FormSchema::class, 'fk' => 'form_schema_id']; } The apply() path walks the chain recursively, building whereIn subqueries against parent models until it hits a column-based strategy. Max 3 hops; deeper chains raise App\Exceptions\TenantScopeResolutionException. The walker accepts BOTH the new tenantScopeStrategy() and the legacy $organisationScopeColumn property at every hop — so PersonIdentityMatch can chain via Person, which still uses the legacy event_id bridge, without requiring Person/Event/Shift/FestivalSection/TimeSlot to migrate to the new convention in this work package. That migration is a separate backlog ticket — explicitly scope-controlled per the addendum. Fourteen newly-scoped models: Form-builder child models (D-03): FormSchemaSection via FormSchema (1 hop) FormField via FormSchema (1 hop) FormSubmission column organisation_id (Commit 2) FormValue via FormSubmission (1 hop) FormValueOption via FormValue -> FormSubmission (2 hops) FormSubmissionSectionStatus via FormSubmission (1 hop) FormSubmissionDelegation via FormSubmission (1 hop) FormSchemaWebhook via FormSchema (1 hop) FormWebhookDelivery via FormSubmission (1 hop) Event-data models (D-04 event-data subset): ShiftAssignment via Shift (legacy festival_section_id) ShiftWaitlist via Shift VolunteerAvailability via TimeSlot (legacy event_id) PersonSectionPreference via FestivalSection (legacy event_id) PersonIdentityMatch via Person (legacy event_id) Note — task directive specified VolunteerAvailability "via: Event, fk: event_id", but the table has no event_id column (only person_id + time_slot_id). Rerouted via TimeSlot, which carries the legacy event_id bridge; same end result, correct FK. Security-relevant callers made explicit: PublicFormSchemaResource::toArray() now eagerly loads fields + sections with withoutGlobalScope(OrganisationScope::class). Prior to this commit the public form endpoint silently relied on those relations being unscoped. The PublicFormCrossOrgScopeTest pre-existing assertions still pass — behaviour unchanged, intent now explicit. Test fix: FormSchemaApiTest::test_publish_sets_is_published_true was flaky (factory randomly picked EVENT_REGISTRATION which requires bindings). Pinned to USER_PROFILE for determinism; PurposeSchemaLifecycleTest covers the binding-enforcement path. Test flip: MultiTenancyTest::test_form_schema_webhook_is_not_globally_scoped renamed to is_scoped_via_fk_chain and asserts the new behaviour: scope filters by route org, withoutGlobalScope() still exposes cross-org rows. The test's original purpose ("pin current behaviour so a future refactor is intentional") is now satisfied by Commit 3 being that intentional refactor. Docs: SCHEMA.md §3.5.11 Rule 5 — tenantScopeStrategy() convention documented; the 14 newly-scoped models enumerated; link to addendum Q2. ARCH-FORM-BUILDER.md §4.14 — new section "Multi-tenancy scope chain" with the hop-count table for all 14 chains and the withoutGlobalScope pattern for cross-org callers. Tests: tests/Feature/MultiTenancy/ScopeLeakageTest.php — two orgs with fully-populated record chains down to each of the 14 leaf models; asserts scoped queries never cross, withoutGlobalScope still does. Plus: three- hop chain (FormValueOption) explicitly exercised, legacy-column bridge verified, over-deep chain raises TenantScopeResolutionException. 16 tests / 31 new assertions. Full suite: 1000 passed (2706 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TenantScopeResolutionException.php | 43 ++ .../FormBuilder/PublicFormSchemaResource.php | 9 +- api/app/Models/FormBuilder/FormField.php | 12 + .../Models/FormBuilder/FormSchemaSection.php | 12 + .../Models/FormBuilder/FormSchemaWebhook.php | 24 +- api/app/Models/FormBuilder/FormSubmission.php | 14 + .../FormBuilder/FormSubmissionDelegation.php | 12 + .../FormSubmissionSectionStatus.php | 12 + api/app/Models/FormBuilder/FormValue.php | 12 + .../Models/FormBuilder/FormValueOption.php | 13 + .../FormBuilder/FormWebhookDelivery.php | 12 + api/app/Models/PersonIdentityMatch.php | 15 + api/app/Models/PersonSectionPreference.php | 15 + api/app/Models/Scopes/OrganisationScope.php | 122 +++- api/app/Models/ShiftAssignment.php | 13 + api/app/Models/ShiftWaitlist.php | 12 + api/app/Models/VolunteerAvailability.php | 15 + .../Feature/FormBuilder/FormSchemaApiTest.php | 8 +- .../Feature/FormBuilder/MultiTenancyTest.php | 27 +- .../Feature/MultiTenancy/ScopeLeakageTest.php | 520 ++++++++++++++++++ dev-docs/ARCH-FORM-BUILDER.md | 44 ++ dev-docs/SCHEMA.md | 11 + 22 files changed, 942 insertions(+), 35 deletions(-) create mode 100644 api/app/Exceptions/TenantScopeResolutionException.php create mode 100644 api/tests/Feature/MultiTenancy/ScopeLeakageTest.php diff --git a/api/app/Exceptions/TenantScopeResolutionException.php b/api/app/Exceptions/TenantScopeResolutionException.php new file mode 100644 index 00000000..aba55ee7 --- /dev/null +++ b/api/app/Exceptions/TenantScopeResolutionException.php @@ -0,0 +1,43 @@ + "..."] or ["via" => FQCN, "fk" => "..."].', + $modelClass + )); + } + + public static function unscopedParent(string $modelClass, string $parentClass): self + { + return new self(sprintf( + 'Model %s chains via %s, but the parent exposes neither tenantScopeStrategy() nor $organisationScopeColumn — cannot resolve tenant.', + $modelClass, + $parentClass + )); + } +} diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php index dabe7115..2bd95758 100644 --- a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php +++ b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php @@ -31,7 +31,14 @@ final class PublicFormSchemaResource extends JsonResource */ public function toArray(Request $request): array { - $this->resource->loadMissing(['fields', 'sections']); + // Public endpoints must resolve cross-org — skip OrganisationScope + // on the FormField / FormSchemaSection relations (both registered + // via addendum Q2 / WS-4 Commit 3). The schema-level tenant check + // already happens at PublicFormTokenResolver::resolve(). + $this->resource->loadMissing([ + 'fields' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class), + 'sections' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class), + ]); $visibleFields = $this->fields ->filter(fn ($f) => (bool) $f->is_portal_visible && ! (bool) $f->is_admin_only) diff --git a/api/app/Models/FormBuilder/FormField.php b/api/app/Models/FormBuilder/FormField.php index 93202cfd..4d9f897c 100644 --- a/api/app/Models/FormBuilder/FormField.php +++ b/api/app/Models/FormBuilder/FormField.php @@ -6,6 +6,7 @@ namespace App\Models\FormBuilder; use App\Enums\FormBuilder\FormFieldDisplayWidth; use App\Enums\FormBuilder\FormValueStorageHint; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -28,6 +29,17 @@ final class FormField extends Model use HasUlids; use SoftDeletes; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => FormSchema::class, 'fk' => 'form_schema_id']; + } + protected $fillable = [ 'form_schema_id', 'form_schema_section_id', diff --git a/api/app/Models/FormBuilder/FormSchemaSection.php b/api/app/Models/FormBuilder/FormSchemaSection.php index 52eeb760..5b4c2dec 100644 --- a/api/app/Models/FormBuilder/FormSchemaSection.php +++ b/api/app/Models/FormBuilder/FormSchemaSection.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -20,6 +21,17 @@ final class FormSchemaSection extends Model use HasUlids; use SoftDeletes; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => FormSchema::class, 'fk' => 'form_schema_id']; + } + protected $fillable = [ 'form_schema_id', 'slug', diff --git a/api/app/Models/FormBuilder/FormSchemaWebhook.php b/api/app/Models/FormBuilder/FormSchemaWebhook.php index 4b566f11..0a1de3a2 100644 --- a/api/app/Models/FormBuilder/FormSchemaWebhook.php +++ b/api/app/Models/FormBuilder/FormSchemaWebhook.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -11,21 +12,26 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; /** - * Scope discipline: organisation isolation is enforced via the parent - * FormSchema. This model does NOT carry a direct organisation_id column, - * and OrganisationScope's column strategies (organisation_id / event_id / - * festival_section_id) do not cover form_schema_id — extending the scope - * to a new strategy is out of scope for S1. - * - * NEVER query FormSchemaWebhook::query() without an eager constraint: - * always go through $schema->webhooks() or join on form_schema_id. Direct - * queries will leak across organisations. + * Scope: organisation isolation enforced via the FormSchema parent. + * See OrganisationScope's FK-chain resolver — declared via + * tenantScopeStrategy() below (addendum Q2). */ final class FormSchemaWebhook extends Model { use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => FormSchema::class, 'fk' => 'form_schema_id']; + } + protected $fillable = [ 'form_schema_id', 'name', diff --git a/api/app/Models/FormBuilder/FormSubmission.php b/api/app/Models/FormBuilder/FormSubmission.php index e3ad9325..e934cf41 100644 --- a/api/app/Models/FormBuilder/FormSubmission.php +++ b/api/app/Models/FormBuilder/FormSubmission.php @@ -8,6 +8,7 @@ use App\Enums\FormBuilder\FormSubmissionReviewStatus; use App\Enums\FormBuilder\FormSubmissionStatus; use App\Models\Event; use App\Models\Organisation; +use App\Models\Scopes\OrganisationScope; use App\Models\User; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -27,6 +28,19 @@ final class FormSubmission extends Model use HasUlids; use SoftDeletes; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{column: string} */ + public static function tenantScopeStrategy(): array + { + // Commit 2 added the denormalized organisation_id column + // (addendum Q2 — rapportage-hot exception). + return ['column' => 'organisation_id']; + } + protected $fillable = [ 'form_schema_id', 'organisation_id', diff --git a/api/app/Models/FormBuilder/FormSubmissionDelegation.php b/api/app/Models/FormBuilder/FormSubmissionDelegation.php index c72b591d..8e2e1c88 100644 --- a/api/app/Models/FormBuilder/FormSubmissionDelegation.php +++ b/api/app/Models/FormBuilder/FormSubmissionDelegation.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; +use App\Models\Scopes\OrganisationScope; use App\Models\User; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -15,6 +16,17 @@ final class FormSubmissionDelegation extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => FormSubmission::class, 'fk' => 'form_submission_id']; + } + protected $fillable = [ 'form_submission_id', 'delegated_to_user_id', diff --git a/api/app/Models/FormBuilder/FormSubmissionSectionStatus.php b/api/app/Models/FormBuilder/FormSubmissionSectionStatus.php index ca8a5e65..73d48bb5 100644 --- a/api/app/Models/FormBuilder/FormSubmissionSectionStatus.php +++ b/api/app/Models/FormBuilder/FormSubmissionSectionStatus.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; +use App\Models\Scopes\OrganisationScope; use App\Models\User; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -15,6 +16,17 @@ final class FormSubmissionSectionStatus extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => FormSubmission::class, 'fk' => 'form_submission_id']; + } + protected $fillable = [ 'form_submission_id', 'form_schema_section_id', diff --git a/api/app/Models/FormBuilder/FormValue.php b/api/app/Models/FormBuilder/FormValue.php index d8ae2d06..9b194d88 100644 --- a/api/app/Models/FormBuilder/FormValue.php +++ b/api/app/Models/FormBuilder/FormValue.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -19,6 +20,17 @@ final class FormValue extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => FormSubmission::class, 'fk' => 'form_submission_id']; + } + protected $fillable = [ 'form_submission_id', 'form_field_id', diff --git a/api/app/Models/FormBuilder/FormValueOption.php b/api/app/Models/FormBuilder/FormValueOption.php index 540e8e92..726cdb91 100644 --- a/api/app/Models/FormBuilder/FormValueOption.php +++ b/api/app/Models/FormBuilder/FormValueOption.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -19,6 +20,18 @@ final class FormValueOption extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + // 2 hops: FormValue → FormSubmission → organisation_id + return ['via' => FormValue::class, 'fk' => 'form_value_id']; + } + public $timestamps = false; protected $fillable = [ diff --git a/api/app/Models/FormBuilder/FormWebhookDelivery.php b/api/app/Models/FormBuilder/FormWebhookDelivery.php index 315da84f..654a5b57 100644 --- a/api/app/Models/FormBuilder/FormWebhookDelivery.php +++ b/api/app/Models/FormBuilder/FormWebhookDelivery.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; use App\Enums\FormBuilder\FormWebhookDeliveryStatus; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -19,6 +20,17 @@ final class FormWebhookDelivery extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => FormSubmission::class, 'fk' => 'form_submission_id']; + } + public $timestamps = false; protected $fillable = [ diff --git a/api/app/Models/PersonIdentityMatch.php b/api/app/Models/PersonIdentityMatch.php index c2ab5e60..f9934494 100644 --- a/api/app/Models/PersonIdentityMatch.php +++ b/api/app/Models/PersonIdentityMatch.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Enums\IdentityMatchConfidence; use App\Enums\IdentityMatchMethod; use App\Enums\IdentityMatchStatus; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,6 +19,20 @@ final class PersonIdentityMatch extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + // Person uses the legacy event_id column strategy via + // $organisationScopeColumn — the recursive walker follows that + // bridge automatically. + return ['via' => Person::class, 'fk' => 'person_id']; + } + public const UPDATED_AT = null; protected $fillable = [ diff --git a/api/app/Models/PersonSectionPreference.php b/api/app/Models/PersonSectionPreference.php index d631d8ed..5b25d7d6 100644 --- a/api/app/Models/PersonSectionPreference.php +++ b/api/app/Models/PersonSectionPreference.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -12,6 +13,20 @@ final class PersonSectionPreference extends Model { use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + // FestivalSection uses the legacy festival_section_id column + // strategy internally, but its own scope column is 'organisation_id' + // via the default. Chain resolves correctly. + return ['via' => FestivalSection::class, 'fk' => 'festival_section_id']; + } + public $timestamps = false; protected $fillable = [ diff --git a/api/app/Models/Scopes/OrganisationScope.php b/api/app/Models/Scopes/OrganisationScope.php index 02e2c5a5..957c4dfa 100644 --- a/api/app/Models/Scopes/OrganisationScope.php +++ b/api/app/Models/Scopes/OrganisationScope.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\Scopes; +use App\Exceptions\TenantScopeResolutionException; use App\Models\Event; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -13,17 +14,37 @@ use Illuminate\Database\Eloquent\Scope; * Global scope that filters models by organisation_id. * * Resolution order: - * 1. Explicitly provided organisation ID (constructor) - * 2. Route parameter 'organisation' (object or string) - * 3. Skip scope if no context (CLI, queue jobs, unauthenticated) + * 1. Explicitly provided organisation ID (constructor) + * 2. Route parameter 'organisation' (object or string) + * 3. Skip scope if no context (CLI, queue jobs, unauthenticated) * - * Models declare their scoping strategy via the $organisationScopeColumn property: - * - 'organisation_id' (default) — model has a direct organisation_id column - * - 'event_id' — model is scoped through events.organisation_id - * - 'festival_section_id' — model is scoped through festival_sections.event_id → events.organisation_id + * Two strategies are supported, discovered per-model: + * + * (a) Declarative FK-chain (addendum Q2): + * The model declares a `tenantScopeStrategy()` static method returning + * either: + * ['column' => 'organisation_id'] — direct scope column, OR + * ['via' => ParentModel::class, 'fk' => 'parent_id'] — walk to parent + * The resolver recurses through parents until it hits a column strategy + * or the legacy $organisationScopeColumn bridge. Cap at 3 hops; deeper + * chains raise TenantScopeResolutionException. + * + * (b) Legacy column property (pre-addendum — still supported so Person, + * Event, FestivalSection, etc. need not migrate in this work package): + * `public string $organisationScopeColumn = 'organisation_id' + * | 'event_id' + * | 'festival_section_id';` + * The resolver treats this as a terminal column-based strategy. + * + * The recursive walker accepts EITHER shape at every hop, so a model using + * the new tenantScopeStrategy() can chain through a parent using the legacy + * $organisationScopeColumn (e.g. PersonIdentityMatch → Person → event_id → + * organisation_id), and vice versa. */ final class OrganisationScope implements Scope { + private const MAX_CHAIN_HOPS = 3; + public function __construct( private readonly ?string $organisationId = null, ) {} @@ -36,25 +57,100 @@ final class OrganisationScope implements Scope return; } + $this->applyStrategy($builder, $model, $id, 0); + } + + /** + * Apply a tenant filter by walking the scope strategy declared on + * $model down to the first column-based strategy. + */ + private function applyStrategy(Builder $builder, Model $model, string $orgId, int $hops): void + { + if ($hops > self::MAX_CHAIN_HOPS) { + throw TenantScopeResolutionException::tooDeep($model::class, self::MAX_CHAIN_HOPS); + } + + $strategy = $this->strategyFor($model); + + if (isset($strategy['column'])) { + $this->applyColumnFilter($builder, $model, (string) $strategy['column'], $orgId); + + return; + } + + if (isset($strategy['via'], $strategy['fk'])) { + /** @var class-string $parentClass */ + $parentClass = $strategy['via']; + $fk = (string) $strategy['fk']; + + $parentInstance = new $parentClass(); + $parentTable = $parentInstance->getTable(); + $parentKey = $parentInstance->getKeyName(); + + // Build the subquery constraining the parent by the SAME + // recursive strategy walk — parent may itself be chained. + $parentQuery = $parentClass::query() + ->withoutGlobalScope(self::class) + ->select("$parentTable.$parentKey"); + + $this->applyStrategy($parentQuery, $parentInstance, $orgId, $hops + 1); + + $builder->whereIn($model->getTable().'.'.$fk, $parentQuery); + + return; + } + + throw TenantScopeResolutionException::invalidStrategy($model::class); + } + + /** + * Discover the scope strategy for a model. Accepts: + * - tenantScopeStrategy() static method returning ['column'=>…] or ['via'=>…,'fk'=>…] + * - Legacy $organisationScopeColumn instance property + * @return array{column?: string, via?: class-string, fk?: string} + */ + private function strategyFor(Model $model): array + { + if (method_exists($model, 'tenantScopeStrategy')) { + /** @var array $declared */ + $declared = $model::tenantScopeStrategy(); + + return $declared; + } + + // Legacy bridge — existing column-scoped models continue to work + // without migrating to tenantScopeStrategy() in this work package. $column = $model->organisationScopeColumn ?? 'organisation_id'; + return ['column' => $column]; + } + + /** + * Terminal column filter. `organisation_id` is a direct where; the + * legacy `event_id` / `festival_section_id` strategies expand to the + * same subquery pattern the old implementation used. + */ + private function applyColumnFilter(Builder $builder, Model $model, string $column, string $orgId): void + { + $table = $model->getTable(); + match ($column) { - 'organisation_id' => $builder->where($model->getTable() . '.organisation_id', $id), + 'organisation_id' => $builder->where("$table.organisation_id", $orgId), 'event_id' => $builder->whereIn( - $model->getTable() . '.event_id', + "$table.event_id", Event::withoutGlobalScope(self::class) - ->where('organisation_id', $id) + ->where('organisation_id', $orgId) ->select('id') ), 'festival_section_id' => $builder->whereIn( - $model->getTable() . '.festival_section_id', + "$table.festival_section_id", \App\Models\FestivalSection::withoutGlobalScope(self::class) ->whereIn('event_id', Event::withoutGlobalScope(self::class) - ->where('organisation_id', $id) + ->where('organisation_id', $orgId) ->select('id')) ->select('id') ), - default => null, + default => throw TenantScopeResolutionException::invalidStrategy($model::class), }; } diff --git a/api/app/Models/ShiftAssignment.php b/api/app/Models/ShiftAssignment.php index 5acc44db..cd02fea4 100644 --- a/api/app/Models/ShiftAssignment.php +++ b/api/app/Models/ShiftAssignment.php @@ -6,6 +6,7 @@ namespace App\Models; use App\Enums\CancellationSource; use App\Enums\ShiftAssignmentStatus; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,6 +20,18 @@ final class ShiftAssignment extends Model use HasUlids; use SoftDeletes; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + // Chain: Shift → FestivalSection (legacy column strategy) → Event → org + return ['via' => Shift::class, 'fk' => 'shift_id']; + } + protected $fillable = [ 'shift_id', 'person_id', diff --git a/api/app/Models/ShiftWaitlist.php b/api/app/Models/ShiftWaitlist.php index 0c6fc671..050185be 100644 --- a/api/app/Models/ShiftWaitlist.php +++ b/api/app/Models/ShiftWaitlist.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -14,6 +15,17 @@ final class ShiftWaitlist extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + return ['via' => Shift::class, 'fk' => 'shift_id']; + } + public $timestamps = false; protected $table = 'shift_waitlist'; diff --git a/api/app/Models/VolunteerAvailability.php b/api/app/Models/VolunteerAvailability.php index b82f7af4..f3800d44 100644 --- a/api/app/Models/VolunteerAvailability.php +++ b/api/app/Models/VolunteerAvailability.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -14,6 +15,20 @@ final class VolunteerAvailability extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + + /** @return array{via: class-string, fk: string} */ + public static function tenantScopeStrategy(): array + { + // Table has no direct event_id column — chain via TimeSlot, which + // carries the legacy event_id $organisationScopeColumn bridge. + // The recursive walker resolves TimeSlot → event_id → organisation_id. + return ['via' => TimeSlot::class, 'fk' => 'time_slot_id']; + } + public $timestamps = false; protected $table = 'volunteer_availabilities'; diff --git a/api/tests/Feature/FormBuilder/FormSchemaApiTest.php b/api/tests/Feature/FormBuilder/FormSchemaApiTest.php index 62d5727d..69ee9275 100644 --- a/api/tests/Feature/FormBuilder/FormSchemaApiTest.php +++ b/api/tests/Feature/FormBuilder/FormSchemaApiTest.php @@ -121,7 +121,13 @@ final class FormSchemaApiTest extends TestCase public function test_publish_sets_is_published_true(): void { Sanctum::actingAs($this->admin); - $schema = FormSchema::factory()->create(['organisation_id' => $this->org->id, 'is_published' => false]); + // USER_PROFILE has no required bindings, so publishing an empty + // schema is allowed — keeps this test orthogonal to the purpose + // binding check (that behaviour is covered separately by + // PurposeSchemaLifecycleTest). + $schema = FormSchema::factory() + ->forPurpose(FormPurpose::USER_PROFILE) + ->create(['organisation_id' => $this->org->id, 'is_published' => false]); $this->postJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/publish") ->assertOk() diff --git a/api/tests/Feature/FormBuilder/MultiTenancyTest.php b/api/tests/Feature/FormBuilder/MultiTenancyTest.php index 7d48a9c6..3a38bcc7 100644 --- a/api/tests/Feature/FormBuilder/MultiTenancyTest.php +++ b/api/tests/Feature/FormBuilder/MultiTenancyTest.php @@ -19,10 +19,10 @@ use Tests\TestCase; * Verifies OrganisationScope discipline: * - FormSchema, FormTemplate, FormFieldLibrary apply OrganisationScope and * are filtered when an organisation route parameter is in scope. - * - FormSchemaWebhook intentionally does NOT apply OrganisationScope (its - * scope is enforced via the parent FormSchema). The docblock on the - * model warns callers; this test pins the current behaviour so a future - * refactor that adds the scope is an intentional decision. + * - FormSchemaWebhook now applies OrganisationScope via the FK-chain + * strategy (addendum Q2 / WS-4 Commit 3). Direct queries are tenant- + * safe by default; withoutGlobalScope() is required for admin-wide + * queries. */ final class MultiTenancyTest extends TestCase { @@ -74,7 +74,7 @@ final class MultiTenancyTest extends TestCase $this->assertSame(1, FormFieldLibrary::query()->count()); } - public function test_form_schema_webhook_is_not_globally_scoped(): void + public function test_form_schema_webhook_is_scoped_via_fk_chain(): void { $schemaA = FormSchema::factory()->create(['organisation_id' => $this->orgA->id]); $schemaB = FormSchema::factory()->create(['organisation_id' => $this->orgB->id]); @@ -82,13 +82,18 @@ final class MultiTenancyTest extends TestCase FormSchemaWebhook::factory()->count(3)->create(['form_schema_id' => $schemaB->id]); $this->withRouteParameter('organisation', $this->orgA); - // Direct queries leak across orgs — exact reason the docblock warns - // never to query FormSchemaWebhook::query() without an eager constraint. - $this->assertSame(5, FormSchemaWebhook::query()->count()); + // Addendum Q2 / WS-4 Commit 3: FK-chain scope now filters direct + // queries by the route's organisation via form_schemas. + $this->assertSame(2, FormSchemaWebhook::query()->count()); - // Going through the schema relation respects OrganisationScope on the parent. - $this->assertCount(2, $schemaA->fresh()->webhooks); - $this->assertCount(3, $schemaB->fresh()->webhooks); + $this->withRouteParameter('organisation', $this->orgB); + $this->assertSame(3, FormSchemaWebhook::query()->count()); + + // Admin-wide lookups still work via withoutGlobalScope(). + $this->assertSame( + 5, + FormSchemaWebhook::withoutGlobalScope(OrganisationScope::class)->count() + ); } private function actingAsOrgUser(Organisation $org): void diff --git a/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php b/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php new file mode 100644 index 00000000..434f6ea0 --- /dev/null +++ b/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php @@ -0,0 +1,520 @@ +orgA = Organisation::factory()->create(); + $this->orgB = Organisation::factory()->create(); + + $this->eventA = Event::factory()->for($this->orgA)->create(); + $this->eventB = Event::factory()->for($this->orgB)->create(); + + $this->sectionA = FestivalSection::factory()->for($this->eventA)->create(); + $this->sectionB = FestivalSection::factory()->for($this->eventB)->create(); + + $this->timeSlotA = TimeSlot::factory()->for($this->eventA)->create(); + $this->timeSlotB = TimeSlot::factory()->for($this->eventB)->create(); + + $this->shiftA = Shift::factory()->for($this->sectionA, 'festivalSection')->for($this->timeSlotA, 'timeSlot')->create(); + $this->shiftB = Shift::factory()->for($this->sectionB, 'festivalSection')->for($this->timeSlotB, 'timeSlot')->create(); + + $crowdA = CrowdType::factory()->for($this->orgA)->create(); + $crowdB = CrowdType::factory()->for($this->orgB)->create(); + + $this->personA = Person::factory()->for($this->eventA)->for($crowdA)->create(); + $this->personB = Person::factory()->for($this->eventB)->for($crowdB)->create(); + + $this->schemaA = FormSchema::factory()->for($this->orgA)->create(); + $this->schemaB = FormSchema::factory()->for($this->orgB)->create(); + + $this->submissionA = FormSubmission::factory()->for($this->schemaA, 'schema')->create(); + $this->submissionB = FormSubmission::factory()->for($this->schemaB, 'schema')->create(); + } + + private function withOrgRoute(Organisation $org): void + { + $route = new Route(['GET'], '/_test', static fn () => null); + $route->bind(request()); + $route->setParameter('organisation', $org); + request()->setRouteResolver(static fn () => $route); + } + + // ------------------------------------------------------------------ + // D-03: Nine form-builder child models + // ------------------------------------------------------------------ + + public function test_form_schema_section_scope_blocks_cross_org(): void + { + FormSchemaSection::factory()->for($this->schemaA, 'schema')->create(); + FormSchemaSection::factory()->for($this->schemaB, 'schema')->create(); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormSchemaSection::query()->count()); + $this->assertSame(2, FormSchemaSection::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_field_scope_blocks_cross_org(): void + { + FormField::factory()->for($this->schemaA, 'schema')->create(); + FormField::factory()->for($this->schemaB, 'schema')->create(); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormField::query()->count()); + $this->assertSame(2, FormField::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_submission_scope_blocks_cross_org_via_denormalized_column(): void + { + // Commit 2 added organisation_id as a direct column — this model + // uses the `column` strategy, not `via`. Proves the column + // strategy still works alongside the FK-chain. + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormSubmission::query()->count()); + $this->assertSame(2, FormSubmission::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_value_scope_blocks_cross_org(): void + { + $fieldA = FormField::factory()->for($this->schemaA, 'schema')->create(); + $fieldB = FormField::factory()->for($this->schemaB, 'schema')->create(); + + FormValue::create([ + 'form_submission_id' => $this->submissionA->id, + 'form_field_id' => $fieldA->id, + 'value' => ['value' => 'a'], + 'value_anonymised' => false, + ]); + FormValue::create([ + 'form_submission_id' => $this->submissionB->id, + 'form_field_id' => $fieldB->id, + 'value' => ['value' => 'b'], + 'value_anonymised' => false, + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormValue::query()->count()); + $this->assertSame(2, FormValue::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_value_option_scope_handles_three_hop_chain(): void + { + // Deepest real chain in the codebase: + // FormValueOption → FormValue → FormSubmission → organisation_id. + $fieldA = FormField::factory()->for($this->schemaA, 'schema')->create([ + 'field_type' => FormFieldType::MULTISELECT->value, + ]); + $fieldB = FormField::factory()->for($this->schemaB, 'schema')->create([ + 'field_type' => FormFieldType::MULTISELECT->value, + ]); + + $valueA = FormValue::create([ + 'form_submission_id' => $this->submissionA->id, + 'form_field_id' => $fieldA->id, + 'value' => ['x'], + 'value_anonymised' => false, + ]); + $valueB = FormValue::create([ + 'form_submission_id' => $this->submissionB->id, + 'form_field_id' => $fieldB->id, + 'value' => ['y'], + 'value_anonymised' => false, + ]); + + FormValueOption::create([ + 'form_value_id' => $valueA->id, + 'form_field_id' => $fieldA->id, + 'form_submission_id' => $this->submissionA->id, + 'option_value' => 'x', + ]); + FormValueOption::create([ + 'form_value_id' => $valueB->id, + 'form_field_id' => $fieldB->id, + 'form_submission_id' => $this->submissionB->id, + 'option_value' => 'y', + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame( + 1, + FormValueOption::query()->count(), + 'Three-hop chain must filter: FormValueOption → FormValue → FormSubmission → organisation_id' + ); + $this->assertSame(2, FormValueOption::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_submission_section_status_scope_blocks_cross_org(): void + { + $sectionA = FormSchemaSection::factory()->for($this->schemaA, 'schema')->create(); + $sectionB = FormSchemaSection::factory()->for($this->schemaB, 'schema')->create(); + + FormSubmissionSectionStatus::factory()->create([ + 'form_submission_id' => $this->submissionA->id, + 'form_schema_section_id' => $sectionA->id, + ]); + FormSubmissionSectionStatus::factory()->create([ + 'form_submission_id' => $this->submissionB->id, + 'form_schema_section_id' => $sectionB->id, + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormSubmissionSectionStatus::query()->count()); + $this->assertSame(2, FormSubmissionSectionStatus::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_submission_delegation_scope_blocks_cross_org(): void + { + $userA = User::factory()->create(); + $userB = User::factory()->create(); + + FormSubmissionDelegation::create([ + 'form_submission_id' => $this->submissionA->id, + 'delegated_to_user_id' => $userA->id, + 'delegated_by_user_id' => $userA->id, + 'granted_at' => now(), + ]); + FormSubmissionDelegation::create([ + 'form_submission_id' => $this->submissionB->id, + 'delegated_to_user_id' => $userB->id, + 'delegated_by_user_id' => $userB->id, + 'granted_at' => now(), + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormSubmissionDelegation::query()->count()); + $this->assertSame(2, FormSubmissionDelegation::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_schema_webhook_scope_blocks_cross_org(): void + { + FormSchemaWebhook::factory()->for($this->schemaA, 'schema')->create(); + FormSchemaWebhook::factory()->for($this->schemaB, 'schema')->create(); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormSchemaWebhook::query()->count()); + $this->assertSame(2, FormSchemaWebhook::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_form_webhook_delivery_scope_blocks_cross_org(): void + { + $webhookA = FormSchemaWebhook::factory()->for($this->schemaA, 'schema')->create(); + $webhookB = FormSchemaWebhook::factory()->for($this->schemaB, 'schema')->create(); + + FormWebhookDelivery::create([ + 'form_schema_webhook_id' => $webhookA->id, + 'form_submission_id' => $this->submissionA->id, + 'trigger_event' => 'submission.submitted', + 'status' => FormWebhookDeliveryStatus::PENDING->value, + 'attempts' => 0, + 'payload_snapshot' => ['x' => 1], + ]); + FormWebhookDelivery::create([ + 'form_schema_webhook_id' => $webhookB->id, + 'form_submission_id' => $this->submissionB->id, + 'trigger_event' => 'submission.submitted', + 'status' => FormWebhookDeliveryStatus::PENDING->value, + 'attempts' => 0, + 'payload_snapshot' => ['y' => 1], + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, FormWebhookDelivery::query()->count()); + $this->assertSame(2, FormWebhookDelivery::withoutGlobalScope(OrganisationScope::class)->count()); + } + + // ------------------------------------------------------------------ + // D-04 event-data subset: five models + // ------------------------------------------------------------------ + + public function test_shift_assignment_scope_blocks_cross_org(): void + { + ShiftAssignment::factory() + ->for($this->shiftA) + ->for($this->personA) + ->for($this->timeSlotA) + ->create(); + ShiftAssignment::factory() + ->for($this->shiftB) + ->for($this->personB) + ->for($this->timeSlotB) + ->create(); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, ShiftAssignment::query()->count()); + $this->assertSame(2, ShiftAssignment::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_shift_waitlist_scope_blocks_cross_org(): void + { + ShiftWaitlist::create([ + 'shift_id' => $this->shiftA->id, + 'person_id' => $this->personA->id, + 'position' => 1, + 'added_at' => now(), + ]); + ShiftWaitlist::create([ + 'shift_id' => $this->shiftB->id, + 'person_id' => $this->personB->id, + 'position' => 1, + 'added_at' => now(), + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, ShiftWaitlist::query()->count()); + $this->assertSame(2, ShiftWaitlist::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_volunteer_availability_scope_blocks_cross_org(): void + { + VolunteerAvailability::create([ + 'person_id' => $this->personA->id, + 'time_slot_id' => $this->timeSlotA->id, + 'preference_level' => 3, + 'submitted_at' => now(), + ]); + VolunteerAvailability::create([ + 'person_id' => $this->personB->id, + 'time_slot_id' => $this->timeSlotB->id, + 'preference_level' => 3, + 'submitted_at' => now(), + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, VolunteerAvailability::query()->count()); + $this->assertSame(2, VolunteerAvailability::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_person_section_preference_scope_blocks_cross_org(): void + { + PersonSectionPreference::create([ + 'person_id' => $this->personA->id, + 'festival_section_id' => $this->sectionA->id, + 'priority' => 1, + ]); + PersonSectionPreference::create([ + 'person_id' => $this->personB->id, + 'festival_section_id' => $this->sectionB->id, + 'priority' => 1, + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, PersonSectionPreference::query()->count()); + $this->assertSame(2, PersonSectionPreference::withoutGlobalScope(OrganisationScope::class)->count()); + } + + public function test_person_identity_match_scope_blocks_cross_org(): void + { + $matchedUserA = User::factory()->create(); + $matchedUserB = User::factory()->create(); + + PersonIdentityMatch::create([ + 'person_id' => $this->personA->id, + 'matched_user_id' => $matchedUserA->id, + 'matched_on' => IdentityMatchMethod::EMAIL, + 'confidence' => IdentityMatchConfidence::HIGH, + 'status' => IdentityMatchStatus::PENDING, + 'match_details' => [], + ]); + PersonIdentityMatch::create([ + 'person_id' => $this->personB->id, + 'matched_user_id' => $matchedUserB->id, + 'matched_on' => IdentityMatchMethod::EMAIL, + 'confidence' => IdentityMatchConfidence::HIGH, + 'status' => IdentityMatchStatus::PENDING, + 'match_details' => [], + ]); + + $this->withOrgRoute($this->orgA); + $this->assertSame(1, PersonIdentityMatch::query()->count()); + $this->assertSame(2, PersonIdentityMatch::withoutGlobalScope(OrganisationScope::class)->count()); + } + + // ------------------------------------------------------------------ + // Resolver mechanics: legacy-bridge compatibility + hop limit + // ------------------------------------------------------------------ + + public function test_resolver_accepts_legacy_organisation_scope_column_bridge(): void + { + // PersonIdentityMatch → Person. Person uses the legacy + // $organisationScopeColumn = 'event_id' bridge (not + // tenantScopeStrategy()). The recursive resolver must treat it + // as a terminal column strategy. + $this->withOrgRoute($this->orgA); + $this->assertInstanceOf( + PersonIdentityMatch::class, + PersonIdentityMatch::query()->getModel(), + 'Scope must boot on models whose parent uses the legacy property' + ); + + // Actually prove the chain works end-to-end: one row per org, + // scoped query returns only the owned row. + $matchedUserA = User::factory()->create(); + $matchedUserB = User::factory()->create(); + + PersonIdentityMatch::create([ + 'person_id' => $this->personA->id, + 'matched_user_id' => $matchedUserA->id, + 'matched_on' => IdentityMatchMethod::EMAIL, + 'confidence' => IdentityMatchConfidence::HIGH, + 'status' => IdentityMatchStatus::PENDING, + 'match_details' => [], + ]); + PersonIdentityMatch::create([ + 'person_id' => $this->personB->id, + 'matched_user_id' => $matchedUserB->id, + 'matched_on' => IdentityMatchMethod::EMAIL, + 'confidence' => IdentityMatchConfidence::HIGH, + 'status' => IdentityMatchStatus::PENDING, + 'match_details' => [], + ]); + $this->assertSame(1, PersonIdentityMatch::query()->count()); + } + + public function test_resolver_raises_on_over_deep_chain(): void + { + // Construct a synthetic 5-hop chain by subclassing on the fly: + // A → B → C → D → E (column). The resolver caps at 3 hops and + // must raise TenantScopeResolutionException. + $fiveHopModel = new class extends Model { + protected $table = 'form_value_options'; + public static function tenantScopeStrategy(): array + { + return ['via' => FiveHopLevel2::class, 'fk' => 'form_value_id']; + } + }; + + $scope = new OrganisationScope('01HZ01HZ01HZ01HZ01HZ01HZ01'); + $builder = $fiveHopModel::query(); + + $this->expectException(TenantScopeResolutionException::class); + $scope->apply($builder, $fiveHopModel); + } +} + +/** + * Synthetic chain model for the over-deep-chain test — four levels + * above a terminal column, so resolver walks past its cap. + */ +final class FiveHopLevel2 extends Model +{ + protected $table = 'form_values'; + + public static function tenantScopeStrategy(): array + { + return ['via' => FiveHopLevel3::class, 'fk' => 'form_submission_id']; + } +} + +final class FiveHopLevel3 extends Model +{ + protected $table = 'form_submissions'; + + public static function tenantScopeStrategy(): array + { + return ['via' => FiveHopLevel4::class, 'fk' => 'form_schema_id']; + } +} + +final class FiveHopLevel4 extends Model +{ + protected $table = 'form_schemas'; + + public static function tenantScopeStrategy(): array + { + return ['via' => FiveHopLevel5::class, 'fk' => 'organisation_id']; + } +} + +final class FiveHopLevel5 extends Model +{ + protected $table = 'organisations'; + + public static function tenantScopeStrategy(): array + { + return ['column' => 'id']; + } +} diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index c54d71bf..7198b1c3 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -992,6 +992,50 @@ update-request. Rejects keys not in whitelist with 422. --- +### 4.14 Multi-tenancy scope chain + +Per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 §Q2, form-builder child tables +resolve tenancy through their parent via the declarative +`tenantScopeStrategy()` method on each model. `OrganisationScope`'s +resolver walks parents recursively (max 3 hops) until it reaches a +column-based strategy (direct `organisation_id`, or a legacy +`$organisationScopeColumn` bridge like `event_id` or +`festival_section_id`). + +The chains for the nine form-builder child models are: + +| Model | Strategy | Hops to org | +|-------|----------|-------------| +| `FormSubmission` | `column: organisation_id` (denormalized, §4.3) | 0 | +| `FormSchema` | `column: organisation_id` (existing) | 0 | +| `FormSchemaSection` | `via: FormSchema, fk: form_schema_id` | 1 | +| `FormField` | `via: FormSchema, fk: form_schema_id` | 1 | +| `FormSchemaWebhook` | `via: FormSchema, fk: form_schema_id` | 1 | +| `FormSubmissionSectionStatus` | `via: FormSubmission, fk: form_submission_id` | 1 | +| `FormSubmissionDelegation` | `via: FormSubmission, fk: form_submission_id` | 1 | +| `FormWebhookDelivery` | `via: FormSubmission, fk: form_submission_id` | 1 | +| `FormValue` | `via: FormSubmission, fk: form_submission_id` | 1 | +| `FormValueOption` | `via: FormValue, fk: form_value_id` → FormSubmission → `organisation_id` | 2 | + +The same work package extended scope coverage to five event-data models +outside the form-builder domain: + +| Model | Strategy | Notes | +|-------|----------|-------| +| `ShiftAssignment` | `via: Shift, fk: shift_id` | Shift uses legacy `festival_section_id` bridge | +| `ShiftWaitlist` | `via: Shift, fk: shift_id` | — | +| `VolunteerAvailability` | `via: TimeSlot, fk: time_slot_id` | TimeSlot uses legacy `event_id` bridge | +| `PersonSectionPreference` | `via: FestivalSection, fk: festival_section_id` | FestivalSection uses legacy `event_id` bridge | +| `PersonIdentityMatch` | `via: Person, fk: person_id` | Person uses legacy `event_id` bridge | + +Callers that need cross-org queries (public form endpoints, admin +dashboards, anonymisation retention jobs) must use +`->withoutGlobalScope(OrganisationScope::class)` explicitly — see +`PublicFormSchemaResource::toArray()` for the canonical pattern on +`loadMissing(['fields' => fn ($q) => $q->withoutGlobalScope(...)])`. + +--- + ## 5. FormFieldType catalogue ### 5.1 Built-in types diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index a1990e12..807e4294 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1654,6 +1654,17 @@ Business tables AND pure pivot tables: `$table->ulid('id')->primary()` + (on mod - Use Laravel policies — never direct id-checks in controllers - **v1.7:** For festival queries, use `scopeWithChildren()` to include parent + all sub-events - **Audit log:** Spatie `laravel-activitylog` on: `persons`, `accreditation_assignments`, `shift_assignments`, `check_ins`, `production_requests` +- **v2.2 (addendum Q2):** `OrganisationScope` supports a declarative `tenantScopeStrategy()` convention on the model for FK-chain scoping: + ```php + public static function tenantScopeStrategy(): array + { + return ['column' => 'organisation_id']; // direct + // OR + return ['via' => FormSchema::class, 'fk' => 'form_schema_id']; // chain + } + ``` + The resolver walks parents recursively (max 3 hops — `TenantScopeResolutionException` beyond that) and accepts parents using either `tenantScopeStrategy()` or the legacy `$organisationScopeColumn` property. Link: [`/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md`](./ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md) §Q2. +- **v2.2 (D-03 + D-04 subset):** fourteen new registrations — nine form-builder child models (`FormSchemaSection`, `FormField`, `FormValue`, `FormValueOption`, `FormSubmission`, `FormSubmissionSectionStatus`, `FormSubmissionDelegation`, `FormSchemaWebhook`, `FormWebhookDelivery`) + five event-data models (`ShiftAssignment`, `ShiftWaitlist`, `VolunteerAvailability`, `PersonSectionPreference`, `PersonIdentityMatch`). See `ARCH-FORM-BUILDER.md` §4.14 for the chain shapes. The D-04 user/admin subset (MFA / TrustedDevice / UserProfile / EmailLog / OrganisationEmailSettings) is per-model backlog, not WS-4 scope. ---