## Summary - introduce a shared tenant-owned query and record-resolution canon for first-slice Filament resources - harden direct views, row actions, bulk actions, relation managers, and workspace-admin canonical viewers against wrong-tenant access - add registry-backed rollout metadata, search posture handling, architectural guards, and focused Pest coverage for scope parity and 404/403 semantics ## Included - Spec 150 package under `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/` - shared support classes: `TenantOwnedModelFamilies`, `TenantOwnedQueryScope`, `TenantOwnedRecordResolver` - shared Filament concern: `InteractsWithTenantOwnedRecords` - resource/page/policy hardening across findings, policies, policy versions, backup schedules, backup sets, restore runs, inventory items, and Entra groups - additional regression coverage for canonical tenant state, wrong-tenant record resolution, relation-manager congruence, and action-surface guardrails ## Validation - `vendor/bin/sail artisan test --compact` passed - full suite result: `2733 passed, 8 skipped` - formatting applied with `vendor/bin/sail bin pint --dirty --format agent` ## Notes - Livewire v4.0+ compliant via existing Filament v5 stack - provider registration remains in `bootstrap/providers.php` - globally searchable first-slice posture: Entra groups scoped; policies and policy versions explicitly disabled - destructive actions continue to use confirmation and policy authorization - no new Filament assets added; existing deployment flow remains unchanged, including `php artisan filament:assets` when registered assets are used Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #180
109 lines
4.4 KiB
PHP
109 lines
4.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
|
use App\Support\WorkspaceIsolation\TenantOwnedTables;
|
|
|
|
function tenantOwnedFamilySource(string $className): string
|
|
{
|
|
$reflection = new ReflectionClass($className);
|
|
$fileName = $reflection->getFileName();
|
|
|
|
expect($fileName)->toBeString();
|
|
|
|
$contents = file_get_contents((string) $fileName);
|
|
|
|
expect($contents)->not->toBeFalse();
|
|
|
|
return (string) $contents;
|
|
}
|
|
|
|
it('keeps the first-slice tenant-owned family inventory aligned to tenant-owned tables', function (): void {
|
|
$allowedSearchPostures = ['scoped', 'disabled', 'not_applicable'];
|
|
|
|
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
|
|
expect(TenantOwnedTables::contains($family['table']))
|
|
->toBeTrue("{$familyName} must point at a known tenant-owned table.");
|
|
|
|
expect(TenantOwnedTables::firstSlice())
|
|
->toContain($family['table']);
|
|
|
|
expect($allowedSearchPostures)
|
|
->toContain($family['search_posture']);
|
|
|
|
expect(class_exists($family['model']))->toBeTrue();
|
|
expect(class_exists($family['resource']))->toBeTrue();
|
|
}
|
|
});
|
|
|
|
it('keeps first-slice family names unique and explicit', function (): void {
|
|
$names = TenantOwnedModelFamilies::names();
|
|
|
|
expect($names)->not->toBeEmpty();
|
|
expect(array_unique($names))->toBe($names);
|
|
});
|
|
|
|
it('keeps the first-slice family registry exhaustive for declared first-slice tenant-owned tables', function (): void {
|
|
$familyTables = array_map(
|
|
static fn (array $family): string => $family['table'],
|
|
TenantOwnedModelFamilies::firstSlice(),
|
|
);
|
|
|
|
expect($familyTables)->toEqualCanonicalizing(TenantOwnedTables::firstSlice());
|
|
});
|
|
|
|
it('keeps the residual tenant-owned rollout inventory aligned to non-first-slice tenant-owned tables', function (): void {
|
|
$residualTables = array_map(
|
|
static fn (array $entry): string => $entry['table'],
|
|
TenantOwnedModelFamilies::residualRolloutInventory(),
|
|
);
|
|
|
|
expect($residualTables)->toEqualCanonicalizing(TenantOwnedTables::residual());
|
|
|
|
foreach (TenantOwnedModelFamilies::residualRolloutInventory() as $familyName => $entry) {
|
|
expect(trim($familyName))->not->toBe('');
|
|
expect(trim($entry['likely_surface']))->not->toBe('');
|
|
expect(trim($entry['why_not_in_first_slice']))->not->toBe('');
|
|
}
|
|
});
|
|
|
|
it('requires first-slice resources to use the shared tenant-owned query and record resolver entry points', function (): void {
|
|
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
|
|
$resourceClass = $family['resource'];
|
|
$traits = class_uses_recursive($resourceClass);
|
|
$source = tenantOwnedFamilySource($resourceClass);
|
|
|
|
expect(in_array(InteractsWithTenantOwnedRecords::class, $traits, true))
|
|
->toBeTrue("{$familyName} must use the shared tenant-owned resource trait.");
|
|
|
|
expect(preg_match('/public\s+static\s+function\s+getEloquentQuery\s*\(/', $source) === 1)
|
|
->toBeTrue("{$familyName} must expose an explicit scoped query entry point.");
|
|
|
|
expect(preg_match('/static::(?:getTenantOwnedEloquentQuery|scopeTenantOwnedQuery)\s*\(/', $source) === 1)
|
|
->toBeTrue("{$familyName} must derive records from the canonical tenant-owned query helper.");
|
|
|
|
expect(preg_match('/public\s+static\s+function\s+resolveScopedRecordOrFail\s*\(/', $source) === 1)
|
|
->toBeTrue("{$familyName} must expose an explicit scoped-record resolver.");
|
|
|
|
expect(str_contains($source, 'resolveTenantOwnedRecordOrFail'))
|
|
->toBeTrue("{$familyName} must resolve detail records through the shared tenant-owned resolver.");
|
|
}
|
|
});
|
|
|
|
it('documents explicit scope exceptions with non-empty reasons', function (): void {
|
|
$exceptions = TenantOwnedModelFamilies::explicitScopeExceptions();
|
|
$exceptionMetadata = TenantOwnedModelFamilies::scopeExceptions();
|
|
|
|
expect($exceptions)->not->toBeEmpty();
|
|
expect(array_keys($exceptionMetadata))->toEqual(array_keys($exceptions));
|
|
|
|
foreach ($exceptions as $surfaceName => $reason) {
|
|
expect($surfaceName)->not->toBe('');
|
|
expect(trim($reason))->not->toBe('');
|
|
expect($exceptionMetadata[$surfaceName]['exception_kind'])->not->toBe('');
|
|
expect($exceptionMetadata[$surfaceName]['still_required_checks'])->not->toBeEmpty();
|
|
}
|
|
});
|