283 lines
12 KiB
PHP
283 lines
12 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\EvidenceSnapshotResource;
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
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\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
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.',
|
|
],
|
|
'FindingException' => [
|
|
'table' => 'finding_exceptions',
|
|
'model' => FindingException::class,
|
|
'resource' => FindingExceptionResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'disabled',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'FindingExceptionResource declares its action surface contract directly.',
|
|
'notes' => 'Finding exceptions stay off global search in the first rollout.',
|
|
],
|
|
'EvidenceSnapshot' => [
|
|
'table' => 'evidence_snapshots',
|
|
'model' => EvidenceSnapshot::class,
|
|
'resource' => EvidenceSnapshotResource::class,
|
|
'tenant_relationship' => 'tenant',
|
|
'search_posture' => 'disabled',
|
|
'action_surface' => 'declared',
|
|
'action_surface_reason' => 'EvidenceSnapshotResource declares its action surface contract directly.',
|
|
'notes' => 'Evidence snapshots stay off global search until broader evidence discovery is introduced.',
|
|
],
|
|
'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.',
|
|
],
|
|
'FindingExceptionDecision' => [
|
|
'table' => 'finding_exception_decisions',
|
|
'likely_surface' => 'FindingExceptionResource decision history entries',
|
|
'why_not_in_first_slice' => 'Decision history is subordinate to the finding exception aggregate instead of a standalone primary resource.',
|
|
],
|
|
'FindingExceptionEvidenceReference' => [
|
|
'table' => 'finding_exception_evidence_references',
|
|
'likely_surface' => 'FindingExceptionResource evidence sections',
|
|
'why_not_in_first_slice' => 'Evidence references are subordinate support records rendered inside finding exception detail.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|