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>
62 lines
1.9 KiB
PHP
62 lines
1.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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.
|
|
* Centralises the 7-day grace window logic (ARCH §10, BACKLOG FORM-04
|
|
* tracks making this configurable).
|
|
*
|
|
* 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
|
|
{
|
|
private const GRACE_DAYS = 7;
|
|
|
|
public function resolve(string $token): FormSchema
|
|
{
|
|
$current = FormSchema::query()
|
|
->withoutGlobalScope(OrganisationScope::class)
|
|
->where('public_token', $token)
|
|
->first();
|
|
if ($current !== null) {
|
|
return $current;
|
|
}
|
|
|
|
$previous = FormSchema::query()
|
|
->withoutGlobalScope(OrganisationScope::class)
|
|
->where('public_token_previous', $token)
|
|
->first();
|
|
|
|
if ($previous === null) {
|
|
throw new SchemaNotFoundException;
|
|
}
|
|
|
|
$rotatedAt = $previous->public_token_rotated_at;
|
|
if ($rotatedAt === null) {
|
|
return $previous;
|
|
}
|
|
|
|
if ($rotatedAt->addDays(self::GRACE_DAYS)->isPast()) {
|
|
throw new TokenExpiredException;
|
|
}
|
|
|
|
return $previous;
|
|
}
|
|
}
|