fix(form-builder): explicit OrganisationScope bypass on every public-form query

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>
This commit is contained in:
2026-04-17 23:16:22 +02:00
parent 68d2c830a0
commit 6ba921442c
4 changed files with 194 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ use App\Http\Resources\FormBuilder\PublicFormSchemaResource;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\FormBuilder\FormSchema;
use App\Models\Scopes\OrganisationScope;
use App\Models\TimeSlot;
use App\Services\FormBuilder\PublicFormTokenResolver;
use Illuminate\Http\JsonResponse;
@@ -45,10 +46,14 @@ final class PublicFormController extends Controller
$eventIds = $this->festivalEventIds($event);
// OrganisationScope explicitly bypassed — public endpoint has no
// {organisation} route param so the scope would no-op today, but
// relying on that is accidentally-correct.
$slots = TimeSlot::query()
->withoutGlobalScope(OrganisationScope::class)
->whereIn('event_id', $eventIds)
->where('person_type', 'VOLUNTEER')
->with(['event:id,name'])
->with(['event' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class)->select('id', 'name')])
->orderBy('date')
->orderBy('start_time')
->get();
@@ -77,7 +82,9 @@ final class PublicFormController extends Controller
$eventIds = $this->festivalEventIds($event);
// OrganisationScope explicitly bypassed — see timeSlots().
$sections = FestivalSection::query()
->withoutGlobalScope(OrganisationScope::class)
->whereIn('event_id', $eventIds)
->where('show_in_registration', true)
->where('type', 'standard')
@@ -109,7 +116,9 @@ final class PublicFormController extends Controller
return null;
}
return Event::withoutGlobalScopes()->find($schema->owner_id);
return Event::query()
->withoutGlobalScope(OrganisationScope::class)
->find($schema->owner_id);
}
/**
@@ -122,7 +131,9 @@ final class PublicFormController extends Controller
*/
private function festivalEventIds(Event $event): array
{
// OrganisationScope explicitly bypassed — see timeSlots().
$childIds = Event::query()
->withoutGlobalScope(OrganisationScope::class)
->where('parent_event_id', $event->id)
->pluck('id')
->map(fn ($id) => (string) $id)

View File

@@ -8,6 +8,7 @@ use App\Enums\FormBuilder\FormFieldType;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\PersonTag;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -100,7 +101,10 @@ final class PublicFormSchemaResource extends JsonResource
return [];
}
$rows = PersonTag::withoutGlobalScopes()
// Named-scope bypass only — don't unintentionally strip future
// soft-delete or is_active scopes if any land later.
$rows = PersonTag::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $organisationId)
->where('is_active', true)
->orderBy('sort_order')

View File

@@ -7,6 +7,7 @@ namespace App\Services\FormBuilder;
use App\Exceptions\FormBuilder\SchemaNotFoundException;
use App\Exceptions\FormBuilder\TokenExpiredException;
use App\Models\FormBuilder\FormSchema;
use App\Models\Scopes\OrganisationScope;
/**
* Token-to-schema resolution for every /public/forms/* endpoint.
@@ -15,6 +16,13 @@ use App\Models\FormBuilder\FormSchema;
*
* Throws the standardised public-form exceptions directly so callers
* don't need to branch on grace state themselves.
*
* Org scope is explicitly bypassed here: public_token is globally unique
* across the platform, and the public routes have no `{organisation}`
* route parameter to drive OrganisationScope. Relying on the scope to
* no-op silently works today but is accidentally-correct any future
* middleware that sets an org context (impersonation, platform admin
* visibility toggle, etc.) would start filtering public resolutions.
*/
final class PublicFormTokenResolver
{
@@ -23,6 +31,7 @@ final class PublicFormTokenResolver
public function resolve(string $token): FormSchema
{
$current = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('public_token', $token)
->first();
if ($current !== null) {
@@ -30,6 +39,7 @@ final class PublicFormTokenResolver
}
$previous = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('public_token_previous', $token)
->first();