TenantAtlas/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php
ahmido 1f3619bd16 feat: tenant-owned query canon and wrong-tenant guards (#180)
## 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
2026-03-18 08:33:13 +00:00

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;
}
}