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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user