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) <noreply@anthropic.com>
Five models that the public form endpoints touch carry a global
OrganisationScope: FormSchema, Event, TimeSlot, FestivalSection,
PersonTag. The initial S2c implementation relied on the scope no-opping
because /public/forms/* has no `{organisation}` route parameter and
OrganisationScope::resolveOrganisationId returns null in that case.
That's accidentally-correct. Any middleware that sets an implicit org
context later (route model binding for platform admin, impersonation,
default-org fallback on an authed Sanctum session) would start
filtering public schema resolution by the wrong org.
- PublicFormTokenResolver: both FormSchema::query() calls now pass
withoutGlobalScope(OrganisationScope::class). public_token is
globally unique so this is safe.
- PublicFormController::timeSlots() / sections() / festivalEventIds():
Event, TimeSlot, FestivalSection queries all explicit now, including
the eager-loaded event relation on time-slots.
- PublicFormController::ownerEvent(): narrowed from
Event::withoutGlobalScopes() to withoutGlobalScope(OrganisationScope)
so future scopes (soft-delete, archived) aren't accidentally
stripped.
- PublicFormSchemaResource::availableTagsByCategory: same narrowing on
the PersonTag query.
PublicFormCrossOrgScopeTest pins the expectation — 4 cases hit every
public endpoint under a stashed foreign-org route parameter and assert
the owner-org data still surfaces. Verified the tests fail when the
fix is reverted (all 4 return `SCHEMA_NOT_FOUND` with the bypass
absent).
Full suite 893 → 897 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D1, D5, D7.
PublicFormSchemaResource:
- Carries available_tags on every TAG_PICKER field, respecting
validation_rules.tag_categories when set. Tags prefetched once per
org to avoid N+1.
- Surfaces the schema version and an opened_at timestamp so the portal
can detect drift between GET-time and submit-time.
PublicFormSubmissionResource (new): the submission envelope the portal
sees on every lifecycle endpoint. No PII echo — public_submitter_name,
public_submitter_email, public_submitter_ip never appear. Admin
metadata (review_status, reviewed_by, schema_snapshot, submitter user
id) is filtered out. Includes schema_drift computed from
schema.version vs schema_version_at_submit, and identity_match read
from the §31.1 listener's column with a Dutch message per state
(pending|matched|none).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of S2b. Nine resources that shape the universal form builder
responses. FieldAccessService::filterVisibleFields gates every field
array — the primary defence tested by FormResourceSecurityTest (§22.9).
- FormSchemaResource: includes fields_count, submissions_count,
has_submissions, is_locked (derived from edit_lock_*), public_form_url
when public_token is set, and filtered fields collection.
- FormSchemaSummaryResource: lean list-endpoint variant.
- FormFieldResource: effective_label / help_text / options resolved via
FormLocaleResolver + translations JSON, plus TAG_PICKER available_tags
filtered by validation_rules.tag_categories.
- FormSubmissionResource: values keyed by field slug with FieldAccessService
filtering, section_statuses, active delegations, review_info,
submitted_in_locale, submission_duration_seconds.
- FormSubmissionSummaryResource: lean list variant.
- FormTemplateResource, FormFieldLibraryResource.
- PublicFormSchemaResource: strictly limited per §10 — only
is_portal_visible=true AND is_admin_only=false fields, no PII hints,
no role_restrictions, no submissions_count.
- FormSchemaWebhookResource: url/secret never returned; only url_host +
has_secret boolean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>