## 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
249 lines
10 KiB
PHP
249 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\WorkspaceIsolation;
|
|
|
|
use App\Filament\Resources\BackupScheduleResource;
|
|
use App\Filament\Resources\BackupSetResource;
|
|
use App\Filament\Resources\EntraGroupResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Filament\Resources\InventoryItemResource;
|
|
use App\Filament\Resources\PolicyResource;
|
|
use App\Filament\Resources\PolicyVersionResource;
|
|
use App\Filament\Resources\RestoreRunResource;
|
|
use App\Models\BackupSchedule;
|
|
use App\Models\BackupSet;
|
|
use App\Models\EntraGroup;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\Policy;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\RestoreRun;
|
|
|
|
final class TenantOwnedModelFamilies
|
|
{
|
|
/**
|
|
* @return array<string, array{
|
|
* table: string,
|
|
* model: class-string,
|
|
* resource: class-string,
|
|
* tenant_relationship: string,
|
|
* search_posture: 'scoped'|'disabled'|'not_applicable',
|
|
* action_surface: 'declared'|'baseline_exemption',
|
|
* action_surface_reason: string,
|
|
* notes: string
|
|
* }>
|
|
*/
|
|
public static function firstSlice(): array
|
|
{
|
|
return [
|
|
'Policy' => [
|
|
'table' => 'policies',
|
|
'model' => Policy::class,
|
|
'resource' => PolicyResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'disabled',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'PolicyResource declares its action surface contract directly.',
|
|
'notes' => 'Policy search remains disabled until list/detail parity is fully migrated.',
|
|
],
|
|
'PolicyVersion' => [
|
|
'table' => 'policy_versions',
|
|
'model' => PolicyVersion::class,
|
|
'resource' => PolicyVersionResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'disabled',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'PolicyVersionResource declares its action surface contract directly.',
|
|
'notes' => 'Policy version search remains disabled until parity is guaranteed.',
|
|
],
|
|
'BackupSchedule' => [
|
|
'table' => 'backup_schedules',
|
|
'model' => BackupSchedule::class,
|
|
'resource' => BackupScheduleResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'not_applicable',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'BackupScheduleResource declares its action surface contract directly.',
|
|
'notes' => 'Backup schedules are not part of global search.',
|
|
],
|
|
'BackupSet' => [
|
|
'table' => 'backup_sets',
|
|
'model' => BackupSet::class,
|
|
'resource' => BackupSetResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'not_applicable',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'BackupSetResource declares its action surface contract directly.',
|
|
'notes' => 'Backup sets are not part of global search.',
|
|
],
|
|
'RestoreRun' => [
|
|
'table' => 'restore_runs',
|
|
'model' => RestoreRun::class,
|
|
'resource' => RestoreRunResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'not_applicable',
|
|
'action_surface' => 'baseline_exemption',
|
|
'action_surface_reason' => 'Restore run resource retrofit is deferred to the restore track and remains explicitly exempt in the action-surface baseline.',
|
|
'notes' => 'Restore runs are not part of global search.',
|
|
],
|
|
'Finding' => [
|
|
'table' => 'findings',
|
|
'model' => Finding::class,
|
|
'resource' => FindingResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'not_applicable',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'FindingResource declares its action surface contract directly.',
|
|
'notes' => 'Findings are not part of global search in the first slice.',
|
|
],
|
|
'InventoryItem' => [
|
|
'table' => 'inventory_items',
|
|
'model' => InventoryItem::class,
|
|
'resource' => InventoryItemResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'not_applicable',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'InventoryItemResource declares its action surface contract directly.',
|
|
'notes' => 'Inventory items stay off global search.',
|
|
],
|
|
'EntraGroup' => [
|
|
'table' => 'entra_groups',
|
|
'model' => EntraGroup::class,
|
|
'resource' => EntraGroupResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'scoped',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'EntraGroupResource declares its action surface contract directly.',
|
|
'notes' => 'Directory groups already support tenant-safe global search.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{table: string, likely_surface: string, why_not_in_first_slice: string}>
|
|
*/
|
|
public static function residualRolloutInventory(): array
|
|
{
|
|
return [
|
|
'BackupItem' => [
|
|
'table' => 'backup_items',
|
|
'likely_surface' => 'BackupSetResource::BackupItemsRelationManager',
|
|
'why_not_in_first_slice' => 'Covered through the backup-set relation manager rather than a standalone primary resource.',
|
|
],
|
|
'InventoryLink' => [
|
|
'table' => 'inventory_links',
|
|
'likely_surface' => 'InventoryItemResource related-links affordances',
|
|
'why_not_in_first_slice' => 'Inventory links are subordinate navigation metadata and inherit tenant scope through inventory items.',
|
|
],
|
|
'EntraRoleDefinition' => [
|
|
'table' => 'entra_role_definitions',
|
|
'likely_surface' => 'Entra admin-role reporting and findings reference flows',
|
|
'why_not_in_first_slice' => 'Read paths remain indirect via reporting and findings surfaces, so direct tenant-owned resource parity is deferred.',
|
|
],
|
|
'TenantPermission' => [
|
|
'table' => 'tenant_permissions',
|
|
'likely_surface' => 'Permissions and onboarding diagnostics surfaces',
|
|
'why_not_in_first_slice' => 'Permission posture is enforced through dedicated diagnostics and onboarding flows, not a first-slice primary resource.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public static function names(): array
|
|
{
|
|
return array_keys(self::firstSlice());
|
|
}
|
|
|
|
/**
|
|
* @return array{table: string, model: class-string, resource: class-string, tenant_relationship: string, search_posture: 'scoped'|'disabled'|'not_applicable', action_surface: 'declared'|'baseline_exemption', action_surface_reason: string, notes: string}|null
|
|
*/
|
|
public static function forModel(string $modelClass): ?array
|
|
{
|
|
foreach (self::firstSlice() as $family) {
|
|
if ($family['model'] === $modelClass) {
|
|
return $family;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static function searchPostureForModel(string $modelClass): ?string
|
|
{
|
|
return self::forModel($modelClass)['search_posture'] ?? null;
|
|
}
|
|
|
|
public static function supportsScopedGlobalSearch(string $modelClass): bool
|
|
{
|
|
return self::searchPostureForModel($modelClass) === 'scoped';
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{exception_kind: string, why_excepted: string, still_required_checks: list<string>}>
|
|
*/
|
|
public static function scopeExceptions(): array
|
|
{
|
|
return [
|
|
'ProviderConnectionResource' => [
|
|
'exception_kind' => 'workspace_admin_canonical_viewer',
|
|
'why_excepted' => 'Workspace-admin tenant-default surface referencing tenant-owned data without being part of the mandatory first-slice canon.',
|
|
'still_required_checks' => [
|
|
'workspace membership',
|
|
'remembered tenant entitlement',
|
|
'capability gating on the destination action',
|
|
],
|
|
],
|
|
'OperationRunResource' => [
|
|
'exception_kind' => 'workspace_owned_reference_surface',
|
|
'why_excepted' => 'Workspace-owned canonical monitoring surface that may deep-link into tenant-owned records only after entitlement checks.',
|
|
'still_required_checks' => [
|
|
'workspace membership',
|
|
'tenant entitlement on deep links',
|
|
'record-owner congruence before rendering tenant-owned destinations',
|
|
],
|
|
],
|
|
'AlertDeliveryResource' => [
|
|
'exception_kind' => 'deferred_family',
|
|
'why_excepted' => 'Mixed workspace-owned and tenant-bound semantics keep this surface outside the mandatory tenant-owned family set for the first slice.',
|
|
'still_required_checks' => [
|
|
'workspace membership',
|
|
'tenant capability checks for tenant-bound mutations',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function explicitScopeExceptions(): array
|
|
{
|
|
return array_map(
|
|
static fn (array $exception): string => $exception['why_excepted'],
|
|
self::scopeExceptions(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<class-string, string>
|
|
*/
|
|
public static function actionSurfaceBaselineExemptions(): array
|
|
{
|
|
$exemptions = [];
|
|
|
|
foreach (self::firstSlice() as $family) {
|
|
if ($family['action_surface'] !== 'baseline_exemption') {
|
|
continue;
|
|
}
|
|
|
|
$exemptions[$family['resource']] = $family['action_surface_reason'];
|
|
}
|
|
|
|
return $exemptions;
|
|
}
|
|
}
|