Files
crewli/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php
bert.hausmans b688ec26f0 feat(scope): declarative FK-chain strategy for OrganisationScope, register on 14 models per addendum Q2 + D-03/D-04
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>
2026-04-24 17:08:33 +02:00

521 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\MultiTenancy;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
use App\Enums\IdentityMatchConfidence;
use App\Enums\IdentityMatchMethod;
use App\Enums\IdentityMatchStatus;
use App\Exceptions\TenantScopeResolutionException;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSchemaWebhook;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionDelegation;
use App\Models\FormBuilder\FormSubmissionSectionStatus;
use App\Models\FormBuilder\FormValue;
use App\Models\FormBuilder\FormValueOption;
use App\Models\FormBuilder\FormWebhookDelivery;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\PersonIdentityMatch;
use App\Models\PersonSectionPreference;
use App\Models\Scopes\OrganisationScope;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\ShiftWaitlist;
use App\Models\TimeSlot;
use App\Models\User;
use App\Models\VolunteerAvailability;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Route;
use Tests\TestCase;
/**
* WS-4 Commit 3 coverage — addendum Q2 FK-chain scope registration.
*
* Two fully-populated tenants (orgA, orgB); for each of the 14 newly-
* registered models, a query scoped to orgA must never surface orgB
* rows. `withoutGlobalScope` must continue to expose everything for
* admin-wide lookups. The 3-hop max and deepest real chain
* (FormValueOption → FormValue → FormSubmission → organisation_id)
* are exercised explicitly.
*/
final class ScopeLeakageTest extends TestCase
{
use RefreshDatabase;
private Organisation $orgA;
private Organisation $orgB;
private Event $eventA;
private Event $eventB;
private FormSchema $schemaA;
private FormSchema $schemaB;
private FormSubmission $submissionA;
private FormSubmission $submissionB;
private Person $personA;
private Person $personB;
private FestivalSection $sectionA;
private FestivalSection $sectionB;
private Shift $shiftA;
private Shift $shiftB;
private TimeSlot $timeSlotA;
private TimeSlot $timeSlotB;
protected function setUp(): void
{
parent::setUp();
$this->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'];
}
}