From a098ca2f16d1082adb63e9c2426136ceac84f3b5 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 18 Mar 2026 09:30:13 +0100 Subject: [PATCH] feat: implement tenant-owned query canon guards --- .github/agents/copilot-instructions.md | 3 +- .../InteractsWithTenantOwnedRecords.php | 72 +++++ .../Concerns/ResolvesPanelTenantContext.php | 7 +- .../Concerns/ScopesGlobalSearchToTenant.php | 5 + .../Resources/BackupScheduleResource.php | 33 ++- .../Pages/EditBackupSchedule.php | 11 +- .../Pages/ListBackupSchedules.php | 34 ++- ...upScheduleOperationRunsRelationManager.php | 45 +++- app/Filament/Resources/BackupSetResource.php | 13 +- .../BackupSetResource/Pages/ViewBackupSet.php | 6 + .../BackupItemsRelationManager.php | 98 ++++++- app/Filament/Resources/EntraGroupResource.php | 34 +-- .../Pages/ViewEntraGroup.php | 6 + app/Filament/Resources/FindingResource.php | 56 +++- .../FindingResource/Pages/ListFindings.php | 45 ++-- .../FindingResource/Pages/ViewFinding.php | 6 + .../Resources/InventoryItemResource.php | 12 +- .../Pages/ViewInventoryItem.php | 6 + app/Filament/Resources/PolicyResource.php | 21 +- .../PolicyResource/Pages/ListPolicies.php | 18 ++ .../PolicyResource/Pages/ViewPolicy.php | 6 + .../VersionsRelationManager.php | 39 ++- .../Resources/PolicyVersionResource.php | 38 ++- .../Pages/ViewPolicyVersion.php | 6 + app/Filament/Resources/RestoreRunResource.php | 63 ++++- .../Pages/ListRestoreRuns.php | 30 +++ .../Pages/ViewRestoreRun.php | 6 + app/Policies/BackupSchedulePolicy.php | 75 ++++-- app/Policies/EntraGroupPolicy.php | 15 +- app/Policies/FindingPolicy.php | 56 ++-- app/Support/OperateHub/OperateHubShell.php | 5 + .../ActionSurface/ActionSurfaceExemptions.php | 7 +- .../TenantOwnedModelFamilies.php | 248 ++++++++++++++++++ .../TenantOwnedQueryScope.php | 25 ++ .../TenantOwnedRecordResolver.php | 34 +++ .../WorkspaceIsolation/TenantOwnedTables.php | 45 +++- docs/product/spec-candidates.md | 9 - .../admin-canonical-tenant-rollout.md | 2 +- routes/web.php | 6 + .../checklists/requirements.md | 36 +++ .../tenant-owned-query-canon.openapi.yaml | 140 ++++++++++ .../data-model.md | 105 ++++++++ .../plan.md | 130 +++++++++ .../quickstart.md | 45 ++++ .../research.md | 50 ++++ .../spec.md | 186 +++++++++++++ .../tasks.md | 187 +++++++++++++ ...ckupScheduleLifecycleAuthorizationTest.php | 54 ++++ ...heduleOperationRunsRelationManagerTest.php | 81 ++++++ .../CanonicalAdminTenantFilterStateTest.php | 28 ++ .../Filament/EntraGroupAdminScopeTest.php | 45 ++++ .../PolicyResourceAdminSearchParityTest.php | 26 ++ .../PolicyVersionAdminSearchParityTest.php | 29 ++ .../PolicyVersionAdminTenantParityTest.php | 34 +++ .../Filament/TenantMakeCurrentTest.php | 3 + .../TenantOwnedResourceScopeParityTest.php | 246 +++++++++++++++++ .../Findings/FindingAdminTenantParityTest.php | 31 +++ tests/Feature/Findings/FindingRbacTest.php | 19 ++ .../Guards/ActionSurfaceContractTest.php | 49 ++++ .../Guards/AdminTenantResolverGuardTest.php | 13 +- .../FilamentTableStandardsGuardTest.php | 8 + .../NoAdHocFilamentAuthPatternsTest.php | 23 ++ .../Guards/TenantOwnedQueryGuardTest.php | 108 ++++++++ .../AdminGlobalSearchContextSafetyTest.php | 55 ++++ .../AdminTenantOwnedPolicyContextTest.php | 167 ++++++++++++ ...ackupItemsRelationManagerSemanticsTest.php | 35 +++ ...pItemsRelationManagerUiEnforcementTest.php | 43 +++ ...InventoryItemResourceAuthorizationTest.php | 54 ++++ ...rsionsRestoreToIntuneUiEnforcementTest.php | 34 +++ .../RunAuthorizationTenantIsolationTest.php | 39 +++ .../TenantOwnedQueryScopeTest.php | 111 ++++++++ 71 files changed, 3264 insertions(+), 196 deletions(-) create mode 100644 app/Filament/Concerns/InteractsWithTenantOwnedRecords.php create mode 100644 app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php create mode 100644 app/Support/WorkspaceIsolation/TenantOwnedQueryScope.php create mode 100644 app/Support/WorkspaceIsolation/TenantOwnedRecordResolver.php create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/checklists/requirements.md create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/contracts/tenant-owned-query-canon.openapi.yaml create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/data-model.md create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/plan.md create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/quickstart.md create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/research.md create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/spec.md create mode 100644 specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/tasks.md create mode 100644 tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php create mode 100644 tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php create mode 100644 tests/Feature/Guards/TenantOwnedQueryGuardTest.php create mode 100644 tests/Feature/Rbac/AdminTenantOwnedPolicyContextTest.php create mode 100644 tests/Unit/Support/WorkspaceIsolation/TenantOwnedQueryScopeTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index e0326fc..e43c3d4 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -87,6 +87,7 @@ ## Active Technologies - PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy) - PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization) - PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization) +- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards) - PHP 8.4.15 (feat/005-bulk-operations) @@ -106,8 +107,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 150-tenant-owned-query-canon-and-wrong-tenant-guards: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 - 149-queued-execution-reauthorization: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams - 148-central-tenant-operability-policy: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` -- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4 diff --git a/app/Filament/Concerns/InteractsWithTenantOwnedRecords.php b/app/Filament/Concerns/InteractsWithTenantOwnedRecords.php new file mode 100644 index 0000000..7173916 --- /dev/null +++ b/app/Filament/Concerns/InteractsWithTenantOwnedRecords.php @@ -0,0 +1,72 @@ +apply( + $query, + $tenant ?? static::resolveTenantContextForTenantOwnedRecords(), + static::tenantOwnedRelationshipName(), + ); + } + + protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model + { + $scopedQuery = static::scopeTenantOwnedQuery( + $query ?? parent::getEloquentQuery(), + $tenant, + ); + + return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record); + } + + protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model + { + $scopedQuery = static::scopeTenantOwnedQuery( + $query ?? parent::getEloquentQuery(), + $tenant, + ); + + return app(TenantOwnedRecordResolver::class)->resolveOrFail($scopedQuery, $record); + } +} diff --git a/app/Filament/Concerns/ResolvesPanelTenantContext.php b/app/Filament/Concerns/ResolvesPanelTenantContext.php index b9d2ca1..ed88a32 100644 --- a/app/Filament/Concerns/ResolvesPanelTenantContext.php +++ b/app/Filament/Concerns/ResolvesPanelTenantContext.php @@ -14,7 +14,7 @@ trait ResolvesPanelTenantContext protected static function resolveTenantContextForCurrentPanel(): ?Tenant { if (Filament::getCurrentPanel()?->getId() === 'admin') { - $tenant = app(OperateHubShell::class)->activeEntitledTenant(request()); + $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); return $tenant instanceof Tenant ? $tenant : null; } @@ -24,6 +24,11 @@ protected static function resolveTenantContextForCurrentPanel(): ?Tenant return $tenant instanceof Tenant ? $tenant : null; } + public static function panelTenantContext(): ?Tenant + { + return static::resolveTenantContextForCurrentPanel(); + } + protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant { $tenant = static::resolveTenantContextForCurrentPanel(); diff --git a/app/Filament/Concerns/ScopesGlobalSearchToTenant.php b/app/Filament/Concerns/ScopesGlobalSearchToTenant.php index e30f70d..3934aa3 100644 --- a/app/Filament/Concerns/ScopesGlobalSearchToTenant.php +++ b/app/Filament/Concerns/ScopesGlobalSearchToTenant.php @@ -6,6 +6,7 @@ use App\Models\Tenant; use App\Support\OperateHub\OperateHubShell; +use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -21,6 +22,10 @@ public static function getGlobalSearchEloquentQuery(): Builder { $query = static::getModel()::query(); + if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) { + return $query->whereRaw('1 = 0'); + } + if (! static::isScopedToTenant()) { $panel = Filament::getCurrentOrDefaultPanel(); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 71f0b6f..d8460c9 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources; use App\Exceptions\InvalidPolicyTypeException; +use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Resources\BackupScheduleResource\Pages; use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; @@ -64,6 +65,7 @@ class BackupScheduleResource extends Resource { + use InteractsWithTenantOwnedRecords; use ResolvesPanelTenantContext; protected static ?string $model = BackupSchedule::class; @@ -581,6 +583,8 @@ public static function table(Table $table): Table ->color('danger') ->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { + $record = static::resolveProtectedScheduleRecordOrFail($record); + Gate::authorize('delete', $record); if ($record->trashed()) { @@ -622,6 +626,8 @@ public static function table(Table $table): Table ->color('success') ->visible(fn (BackupSchedule $record): bool => $record->trashed()) ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { + $record = static::resolveProtectedScheduleRecordOrFail($record); + Gate::authorize('restore', $record); if (! $record->trashed()) { @@ -662,6 +668,8 @@ public static function table(Table $table): Table ->color('danger') ->visible(fn (BackupSchedule $record): bool => $record->trashed()) ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { + $record = static::resolveProtectedScheduleRecordOrFail($record); + Gate::authorize('forceDelete', $record); if (! $record->trashed()) { @@ -919,17 +927,32 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey(); - - return parent::getEloquentQuery() - ->where('tenant_id', $tenantId) + return static::getTenantOwnedEloquentQuery() ->orderByDesc('is_enabled') ->orderBy('next_run_at'); } public static function getRecordRouteBindingEloquentQuery(): Builder { - return static::getEloquentQuery()->withTrashed(); + return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed()) + ->orderByDesc('is_enabled') + ->orderBy('next_run_at'); + } + + public static function resolveScopedRecordOrFail(int|string $key): Model + { + return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed()); + } + + protected static function resolveProtectedScheduleRecordOrFail(BackupSchedule|int|string $record): BackupSchedule + { + $resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record); + + if (! $resolvedRecord instanceof BackupSchedule) { + abort(404); + } + + return $resolvedRecord; } public static function getRelations(): array diff --git a/app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php b/app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php index 113f1a6..b433a97 100644 --- a/app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php +++ b/app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php @@ -5,7 +5,6 @@ use App\Filament\Resources\BackupScheduleResource; use Filament\Resources\Pages\EditRecord; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\ModelNotFoundException; class EditBackupSchedule extends EditRecord { @@ -13,15 +12,7 @@ class EditBackupSchedule extends EditRecord protected function resolveRecord(int|string $key): Model { - $record = BackupScheduleResource::getEloquentQuery() - ->withTrashed() - ->find($key); - - if ($record === null) { - throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]); - } - - return $record; + return BackupScheduleResource::resolveScopedRecordOrFail($key); } protected function mutateFormDataBeforeSave(array $data): array diff --git a/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php b/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php index c238542..f6cc205 100644 --- a/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php +++ b/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php @@ -5,18 +5,32 @@ use App\Filament\Resources\BackupScheduleResource; use App\Support\Filament\CanonicalAdminTenantFilterState; use Filament\Resources\Pages\ListRecords; +use Illuminate\Database\Eloquent\ModelNotFoundException; class ListBackupSchedules extends ListRecords { protected static string $resource = BackupScheduleResource::class; + /** + * @param array $arguments + * @param array $context + */ + public function mountAction(string $name, array $arguments = [], array $context = []): mixed + { + if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'restore', 'forceDelete'], true)) { + try { + BackupScheduleResource::resolveScopedRecordOrFail($context['recordKey']); + } catch (ModelNotFoundException) { + abort(404); + } + } + + return parent::mountAction($name, $arguments, $context); + } + public function mount(): void { - app(CanonicalAdminTenantFilterState::class)->sync( - $this->getTableFiltersSessionKey(), - request: request(), - tenantFilterName: null, - ); + $this->syncCanonicalAdminTenantFilterState(); parent::mount(); } @@ -40,4 +54,14 @@ private function tableHasRecords(): bool { return $this->getTableRecords()->count() > 0; } + + private function syncCanonicalAdminTenantFilterState(): void + { + app(CanonicalAdminTenantFilterState::class)->sync( + $this->getTableFiltersSessionKey(), + tenantSensitiveFilters: [], + request: request(), + tenantFilterName: null, + ); + } } diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php index f0ee175..5217bec 100644 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php @@ -12,6 +12,7 @@ use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; +use Closure; use Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -24,6 +25,19 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager protected static ?string $title = 'Executions'; + /** + * @param array $arguments + * @param array $context + */ + public function mountAction(string $name, array $arguments = [], array $context = []): mixed + { + if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) { + $this->resolveOwnerScopedOperationRun($context['recordKey']); + } + + return parent::mountAction($name, $arguments, $context); + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) @@ -48,7 +62,7 @@ public function table(Table $table): Table Tables\Columns\TextColumn::make('type') ->label('Type') - ->formatStateUsing([OperationCatalog::class, 'label']), + ->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])), Tables\Columns\TextColumn::make('status') ->badge() @@ -87,6 +101,7 @@ public function table(Table $table): Table ->label('View') ->icon('heroicon-o-eye') ->url(function (OperationRun $record): string { + $record = $this->resolveOwnerScopedOperationRun($record); $tenant = Tenant::currentOrFail(); return OperationRunLinks::view($record, $tenant); @@ -97,4 +112,32 @@ public function table(Table $table): Table ->emptyStateHeading('No schedule runs yet') ->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.'); } + + private function resolveOwnerScopedOperationRun(mixed $record): OperationRun + { + $recordId = $record instanceof OperationRun + ? (int) $record->getKey() + : (is_numeric($record) ? (int) $record : 0); + + if ($recordId <= 0) { + abort(404); + } + + $resolvedRecord = $this->getOwnerRecord() + ->operationRuns() + ->where('tenant_id', Tenant::currentOrFail()->getKey()) + ->whereKey($recordId) + ->first(); + + if (! $resolvedRecord instanceof OperationRun) { + abort(404); + } + + return $resolvedRecord; + } + + public static function formatOperationType(?string $state): string + { + return OperationCatalog::label($state); + } } diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 8ce91b8..069d16e 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; @@ -56,6 +57,7 @@ class BackupSetResource extends Resource { + use InteractsWithTenantOwnedRecords; use ResolvesPanelTenantContext; protected static ?string $model = BackupSet::class; @@ -120,13 +122,12 @@ public static function canCreate(): bool public static function getEloquentQuery(): Builder { - $tenant = static::resolveTenantContextForCurrentPanel(); + return static::getTenantOwnedEloquentQuery(); + } - if (! $tenant instanceof Tenant) { - return parent::getEloquentQuery()->whereRaw('1 = 0'); - } - - return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey()); + public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model + { + return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed()); } public static function form(Schema $schema): Schema diff --git a/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php b/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php index 45c8826..0d6d803 100644 --- a/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php +++ b/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php @@ -17,6 +17,7 @@ use Filament\Actions\ActionGroup; use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; +use Illuminate\Database\Eloquent\Model; class ViewBackupSet extends ViewRecord { @@ -24,6 +25,11 @@ class ViewBackupSet extends ViewRecord protected static string $resource = BackupSetResource::class; + protected function resolveRecord(int|string $key): Model + { + return BackupSetResource::resolveScopedRecordOrFail($key); + } + protected function getHeaderActions(): array { $actions = [ diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index fafba88..6ba9c74 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -43,6 +43,27 @@ public function closeAddPoliciesModal(): void $this->unmountAction(); } + /** + * @param array $arguments + * @param array $context + */ + public function mountAction(string $name, array $arguments = [], array $context = []): mixed + { + if (($context['table'] ?? false) === true) { + $backupSet = $this->getOwnerRecord(); + + if ($name === 'remove' && filled($context['recordKey'] ?? null)) { + $this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']); + } + + if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) { + $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords); + } + } + + return parent::mountAction($name, $arguments, $context); + } + public function table(Table $table): Table { $refreshTable = Actions\Action::make('refreshTable') @@ -77,7 +98,7 @@ public function table(Table $table): Table ->color('danger') ->icon('heroicon-o-x-mark') ->requiresConfirmation() - ->action(function (BackupItem $record): void { + ->action(function (mixed $record): void { $backupSet = $this->getOwnerRecord(); $user = auth()->user(); @@ -94,7 +115,7 @@ public function table(Table $table): Table abort(404); } - $backupItemIds = [(int) $record->getKey()]; + $backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)]; /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -173,14 +194,7 @@ public function table(Table $table): Table abort(404); } - $backupItemIds = $records - ->pluck('id') - ->map(fn (mixed $value): int => (int) $value) - ->filter(fn (int $value): bool => $value > 0) - ->unique() - ->sort() - ->values() - ->all(); + $backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords); if ($backupItemIds === []) { return; @@ -434,4 +448,68 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu return $query->whereIn('policy_type', $types); } + + private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int + { + $recordId = $this->normalizeBackupItemKey($record); + + if ($recordId <= 0) { + abort(404); + } + + $resolvedId = $backupSet->items() + ->where('tenant_id', (int) $backupSet->tenant_id) + ->whereKey($recordId) + ->value('id'); + + if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) { + abort(404); + } + + return (int) $resolvedId; + } + + /** + * @return array + */ + private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array + { + $requestedIds = collect($recordKeys) + ->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record)) + ->filter(fn (int $value): bool => $value > 0) + ->unique() + ->sort() + ->values() + ->all(); + + if ($requestedIds === []) { + return []; + } + + $resolvedIds = $backupSet->items() + ->where('tenant_id', (int) $backupSet->tenant_id) + ->whereIn('id', $requestedIds) + ->pluck('id') + ->map(fn (mixed $value): int => (int) $value) + ->filter(fn (int $value): bool => $value > 0) + ->unique() + ->sort() + ->values() + ->all(); + + if (count($resolvedIds) !== count($requestedIds)) { + abort(404); + } + + return $resolvedIds; + } + + private function normalizeBackupItemKey(mixed $record): int + { + if ($record instanceof BackupItem) { + return (int) $record->getKey(); + } + + return is_numeric($record) ? (int) $record : 0; + } } diff --git a/app/Filament/Resources/EntraGroupResource.php b/app/Filament/Resources/EntraGroupResource.php index eb954d0..cc26b85 100644 --- a/app/Filament/Resources/EntraGroupResource.php +++ b/app/Filament/Resources/EntraGroupResource.php @@ -2,6 +2,8 @@ namespace App\Filament\Resources; +use App\Filament\Concerns\InteractsWithTenantOwnedRecords; +use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ScopesGlobalSearchToTenant; use App\Filament\Resources\EntraGroupResource\Pages; use App\Models\EntraGroup; @@ -9,7 +11,6 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Filament\TablePaginationProfiles; -use App\Support\OperateHub\OperateHubShell; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -33,12 +34,16 @@ class EntraGroupResource extends Resource { + use InteractsWithTenantOwnedRecords; + use ResolvesPanelTenantContext; use ScopesGlobalSearchToTenant; protected static bool $isScopedToTenant = false; protected static ?string $model = EntraGroup::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static ?string $recordTitleAttribute = 'display_name'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group'; @@ -188,17 +193,15 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenant = static::panelTenantContext(); - - return parent::getEloquentQuery() - ->when( - $tenant instanceof Tenant, - fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenant->getKey()), - fn (Builder $query): Builder => $query->whereRaw('1 = 0'), - ) + return static::getTenantOwnedEloquentQuery() ->latest('id'); } + public static function resolveScopedRecordOrFail(int|string $key): Model + { + return static::resolveTenantOwnedRecordOrFail($key); + } + public static function getGlobalSearchResultUrl(Model $record): string { $tenant = $record instanceof EntraGroup && $record->tenant instanceof Tenant @@ -216,19 +219,6 @@ public static function getPages(): array ]; } - public static function panelTenantContext(): ?Tenant - { - if (Filament::getCurrentPanel()?->getId() === 'admin') { - $tenant = app(OperateHubShell::class)->activeEntitledTenant(request()); - - return $tenant instanceof Tenant ? $tenant : null; - } - - $tenant = Tenant::current(); - - return $tenant instanceof Tenant ? $tenant : null; - } - /** * @param array $parameters */ diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php b/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php index 2e6d20a..cfd821f 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php @@ -8,11 +8,17 @@ use App\Models\User; use Filament\Facades\Filament; use Filament\Resources\Pages\ViewRecord; +use Illuminate\Database\Eloquent\Model; class ViewEntraGroup extends ViewRecord { protected static string $resource = EntraGroupResource::class; + protected function resolveRecord(int|string $key): Model + { + return EntraGroupResource::resolveScopedRecordOrFail($key); + } + protected function authorizeAccess(): void { $tenant = EntraGroupResource::panelTenantContext(); diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index d97cb1f..5af9174 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Resources\FindingResource\Pages; use App\Models\Finding; @@ -55,6 +56,7 @@ class FindingResource extends Resource { + use InteractsWithTenantOwnedRecords; use ResolvesPanelTenantContext; protected static ?string $model = Finding::class; @@ -752,6 +754,7 @@ public static function table(Table $table): Table } try { + $record = static::resolveProtectedFindingRecordOrFail($record); $workflow->triage($record, $tenant, $user); $triagedCount++; } catch (Throwable) { @@ -832,6 +835,7 @@ public static function table(Table $table): Table } try { + $record = static::resolveProtectedFindingRecordOrFail($record); $workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId); $assignedCount++; } catch (Throwable) { @@ -906,6 +910,7 @@ public static function table(Table $table): Table } try { + $record = static::resolveProtectedFindingRecordOrFail($record); $workflow->resolve($record, $tenant, $user, $reason); $resolvedCount++; } catch (Throwable) { @@ -980,6 +985,7 @@ public static function table(Table $table): Table } try { + $record = static::resolveProtectedFindingRecordOrFail($record); $workflow->close($record, $tenant, $user, $reason); $closedCount++; } catch (Throwable) { @@ -1054,6 +1060,7 @@ public static function table(Table $table): Table } try { + $record = static::resolveProtectedFindingRecordOrFail($record); $workflow->riskAccept($record, $tenant, $user, $reason); $acceptedCount++; } catch (Throwable) { @@ -1089,12 +1096,19 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = static::resolveTenantContextForCurrentPanel()?->getKey(); - - return parent::getEloquentQuery() + return static::getTenantOwnedEloquentQuery() ->with(['assigneeUser', 'ownerUser', 'closedByUser']) - ->withSubjectDisplayName() - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + ->withSubjectDisplayName(); + } + + public static function resolveScopedRecordOrFail(int|string $key): Model + { + return static::resolveTenantOwnedRecordOrFail( + $key, + parent::getEloquentQuery() + ->with(['assigneeUser', 'ownerUser', 'closedByUser']) + ->withSubjectDisplayName(), + ); } /** @@ -1182,7 +1196,7 @@ public static function triageAction(): Actions\Action ->label('Triage') ->icon('heroicon-o-check') ->color('gray') - ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ + ->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, Finding::STATUS_ACKNOWLEDGED, @@ -1208,7 +1222,7 @@ public static function startProgressAction(): Actions\Action ->label('Start progress') ->icon('heroicon-o-play') ->color('info') - ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ + ->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [ Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED, ], true)) @@ -1233,7 +1247,7 @@ public static function assignAction(): Actions\Action ->label('Assign') ->icon('heroicon-o-user-plus') ->color('gray') - ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) + ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) ->fillForm(fn (Finding $record): array => [ 'assignee_user_id' => $record->assignee_user_id, 'owner_user_id' => $record->owner_user_id, @@ -1277,7 +1291,7 @@ public static function resolveAction(): Actions\Action ->label('Resolve') ->icon('heroicon-o-check-badge') ->color('success') - ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) + ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) ->requiresConfirmation() ->form([ Textarea::make('resolved_reason') @@ -1381,7 +1395,7 @@ public static function reopenAction(): Actions\Action ->icon('heroicon-o-arrow-uturn-left') ->color('warning') ->requiresConfirmation() - ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) + ->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record))) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, @@ -1401,6 +1415,7 @@ public static function reopenAction(): Actions\Action */ private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void { + $record = static::resolveProtectedFindingRecordOrFail($record); $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); @@ -1435,6 +1450,27 @@ private static function runWorkflowMutation(Finding $record, string $successTitl ->send(); } + private static function freshWorkflowRecord(Finding $record): Finding + { + return static::resolveProtectedFindingRecordOrFail($record); + } + + private static function freshWorkflowStatus(Finding $record): string + { + return (string) static::freshWorkflowRecord($record)->status; + } + + private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record): Finding + { + $resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record); + + if (! $resolvedRecord instanceof Finding) { + abort(404); + } + + return $resolvedRecord; + } + /** * @return array */ diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index 1f4a42c..5fecee2 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -23,6 +23,7 @@ use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Arr; use Throwable; @@ -32,14 +33,26 @@ class ListFindings extends ListRecords protected static string $resource = FindingResource::class; + /** + * @param array $arguments + * @param array $context + */ + public function mountAction(string $name, array $arguments = [], array $context = []): mixed + { + if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'assign', 'resolve', 'close', 'reopen'], true)) { + try { + FindingResource::resolveScopedRecordOrFail($context['recordKey']); + } catch (ModelNotFoundException) { + abort(404); + } + } + + return parent::mountAction($name, $arguments, $context); + } + public function mount(): void { - app(CanonicalAdminTenantFilterState::class)->sync( - $this->getTableFiltersSessionKey(), - tenantSensitiveFilters: ['scope_key', 'run_ids'], - request: request(), - tenantFilterName: null, - ); + $this->syncCanonicalAdminTenantFilterState(); parent::mount(); } @@ -246,15 +259,7 @@ protected function getHeaderActions(): array protected function buildAllMatchingQuery(): Builder { - $query = Finding::query(); - - $tenantId = static::resolveTenantContextForCurrentPanel()?->getKey(); - - if (! is_numeric($tenantId)) { - return $query->whereRaw('1 = 0'); - } - - $query->where('tenant_id', (int) $tenantId); + $query = FindingResource::getEloquentQuery(); $query->where('status', Finding::STATUS_NEW); @@ -304,6 +309,16 @@ protected function buildAllMatchingQuery(): Builder return $query; } + private function syncCanonicalAdminTenantFilterState(): void + { + app(CanonicalAdminTenantFilterState::class)->sync( + $this->getTableFiltersSessionKey(), + tenantSensitiveFilters: ['scope_key', 'run_ids'], + request: request(), + tenantFilterName: null, + ); + } + private function filterIsActive(string $filterName): bool { $state = $this->getTableFilterState($filterName); diff --git a/app/Filament/Resources/FindingResource/Pages/ViewFinding.php b/app/Filament/Resources/FindingResource/Pages/ViewFinding.php index 4353d2a..9b058d3 100644 --- a/app/Filament/Resources/FindingResource/Pages/ViewFinding.php +++ b/app/Filament/Resources/FindingResource/Pages/ViewFinding.php @@ -8,11 +8,17 @@ use Filament\Actions; use Filament\Resources\Pages\ViewRecord; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Model; class ViewFinding extends ViewRecord { protected static string $resource = FindingResource::class; + protected function resolveRecord(int|string $key): Model + { + return FindingResource::resolveScopedRecordOrFail($key); + } + protected function getHeaderActions(): array { return [ diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index c991c6b..2145081 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources; use App\Filament\Clusters\Inventory\InventoryCluster; +use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Resources\InventoryItemResource\Pages; use App\Models\InventoryItem; @@ -38,6 +39,7 @@ class InventoryItemResource extends Resource { + use InteractsWithTenantOwnedRecords; use ResolvesPanelTenantContext; protected static ?string $model = InventoryItem::class; @@ -334,13 +336,15 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = static::resolveTenantContextForCurrentPanel()?->getKey(); - - return parent::getEloquentQuery() - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + return static::getTenantOwnedEloquentQuery() ->with('lastSeenRun'); } + public static function resolveScopedRecordOrFail(int|string $key): Model + { + return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun')); + } + public static function getPages(): array { return [ diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php b/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php index 9e2d2fd..780f95c 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php @@ -4,8 +4,14 @@ use App\Filament\Resources\InventoryItemResource; use Filament\Resources\Pages\ViewRecord; +use Illuminate\Database\Eloquent\Model; class ViewInventoryItem extends ViewRecord { protected static string $resource = InventoryItemResource::class; + + protected function resolveRecord(int|string $key): Model + { + return InventoryItemResource::resolveScopedRecordOrFail($key); + } } diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 9a6a9b1..8d7337c 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -2,7 +2,9 @@ namespace App\Filament\Resources; +use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; +use App\Filament\Concerns\ScopesGlobalSearchToTenant; use App\Filament\Resources\PolicyResource\Pages; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Jobs\BulkPolicyDeleteJob; @@ -54,7 +56,9 @@ class PolicyResource extends Resource { + use InteractsWithTenantOwnedRecords; use ResolvesPanelTenantContext; + use ScopesGlobalSearchToTenant; protected static ?string $model = Policy::class; @@ -1010,16 +1014,25 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey(); - - return parent::getEloquentQuery() - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + return static::getTenantOwnedEloquentQuery() ->withCount('versions') ->with([ 'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1), ]); } + public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model + { + return static::resolveTenantOwnedRecordOrFail( + $key, + parent::getEloquentQuery() + ->withCount('versions') + ->with([ + 'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1), + ]), + ); + } + public static function getRelations(): array { return [ diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 3a442e1..ad9356f 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -3,12 +3,20 @@ namespace App\Filament\Resources\PolicyResource\Pages; use App\Filament\Resources\PolicyResource; +use App\Support\Filament\CanonicalAdminTenantFilterState; use Filament\Resources\Pages\ListRecords; class ListPolicies extends ListRecords { protected static string $resource = PolicyResource::class; + public function mount(): void + { + $this->syncCanonicalAdminTenantFilterState(); + + parent::mount(); + } + protected function getHeaderActions(): array { return [ @@ -22,4 +30,14 @@ protected function getTableEmptyStateActions(): array PolicyResource::makeSyncAction(), ]; } + + private function syncCanonicalAdminTenantFilterState(): void + { + app(CanonicalAdminTenantFilterState::class)->sync( + $this->getTableFiltersSessionKey(), + tenantSensitiveFilters: [], + request: request(), + tenantFilterName: null, + ); + } } diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index 55d53c3..b467922 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -16,6 +16,7 @@ use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; use Filament\Support\Enums\Width; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; class ViewPolicy extends ViewRecord @@ -24,6 +25,11 @@ class ViewPolicy extends ViewRecord protected Width|string|null $maxContentWidth = Width::Full; + protected function resolveRecord(int|string $key): Model + { + return PolicyResource::resolveScopedRecordOrFail($key); + } + protected function getActions(): array { return [$this->makeCaptureSnapshotAction()]; diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index 97d52c8..bbec6ee 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -4,6 +4,7 @@ use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Resources\RestoreRunResource; +use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; @@ -31,6 +32,19 @@ class VersionsRelationManager extends RelationManager protected static string $relationship = 'versions'; + /** + * @param array $arguments + * @param array $context + */ + public function mountAction(string $name, array $arguments = [], array $context = []): mixed + { + if (($context['table'] ?? false) === true && $name === 'restore_to_intune' && filled($context['recordKey'] ?? null)) { + $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $context['recordKey']); + } + + return parent::mountAction($name, $arguments, $context); + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) @@ -55,7 +69,8 @@ public function table(Table $table): Table ->label('Preview only (dry-run)') ->default(true), ]) - ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { + ->action(function (mixed $record, array $data, RestoreService $restoreService) { + $record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record); $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); @@ -178,4 +193,26 @@ public function table(Table $table): Table ->emptyStateHeading('No versions captured') ->emptyStateDescription('Capture or sync this policy again to create version history entries.'); } + + private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion + { + $recordId = $record instanceof PolicyVersion + ? (int) $record->getKey() + : (is_numeric($record) ? (int) $record : 0); + + if ($recordId <= 0) { + abort(404); + } + + $resolvedRecord = $policy->versions() + ->where('tenant_id', (int) $policy->tenant_id) + ->whereKey($recordId) + ->first(); + + if (! $resolvedRecord instanceof PolicyVersion) { + abort(404); + } + + return $resolvedRecord; + } } diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 958d817..d61d3f2 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -2,7 +2,9 @@ namespace App\Filament\Resources; +use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; +use App\Filament\Concerns\ScopesGlobalSearchToTenant; use App\Filament\Resources\PolicyVersionResource\Pages; use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionPruneJob; @@ -59,7 +61,9 @@ class PolicyVersionResource extends Resource { + use InteractsWithTenantOwnedRecords; use ResolvesPanelTenantContext; + use ScopesGlobalSearchToTenant; protected static ?string $model = PolicyVersion::class; @@ -893,7 +897,6 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { $tenant = static::resolveTenantContextForCurrentPanelOrFail(); - $tenantId = $tenant->getKey(); $user = auth()->user(); $resolver = app(CapabilityResolver::class); @@ -903,8 +906,7 @@ public static function getEloquentQuery(): Builder || $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW) ); - return parent::getEloquentQuery() - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + return static::getTenantOwnedEloquentQuery() ->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder { return $query->where(function (Builder $query): void { $query @@ -918,6 +920,36 @@ public static function getEloquentQuery(): Builder ->with('policy'); } + public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model + { + $tenant = static::resolveTenantContextForCurrentPanelOrFail(); + $user = auth()->user(); + + $resolver = app(CapabilityResolver::class); + $canSeeBaselinePurposeEvidence = $user instanceof User + && ( + $resolver->can($user, $tenant, Capabilities::TENANT_SYNC) + || $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW) + ); + + return static::resolveTenantOwnedRecordOrFail( + $key, + parent::getEloquentQuery() + ->withTrashed() + ->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder { + return $query->where(function (Builder $query): void { + $query + ->whereNull('capture_purpose') + ->orWhereNotIn('capture_purpose', [ + PolicyVersionCapturePurpose::BaselineCapture->value, + PolicyVersionCapturePurpose::BaselineCompare->value, + ]); + }); + }) + ->with('policy'), + ); + } + /** * @return listgetKey(); + return static::scopeTenantOwnedQuery(parent::getEloquentQuery()) + ->with('backupSet'); + } - return parent::getEloquentQuery() - ->with('backupSet') - ->when( - $tenantId !== null, - fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantId), - ) - ->when( - $tenantId === null, - fn (Builder $query): Builder => $query->whereRaw('1 = 0'), - ); + public static function resolveScopedRecordOrFail(int|string $key): Model + { + return static::resolveTenantOwnedRecordOrFail( + $key, + parent::getEloquentQuery()->withTrashed()->with('backupSet'), + ); + } + + protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun + { + $resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record); + + if (! $resolvedRecord instanceof RestoreRun) { + abort(404); + } + + return $resolvedRecord; + } + + /** + * @return array + */ + protected static function resolveProtectedRestoreRunIds(Collection $records): array + { + return $records + ->map(function (mixed $record): int { + $resolvedRecord = static::resolveProtectedRestoreRunRecordOrFail($record instanceof RestoreRun ? $record : (is_numeric($record) ? (int) $record : 0)); + + return (int) $resolvedRecord->getKey(); + }) + ->filter(fn (int $value): bool => $value > 0) + ->unique() + ->values() + ->all(); } /** @@ -846,6 +874,8 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (RestoreRun $record): bool => $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $record = static::resolveProtectedRestoreRunRecordOrFail($record); + $record->restore(); if ($record->tenant) { @@ -877,6 +907,8 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (RestoreRun $record): bool => ! $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $record = static::resolveProtectedRestoreRunRecordOrFail($record); + if (! $record->isDeletable()) { Notification::make() ->title('Restore run cannot be archived') @@ -918,6 +950,8 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (RestoreRun $record): bool => $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $record = static::resolveProtectedRestoreRunRecordOrFail($record); + if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, @@ -978,7 +1012,7 @@ public static function table(Table $table): Table $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + $ids = static::resolveProtectedRestoreRunIds($records); if (! $tenant instanceof Tenant) { return; @@ -1048,7 +1082,7 @@ public static function table(Table $table): Table $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + $ids = static::resolveProtectedRestoreRunIds($records); if (! $tenant instanceof Tenant) { return; @@ -1138,7 +1172,7 @@ public static function table(Table $table): Table $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); $count = $records->count(); - $ids = $records->pluck('id')->toArray(); + $ids = static::resolveProtectedRestoreRunIds($records); if (! $tenant instanceof Tenant) { return; @@ -1927,6 +1961,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction \App\Services\Intune\AuditLogger $auditLogger, HasTable $livewire ) { + $record = static::resolveProtectedRestoreRunRecordOrFail($record); $tenant = $record->tenant; $backupSet = $record->backupSet; diff --git a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php index 12faa12..4c7a313 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php @@ -3,12 +3,42 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use App\Support\Filament\CanonicalAdminTenantFilterState; use Filament\Resources\Pages\ListRecords; +use Illuminate\Database\Eloquent\ModelNotFoundException; class ListRestoreRuns extends ListRecords { protected static string $resource = RestoreRunResource::class; + /** + * @param array $arguments + * @param array $context + */ + public function mountAction(string $name, array $arguments = [], array $context = []): mixed + { + if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'forceDelete', 'rerun'], true)) { + try { + RestoreRunResource::resolveScopedRecordOrFail($context['recordKey']); + } catch (ModelNotFoundException) { + abort(404); + } + } + + return parent::mountAction($name, $arguments, $context); + } + + public function mount(): void + { + app(CanonicalAdminTenantFilterState::class)->sync( + $this->getTableFiltersSessionKey(), + request: request(), + tenantFilterName: null, + ); + + parent::mount(); + } + private function tableHasRecords(): bool { return $this->getTableRecords()->count() > 0; diff --git a/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php index 42bdea4..6b2b71c 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php @@ -4,8 +4,14 @@ use App\Filament\Resources\RestoreRunResource; use Filament\Resources\Pages\ViewRecord; +use Illuminate\Database\Eloquent\Model; class ViewRestoreRun extends ViewRecord { protected static string $resource = RestoreRunResource::class; + + protected function resolveRecord(int|string $key): Model + { + return RestoreRunResource::resolveScopedRecordOrFail($key); + } } diff --git a/app/Policies/BackupSchedulePolicy.php b/app/Policies/BackupSchedulePolicy.php index b12d9f2..6d69dd7 100644 --- a/app/Policies/BackupSchedulePolicy.php +++ b/app/Policies/BackupSchedulePolicy.php @@ -6,7 +6,10 @@ use App\Models\Tenant; use App\Models\User; use App\Support\Auth\Capabilities; +use App\Support\OperateHub\OperateHubShell; +use Filament\Facades\Filament; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Auth\Access\Response; use Illuminate\Support\Facades\Gate; class BackupSchedulePolicy @@ -15,7 +18,7 @@ class BackupSchedulePolicy protected function isTenantMember(User $user, ?Tenant $tenant = null): bool { - $tenant ??= Tenant::current(); + $tenant ??= $this->resolvedTenant(); return $tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant); @@ -26,58 +29,74 @@ public function viewAny(User $user): bool return $this->isTenantMember($user); } - public function view(User $user, BackupSchedule $schedule): bool + public function view(User $user, BackupSchedule $schedule): Response|bool { - $tenant = Tenant::current(); + $tenant = $this->resolvedTenant(); if (! $this->isTenantMember($user, $tenant)) { - return false; + return Response::denyAsNotFound(); } - return (int) $schedule->tenant_id === (int) $tenant->getKey(); + return (int) $schedule->tenant_id === (int) $tenant->getKey() + ? true + : Response::denyAsNotFound(); } public function create(User $user): bool { - $tenant = Tenant::current(); + $tenant = $this->resolvedTenant(); return $tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } - public function update(User $user, BackupSchedule $schedule): bool + public function update(User $user, BackupSchedule $schedule): Response|bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && (int) $schedule->tenant_id === (int) $tenant->getKey() - && Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } - public function delete(User $user, BackupSchedule $schedule): bool + public function delete(User $user, BackupSchedule $schedule): Response|bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && (int) $schedule->tenant_id === (int) $tenant->getKey() - && Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } - public function restore(User $user, BackupSchedule $schedule): bool + public function restore(User $user, BackupSchedule $schedule): Response|bool { - $tenant = Tenant::current(); - - return $tenant instanceof Tenant - && (int) $schedule->tenant_id === (int) $tenant->getKey() - && Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE); } - public function forceDelete(User $user, BackupSchedule $schedule): bool + public function forceDelete(User $user, BackupSchedule $schedule): Response|bool { + return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_DELETE); + } + + protected function authorizeScheduleAction(User $user, BackupSchedule $schedule, string $capability): Response|bool + { + $tenant = $this->resolvedTenant(); + + if (! $this->isTenantMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + if (! $tenant instanceof Tenant || (int) $schedule->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + return Gate::forUser($user)->allows($capability, $tenant) + ? true + : Response::deny(); + } + + protected function resolvedTenant(): ?Tenant + { + if (Filament::getCurrentPanel()?->getId() === 'admin') { + $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); + + return $tenant instanceof Tenant ? $tenant : null; + } + $tenant = Tenant::current(); - return $tenant instanceof Tenant - && (int) $schedule->tenant_id === (int) $tenant->getKey() - && Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant); + return $tenant instanceof Tenant ? $tenant : null; } } diff --git a/app/Policies/EntraGroupPolicy.php b/app/Policies/EntraGroupPolicy.php index 5b4799a..2e8623e 100644 --- a/app/Policies/EntraGroupPolicy.php +++ b/app/Policies/EntraGroupPolicy.php @@ -8,6 +8,7 @@ use App\Support\OperateHub\OperateHubShell; use Filament\Facades\Filament; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Auth\Access\Response; class EntraGroupPolicy { @@ -24,25 +25,29 @@ public function viewAny(User $user): bool return $user->canAccessTenant($tenant); } - public function view(User $user, EntraGroup $group): bool + public function view(User $user, EntraGroup $group): Response|bool { $tenant = $this->resolvedTenant(); if (! $tenant) { - return false; + return Response::denyAsNotFound(); } if (! $user->canAccessTenant($tenant)) { - return false; + return Response::denyAsNotFound(); } - return (int) $group->tenant_id === (int) $tenant->getKey(); + if ((int) $group->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + return true; } private function resolvedTenant(): ?Tenant { if (Filament::getCurrentPanel()?->getId() === 'admin') { - $tenant = app(OperateHubShell::class)->activeEntitledTenant(request()); + $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); return $tenant instanceof Tenant ? $tenant : null; } diff --git a/app/Policies/FindingPolicy.php b/app/Policies/FindingPolicy.php index 7c075a2..2e74382 100644 --- a/app/Policies/FindingPolicy.php +++ b/app/Policies/FindingPolicy.php @@ -7,7 +7,10 @@ use App\Models\User; use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; +use App\Support\OperateHub\OperateHubShell; +use Filament\Facades\Filament; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Auth\Access\Response; class FindingPolicy { @@ -15,7 +18,7 @@ class FindingPolicy public function viewAny(User $user): bool { - $tenant = Tenant::current(); + $tenant = $this->resolvedTenant(); if (! $tenant instanceof Tenant) { return false; @@ -28,31 +31,31 @@ public function viewAny(User $user): bool return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW); } - public function view(User $user, Finding $finding): bool + public function view(User $user, Finding $finding): Response|bool { - $tenant = Tenant::current(); + $tenant = $this->resolvedTenant(); if (! $tenant) { - return false; + return Response::denyAsNotFound(); } if (! $user->canAccessTenant($tenant)) { - return false; + return Response::denyAsNotFound(); } if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { - return false; + return Response::denyAsNotFound(); } return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW); } - public function update(User $user, Finding $finding): bool + public function update(User $user, Finding $finding): Response|bool { return $this->triage($user, $finding); } - public function triage(User $user, Finding $finding): bool + public function triage(User $user, Finding $finding): Response|bool { return $this->canMutateWithAnyCapability($user, $finding, [ Capabilities::TENANT_FINDINGS_TRIAGE, @@ -60,32 +63,32 @@ public function triage(User $user, Finding $finding): bool ]); } - public function assign(User $user, Finding $finding): bool + public function assign(User $user, Finding $finding): Response|bool { return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_ASSIGN); } - public function resolve(User $user, Finding $finding): bool + public function resolve(User $user, Finding $finding): Response|bool { return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RESOLVE); } - public function close(User $user, Finding $finding): bool + public function close(User $user, Finding $finding): Response|bool { return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_CLOSE); } - public function riskAccept(User $user, Finding $finding): bool + public function riskAccept(User $user, Finding $finding): Response|bool { return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RISK_ACCEPT); } - public function reopen(User $user, Finding $finding): bool + public function reopen(User $user, Finding $finding): Response|bool { return $this->triage($user, $finding); } - private function canMutateWithCapability(User $user, Finding $finding, string $capability): bool + private function canMutateWithCapability(User $user, Finding $finding, string $capability): Response|bool { return $this->canMutateWithAnyCapability($user, $finding, [$capability]); } @@ -93,20 +96,20 @@ private function canMutateWithCapability(User $user, Finding $finding, string $c /** * @param array $capabilities */ - private function canMutateWithAnyCapability(User $user, Finding $finding, array $capabilities): bool + private function canMutateWithAnyCapability(User $user, Finding $finding, array $capabilities): Response|bool { - $tenant = Tenant::current(); + $tenant = $this->resolvedTenant(); if (! $tenant instanceof Tenant) { - return false; + return Response::denyAsNotFound(); } if (! $user->canAccessTenant($tenant)) { - return false; + return Response::denyAsNotFound(); } if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { - return false; + return Response::denyAsNotFound(); } /** @var CapabilityResolver $resolver */ @@ -118,6 +121,19 @@ private function canMutateWithAnyCapability(User $user, Finding $finding, array } } - return false; + return Response::deny(); + } + + private function resolvedTenant(): ?Tenant + { + if (Filament::getCurrentPanel()?->getId() === 'admin') { + $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); + + return $tenant instanceof Tenant ? $tenant : null; + } + + $tenant = Tenant::current(); + + return $tenant instanceof Tenant ? $tenant : null; } } diff --git a/app/Support/OperateHub/OperateHubShell.php b/app/Support/OperateHub/OperateHubShell.php index c3afd53..990aaaf 100644 --- a/app/Support/OperateHub/OperateHubShell.php +++ b/app/Support/OperateHub/OperateHubShell.php @@ -86,6 +86,11 @@ public function activeEntitledTenant(?Request $request = null): ?Tenant return $this->resolveActiveTenant($request); } + public function tenantOwnedPanelContext(?Request $request = null): ?Tenant + { + return $this->activeEntitledTenant($request); + } + private function resolveActiveTenant(?Request $request = null): ?Tenant { $pageCategory = $this->pageCategory($request); diff --git a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index 32ba9e4..c8cd60f 100644 --- a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -4,6 +4,8 @@ namespace App\Support\Ui\ActionSurface; +use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; + final class ActionSurfaceExemptions { /** @@ -15,7 +17,7 @@ public function __construct( public static function baseline(): self { - return new self([ + return new self(array_merge([ // Baseline allowlist for legacy surfaces. Keep shrinking this list. 'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.', 'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.', @@ -33,10 +35,9 @@ public static function baseline(): self 'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.', 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', - 'App\\Filament\\Resources\\RestoreRunResource' => 'Restore run resource retrofit deferred to restore track.', 'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.', 'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.', - ]); + ], TenantOwnedModelFamilies::actionSurfaceBaselineExemptions())); } /** diff --git a/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php b/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php new file mode 100644 index 0000000..93bed2a --- /dev/null +++ b/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php @@ -0,0 +1,248 @@ + + */ + 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 + */ + 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 + */ + 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}> + */ + 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 + */ + public static function explicitScopeExceptions(): array + { + return array_map( + static fn (array $exception): string => $exception['why_excepted'], + self::scopeExceptions(), + ); + } + + /** + * @return array + */ + 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; + } +} diff --git a/app/Support/WorkspaceIsolation/TenantOwnedQueryScope.php b/app/Support/WorkspaceIsolation/TenantOwnedQueryScope.php new file mode 100644 index 0000000..d1796b3 --- /dev/null +++ b/app/Support/WorkspaceIsolation/TenantOwnedQueryScope.php @@ -0,0 +1,25 @@ +empty($query); + } + + return $query->whereBelongsTo($tenant, $relationship); + } + + public function empty(Builder $query): Builder + { + return $query->whereRaw('1 = 0'); + } +} diff --git a/app/Support/WorkspaceIsolation/TenantOwnedRecordResolver.php b/app/Support/WorkspaceIsolation/TenantOwnedRecordResolver.php new file mode 100644 index 0000000..85efa79 --- /dev/null +++ b/app/Support/WorkspaceIsolation/TenantOwnedRecordResolver.php @@ -0,0 +1,34 @@ +getKey() : $record; + + if ($recordKey === null || $recordKey === '') { + return null; + } + + return $query->whereKey($recordKey)->first(); + } + + public function resolveOrFail(Builder $query, Model|int|string|null $record): Model + { + $resolved = $this->resolve($query, $record); + + if ($resolved instanceof Model) { + return $resolved; + } + + throw (new ModelNotFoundException)->setModel($query->getModel()::class); + } +} diff --git a/app/Support/WorkspaceIsolation/TenantOwnedTables.php b/app/Support/WorkspaceIsolation/TenantOwnedTables.php index 3a5ea5d..3ec3e8e 100644 --- a/app/Support/WorkspaceIsolation/TenantOwnedTables.php +++ b/app/Support/WorkspaceIsolation/TenantOwnedTables.php @@ -11,24 +11,41 @@ class TenantOwnedTables */ public static function all(): array { - return [ - 'policies', - 'policy_versions', - 'backup_sets', - 'backup_items', - 'restore_runs', - 'backup_schedules', - 'inventory_items', - 'inventory_links', - 'entra_groups', - 'findings', - 'entra_role_definitions', - 'tenant_permissions', - ]; + return [...self::firstSlice(), ...self::residual()]; } public static function contains(string $table): bool { return in_array($table, self::all(), true); } + + /** + * @return array + */ + public static function firstSlice(): array + { + return [ + 'policies', + 'policy_versions', + 'backup_schedules', + 'backup_sets', + 'restore_runs', + 'findings', + 'inventory_items', + 'entra_groups', + ]; + } + + /** + * @return array + */ + public static function residual(): array + { + return [ + 'backup_items', + 'inventory_links', + 'entra_role_definitions', + 'tenant_permissions', + ]; + } } diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 025f830..2922244 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -37,15 +37,6 @@ ### Queued Execution Reauthorization and Scope Continuity - **Dependencies**: Existing operations semantics, audit log foundation, queued job execution paths - **Priority**: high -### Tenant-Owned Query Canon and Wrong-Tenant Guards -- **Type**: hardening -- **Source**: architecture audit 2026-03-15 -- **Problem**: Tenant isolation exists, but many reads still depend on local `tenant_id` filters instead of a reusable canonical query path. Wrong-tenant regression coverage is also uneven. -- **Why it matters**: This is isolation drift. Repeated local filtering increases the chance of future cross-tenant mistakes across resources, widgets, actions, and detail pages. -- **Proposed direction**: Define a canonical query entry pattern for tenant-owned models plus a required wrong-tenant regression matrix for tier-1 surfaces. -- **Dependencies**: Canonical tenant context work in Specs 135 and 136 -- **Priority**: high - ### Livewire Context Locking and Trusted-State Reduction - **Type**: hardening - **Source**: architecture audit 2026-03-15 diff --git a/docs/research/admin-canonical-tenant-rollout.md b/docs/research/admin-canonical-tenant-rollout.md index ddd709f..d2201fa 100644 --- a/docs/research/admin-canonical-tenant-rollout.md +++ b/docs/research/admin-canonical-tenant-rollout.md @@ -73,7 +73,6 @@ ## Exception Inventory - `app/Filament/Pages/ChooseTenant.php` - `app/Http/Controllers/SelectTenantController.php` - `app/Support/Middleware/EnsureFilamentTenantSelected.php` -- `app/Filament/Resources/EntraGroupResource.php` - `app/Filament/Concerns/ResolvesPanelTenantContext.php` `app/Filament/Concerns/ResolvesPanelTenantContext.php` is the only shared internal delegation wrapper allowed for this rollout. It is not a new public resolver. Admin semantics still come from `OperateHubShell`. @@ -89,6 +88,7 @@ ## Future-Surface Rule Any new admin-visible or admin-reachable tenant-sensitive Filament surface must: - resolve workspace-admin tenant context through `OperateHubShell` or the internal `ResolvesPanelTenantContext` helper +- route tenant-owned list/detail resolution through the shared `InteractsWithTenantOwnedRecords` helper where the surface is tenant-owned - keep tenant-panel requests panel-native - synchronize persisted tenant-derived filters before render when `persistFiltersInSession()` is used - disable global search unless list, detail, and search parity are explicitly tenant-safe diff --git a/routes/web.php b/routes/web.php index b4202f1..4c550e6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\SelectTenantController; use App\Http\Controllers\SwitchWorkspaceController; use App\Http\Controllers\TenantOnboardingController; +use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\TenantOnboardingSession; @@ -101,6 +102,10 @@ return app(OnboardingDraftResolver::class)->resolve((int) $value, $user, $workspace); }); +Route::bind('run', function (string $value): OperationRun { + return OperationRun::query()->whereKey((int) $value)->firstOrFail(); +}); + $authorizeManagedTenantRoute = function (Tenant $tenant, Request $request): void { $user = $request->user(); @@ -232,6 +237,7 @@ 'ensure-workspace-selected', ]) ->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class) + ->can('view', 'run') ->name('admin.operations.view'); Route::middleware([ diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/checklists/requirements.md b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/checklists/requirements.md new file mode 100644 index 0000000..6cddf37 --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Tenant-Owned Query Canon and Wrong-Tenant Guards + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-17 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass complete on 2026-03-17. +- No clarification markers remained after the first drafting pass. +- Spec 150 is intentionally positioned as the read and lookup complement to Spec 149, which already covers execution-time reauthorization for queued mutation work. \ No newline at end of file diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/contracts/tenant-owned-query-canon.openapi.yaml b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/contracts/tenant-owned-query-canon.openapi.yaml new file mode 100644 index 0000000..7e6d431 --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/contracts/tenant-owned-query-canon.openapi.yaml @@ -0,0 +1,140 @@ +openapi: 3.1.0 +info: + title: Tenant-Owned Query Canon Contract + version: 0.1.0 + summary: Internal behavioral contract for tenant-owned list, detail, search, and action paths. +servers: + - url: http://localhost +paths: + /admin/t/{tenant}/{resource}: + get: + summary: List tenant-owned records within the route tenant scope + operationId: listTenantOwnedRecords + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Resource' + responses: + '200': + description: Returns only records owned by the entitled route tenant. + '404': + $ref: '#/components/responses/NotFound' + /admin/t/{tenant}/{resource}/{record}: + get: + summary: View a tenant-owned record using the same scope rule as the list + operationId: viewTenantOwnedRecord + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Resource' + - $ref: '#/components/parameters/Record' + responses: + '200': + description: The record belongs to the entitled route tenant and is viewable. + '404': + $ref: '#/components/responses/NotFound' + /admin/t/{tenant}/{resource}/{record}/actions/{action}: + post: + summary: Execute a protected row action against a tenant-owned record + operationId: actOnTenantOwnedRecord + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Resource' + - $ref: '#/components/parameters/Record' + - $ref: '#/components/parameters/Action' + responses: + '204': + description: The action executed against an in-scope record. + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /admin/t/{tenant}/{resource}/bulk-actions/{action}: + post: + summary: Execute a protected bulk action against tenant-owned records + operationId: bulkActOnTenantOwnedRecords + parameters: + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Resource' + - $ref: '#/components/parameters/Action' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [record_ids] + properties: + record_ids: + type: array + items: + type: string + responses: + '204': + description: All submitted record IDs belong to the entitled tenant scope and the action executed. + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /admin/{resource}/{record}: + get: + summary: View a tenant-owned record from a workspace-admin canonical viewer + operationId: viewTenantOwnedRecordFromCanonicalViewer + parameters: + - $ref: '#/components/parameters/Resource' + - $ref: '#/components/parameters/Record' + responses: + '200': + description: The record is tenant-owned, and explicit record-owner entitlement succeeded. + '404': + $ref: '#/components/responses/NotFound' + /admin/search: + get: + summary: Search tenant-owned resources only when safe search parity is enabled + operationId: searchTenantOwnedRecords + parameters: + - name: q + in: query + required: true + schema: + type: string + - name: resource + in: query + required: true + schema: + type: string + responses: + '200': + description: Search results are limited to tenant-owned families whose search posture is scoped. + '404': + description: Used only when the search destination would reveal an inaccessible record. +components: + parameters: + Tenant: + name: tenant + in: path + required: true + schema: + type: string + description: Route tenant external identifier for tenant-bound surfaces. + Resource: + name: resource + in: path + required: true + schema: + type: string + Record: + name: record + in: path + required: true + schema: + type: string + Action: + name: action + in: path + required: true + schema: + type: string + responses: + NotFound: + description: The actor is not entitled to the workspace or tenant scope, or the target record does not belong to the resolved tenant scope. + Forbidden: + description: The actor is entitled to the tenant scope, but lacks the required capability for the protected action. \ No newline at end of file diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/data-model.md b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/data-model.md new file mode 100644 index 0000000..8049bdd --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/data-model.md @@ -0,0 +1,105 @@ +# Data Model: Tenant-Owned Query Canon and Wrong-Tenant Guards + +## 1. TenantOwnedModelFamily + +- Purpose: Defines which data families are mandatory consumers of the canonical tenant-owned query rule. +- Fields: + - `name`: stable family name such as `Policy`, `PolicyVersion`, `BackupSet`, `BackupSchedule`, `RestoreRun`, `Finding`, `InventoryItem`, `EntraGroup` + - `table`: underlying tenant-owned table name + - `owns_tenant_scope`: always `true` for in-scope families + - `primary_surface`: Filament resource or page that exposes the family + - `search_posture`: `scoped`, `disabled`, or `not_applicable` + - `action_surface_status`: `declared` or `baseline_exemption` + - `action_surface_reason`: why the Action Surface Contract is declared directly or why an exemption remains temporary + - `notes`: documented exceptions or rollout deferments +- Relationships: + - One `TenantOwnedModelFamily` has many `TenantOwnedAccessPath` entries + - One `TenantOwnedModelFamily` has many `WrongTenantGuardScenario` entries + +## 2. TenantOwnedAccessPath + +- Purpose: Represents every way an operator can reach or act on a tenant-owned record. +- Fields: + - `family_name`: owning `TenantOwnedModelFamily` + - `path_type`: one of `index`, `detail`, `row_action`, `bulk_action`, `relation_manager`, `global_search`, `canonical_viewer` + - `scope_anchor`: `route_tenant`, `owner_record`, or `explicit_record_lookup` + - `entitlement_rule`: `workspace_and_tenant_required` + - `capability_rule`: `capability_after_scope` + - `not_entitled_result`: always `404` + - `missing_capability_result`: always `403` for protected actions after scope is established + - `record_lookup_behavior`: `same_as_list_scope` or `explicit_exception` +- State transitions: + - `accessible` when workspace membership, tenant entitlement, and lookup congruence are all satisfied + - `not_found` when workspace or tenant scope fails or when the record belongs to a foreign tenant + - `forbidden` when scope passes but the protected action capability fails + +## 3. CanonicalTenantOwnedQueryRule + +- Purpose: The conceptual contract that all in-scope surfaces must use to obtain records. +- Fields: + - `family_name`: target model family + - `tenant_source`: `route_tenant`, `panel_tenant`, or `explicit_record_owner` + - `workspace_source`: current workspace context + - `query_shape`: constrained family query that cannot widen beyond the owning tenant + - `action_target_check`: boolean flag requiring congruence between submitted record IDs and the resolved tenant scope + - `search_rule`: `scoped_only` or `disabled` +- Invariants: + - Must never widen from one tenant to another within the same request path + - Must fail closed when tenant context is missing for a tenant-bound surface + - Must preserve explicit record-owner entitlement checks for canonical viewers + +## 4. WrongTenantGuardScenario + +- Purpose: Captures the test matrix that proves the canon works across representative surfaces. +- Fields: + - `family_name`: target model family + - `scenario_type`: `positive_scope`, `wrong_tenant_index`, `wrong_tenant_detail`, `wrong_tenant_row_action`, `wrong_tenant_bulk_action`, `wrong_tenant_relation_manager`, `safe_search` + - `expected_outcome`: `200`, `403`, `404`, or `hidden_or_disabled_without_side_effect` + - `surface_class`: route test, Livewire table test, relation-manager test, or guard test + - `notes`: explanation of any Filament-specific behavior such as hidden actions not returning literal 404 + +## 5. ScopeException + +- Purpose: Documents legitimate cases that touch tenant data but are not treated as ordinary tenant-owned surfaces. +- Fields: + - `surface_name`: human-readable surface name + - `exception_kind`: `workspace_admin_canonical_viewer`, `workspace_owned_reference_surface`, or `deferred_family` + - `why_excepted`: concise reason the canonical tenant-bound list rule does not apply directly + - `still_required_checks`: explicit list of remaining workspace, tenant, and capability checks + +## 6. ResidualRolloutInventoryEntry + +- Purpose: Tracks tenant-owned tables that remain outside the first-slice family map so future rollout work stays explicit instead of rediscovered ad hoc. +- Fields: + - `name`: stable residual family name + - `table`: underlying tenant-owned table name + - `likely_surface`: current relation-manager, reporting surface, or diagnostics entry point that still touches the table + - `why_not_in_first_slice`: concise reason this table is not yet a first-slice primary family + +## Initial Rollout Family Map + +| Family | Table | Primary Surface | Access Paths In First Slice | Search Posture | Action Surface Contract | +|---|---|---|---|---|---| +| Policy | `policies` | `PolicyResource` | index, detail, row action, bulk action, relation manager linkage | disabled until parity is guaranteed | declared | +| PolicyVersion | `policy_versions` | `PolicyVersionResource` | index, detail, row action, bulk action, relation manager | disabled until parity is guaranteed | declared | +| BackupSchedule | `backup_schedules` | `BackupScheduleResource` | index, detail, row action, bulk action | not applicable | declared | +| BackupSet | `backup_sets` | `BackupSetResource` | index, detail, row action, bulk action, relation manager | not applicable | declared | +| RestoreRun | `restore_runs` | `RestoreRunResource` | index, detail, row action, bulk action | not applicable | baseline exemption until restore-track retrofit lands | +| Finding | `findings` | `FindingResource` | index, detail, row action, bulk action | not applicable | declared | +| InventoryItem | `inventory_items` | `InventoryItemResource` | index, detail, row action, bulk action | not applicable | declared | +| EntraGroup | `entra_groups` | `EntraGroupResource` | index, detail, global search, canonical viewer | scoped | declared | + +## Residual Rollout Inventory + +| Family | Table | Likely Surface | Why Not In First Slice | +|---|---|---|---| +| BackupItem | `backup_items` | `BackupSetResource::BackupItemsRelationManager` | Covered through the backup-set relation manager rather than a standalone primary resource. | +| InventoryLink | `inventory_links` | `InventoryItemResource` related-links affordances | Inventory links remain subordinate navigation metadata and inherit tenant scope through inventory items. | +| EntraRoleDefinition | `entra_role_definitions` | Entra admin-role reporting and findings reference flows | Direct tenant-owned resource parity is deferred while read paths remain indirect. | +| TenantPermission | `tenant_permissions` | Permissions and onboarding diagnostics surfaces | Permission posture is enforced through dedicated diagnostics and onboarding flows, not a first-slice primary resource. | + +## Explicit Exceptions In First Slice + +- `ProviderConnectionResource`: tenant-owned data with a workspace-admin tenant-default surface. It is a useful reference but not the primary implementation slice for the tenant-bound canon. +- `OperationRunResource`: workspace-owned canonical monitoring surface. It remains relevant only when it deep-links into tenant-owned records. +- `AlertDeliveryResource`: mixed workspace-owned and tenant-bound semantics, therefore outside the mandatory tenant-owned family set for this slice. \ No newline at end of file diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/plan.md b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/plan.md new file mode 100644 index 0000000..a995644 --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/plan.md @@ -0,0 +1,130 @@ +# Implementation Plan: Tenant-Owned Query Canon and Wrong-Tenant Guards + +**Branch**: `150-tenant-owned-query-canon-and-wrong-tenant-guards` | **Date**: 2026-03-17 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/spec.md` +**Input**: Feature specification from `/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Define one canonical query and record-resolution contract for tenant-owned model families so list pages, detail links, relation managers, global search, and protected actions all enforce the same tenant boundary. The implementation will build on the existing panel tenant resolution rules from `ResolvesPanelTenantContext` and `OperateHubShell`, apply a shared tenant-owned query helper to a representative first-slice family set, and back the rollout with a reusable wrong-tenant regression matrix plus a lightweight guardrail. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 +**Storage**: PostgreSQL +**Testing**: Pest feature tests, Livewire component tests, architectural guard tests +**Target Platform**: Laravel Sail web application on macOS/local Docker and Dokploy deployment targets +**Project Type**: Web application +**Performance Goals**: No material regression on standardized admin tables; tenant-owned list and detail paths remain DB-only at render time and fail closed without broad fallback queries +**Constraints**: Must preserve 404 vs 403 semantics, must not widen tenant scope in workspace-admin canonical viewers, must keep existing Filament action surfaces intact, must avoid high-noise guardrails +**Scale/Scope**: Representative first-slice rollout across tier-1 tenant-owned Filament resources, relation managers, search paths, and guard tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: clarify what is “last observed” vs snapshots/backups +- Read/write separation: any writes require preview + confirmation + audit + tests +- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` +- Deterministic capabilities: capability derivation is testable (snapshot/golden tests) +- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks) +- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy +- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text +- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics) +- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked +- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun` +- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications +- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside +- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only +- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress +- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications) +- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter +- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens +- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests +- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI +- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted +- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency + +**Phase 0 Gate Result**: PASS + +- Inventory-first and read/write separation remain intact because the feature hardens read/query and protected-action targeting behavior without introducing new Graph or snapshot flows. +- RBAC-UX requirements are central to the feature and are explicitly preserved: non-member or wrong-tenant paths stay 404, in-scope missing-capability action paths stay 403. +- No new `OperationRun` behavior is introduced. +- Existing Filament action surfaces remain in place; the feature changes reachability and congruence, not the visible action inventory. +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Concerns/ +│ ├── Pages/ +│ ├── Resources/ +│ └── Widgets/ +├── Models/ +├── Policies/ +├── Services/ +└── Support/ +routes/ +└── web.php +tests/ +├── Feature/ +│ ├── Filament/ +│ ├── Guards/ +│ ├── Rbac/ +│ ├── BackupScheduling/ +│ ├── ProviderConnections/ +│ └── Findings/ +└── Unit/ +``` + +**Structure Decision**: Use the existing Laravel web application structure. The feature is concentrated in `app/Filament`, `app/Policies`, `app/Support`, route-level canonical viewers in `routes/web.php`, and focused Pest coverage under `tests/Feature`. + +## Phase 0 Research Output + +- [research.md](./research.md) resolves the key architectural choices for the rollout. +- Resolved unknowns: + - Canonical pattern shape: shared tenant-owned query helper layered on current panel tenant resolution + - First rollout family set: derived from `TenantOwnedTables` and mapped to representative Filament resources + - Global search strategy: scoped when parity is guaranteed, otherwise explicitly disabled + - Guard strategy: extend existing architectural guards with a focused tenant-owned query guard and wrong-tenant regression matrix + - Relation-manager strategy: owner-record anchored scope with explicit action-target congruence + +## Phase 1 Design Output + +- [data-model.md](./data-model.md) defines the conceptual entities for tenant-owned model families, access paths, canonical query rules, wrong-tenant guard scenarios, and explicit scope exceptions. +- [contracts/tenant-owned-query-canon.openapi.yaml](./contracts/tenant-owned-query-canon.openapi.yaml) captures the internal HTTP semantics for list, detail, action, bulk action, canonical-viewer, and search paths. +- [quickstart.md](./quickstart.md) captures the recommended implementation order and verification flow. + +## Post-Design Constitution Check + +**Result**: PASS + +- The design stays inside the current Laravel 12 + Filament v5 + Livewire v4 stack and preserves the existing `/admin` versus `/system` plane separation. +- No new Graph path, queue path, or snapshot path is introduced. +- The design preserves deny-as-not-found for scope failures and forbidden for in-scope capability failures. +- Global search is explicitly bounded by safe parity rules, which aligns with RBAC-UX and Filament global-search rules. +- Relation-manager and action-surface behavior remain compatible with the Filament action surface contract because the plan changes target resolution, not the visible action taxonomy. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | Not applicable | Not applicable | diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/quickstart.md b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/quickstart.md new file mode 100644 index 0000000..f4015a4 --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/quickstart.md @@ -0,0 +1,45 @@ +# Quickstart: Tenant-Owned Query Canon and Wrong-Tenant Guards + +## Goal + +Implement a reusable tenant-owned query contract that keeps list, detail, search, relation-manager, and protected action paths aligned to the same tenant boundary. + +## Suggested Implementation Order + +1. Identify the first-slice family inventory from `TenantOwnedTables` and map each family to its primary Filament surface. +2. Introduce the shared tenant-owned query and explicit record-resolution helper(s) for representative families. +3. Migrate representative resources to the shared helper, starting with `EntraGroupResource`, `PolicyResource`, `PolicyVersionResource`, `BackupScheduleResource`, `BackupSetResource`, `RestoreRunResource`, `FindingResource`, and `InventoryItemResource`. +4. Update relation managers in the first slice so their action targets prove owner-record and tenant congruence. +5. Align global search posture per family: keep it scoped where parity exists, disable it deliberately where parity still does not exist. +6. Add the wrong-tenant regression matrix and the lightweight architectural guard. + +## Expected Code Areas + +- `app/Filament/Concerns/` +- `app/Filament/Resources/` +- `app/Policies/` +- `app/Support/WorkspaceIsolation/` +- `routes/web.php` +- `tests/Feature/Filament/` +- `tests/Feature/Rbac/` +- `tests/Feature/Guards/` + +## Verification Flow + +Run the minimum relevant checks through Sail: + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupAdminScopeTest.php +vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling/BackupScheduleAdminTenantParityTest.php +vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminTenantResolverGuardTest.php +vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php +vendor/bin/sail artisan test --compact tests/Feature/Rbac +vendor/bin/sail bin pint --dirty --format agent +``` + +## Completion Criteria + +- Representative tenant-owned families share one canonical query and lookup pattern. +- Wrong-tenant index, detail, relation-manager, and protected action regressions are covered. +- Global search is either safely scoped or explicitly disabled per family. +- Guard coverage prevents new forbidden query patterns on covered surfaces. \ No newline at end of file diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/research.md b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/research.md new file mode 100644 index 0000000..c2cb073 --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/research.md @@ -0,0 +1,50 @@ +# Research: Tenant-Owned Query Canon and Wrong-Tenant Guards + +## Decision 1: Build the canon around a shared tenant-owned query helper, not page-local `where('tenant_id', ...)` + +- Decision: The first implementation slice should introduce a shared tenant-owned query and record-resolution contract for representative model families, layered on top of the existing panel tenant resolution rules from `ResolvesPanelTenantContext` and `OperateHubShell`. +- Rationale: The repo already has stable context resolution semantics, but many resources still enforce tenant scope by repeating `where('tenant_id', ...)` inside `getEloquentQuery()`, list pages, relation managers, and action code paths. A shared contract lets list, detail, row action, bulk action, relation manager, and search paths use one source of truth instead of drifting independently. +- Alternatives considered: + - Policy-only enforcement: rejected because policies do not guarantee list/query parity, relation-manager parity, or forged identifier protection on their own. + - Route middleware only: rejected because many wrong-tenant paths happen after the page is already loaded, especially in table actions and relation managers. + - Global Eloquent scope on every tenant-owned model: rejected for the first slice because admin canonical viewers and explicit exceptions need more precise, surface-aware control. + +## Decision 2: Use `TenantOwnedTables` as the authoritative inventory for mandatory tenant-owned families, then map that inventory to representative Filament surfaces + +- Decision: The rollout inventory should start from `App\Support\WorkspaceIsolation\TenantOwnedTables` and then map each table to the corresponding model/resource/relation-manager surfaces. The first implementation slice should cover representative tier-1 families: policies, policy versions, backup schedules, backup sets plus backup items relation manager, restore runs, findings, inventory items, and Entra groups. +- Rationale: `TenantOwnedTables` already encodes the repo's explicit tenant-owned data boundary. Planning from that list avoids guessing which families are in scope and creates a stable bridge between database ownership rules and UI/query enforcement. The listed resources also already have regression coverage and existing tenant-resolution logic that can be unified. +- Alternatives considered: + - Start from all Filament resources under `app/Filament/Resources`: rejected because not every resource is tenant-owned and that would blur workspace-owned exceptions. + - Start from one resource family only: rejected because the spec is intended to prove a reusable architectural pattern, not a local fix. + +## Decision 3: Treat workspace-admin tenant-owned viewers as explicit exceptions with strict record-level entitlement checks + +- Decision: Workspace-admin canonical viewers may continue to open tenant-owned records outside `/admin/t/{tenant}/...`, but only through explicit record-level lookup that verifies workspace membership, tenant entitlement, and owning-tenant congruence before rendering the record. +- Rationale: The constitution explicitly allows tenantless canonical views when entitlement checks remain tenant-safe. The repo already does this for `OperationRunPolicy`, `EntraGroupResource` admin behavior, and several managed-tenant routes. The plan should preserve those legitimate deep-link cases while banning broader list-style fallback queries. +- Alternatives considered: + - Force all tenant-owned views under `/admin/t/{tenant}/...`: rejected because the current product has legitimate workspace-admin inspection paths and forcing a route migration is outside this spec. + - Allow broad admin viewers with policy-only checks: rejected because it recreates the original wrong-tenant drift problem. + +## Decision 4: Keep global search enabled only where list/detail parity can be guaranteed; otherwise disable it deliberately + +- Decision: The rollout should adopt `ScopesGlobalSearchToTenant` where list/detail parity is already achievable and leave search disabled for resources that still lack safe parity. +- Rationale: The trait already resolves tenant context through admin remembered-tenant semantics and tenant-panel context, and it fails closed with `whereRaw('1 = 0')` when scope is unavailable. Existing tests already document deliberate search disablement for resources such as policies and policy versions when parity is not guaranteed. The plan should formalize that posture rather than assume all tenant-owned resources must be searchable. +- Alternatives considered: + - Enable global search for every tenant-owned resource: rejected because some resources still use broader or inconsistent resolution paths. + - Disable global search repo-wide for tenant-owned resources: rejected because safe search already exists for some families and is useful when parity is enforced. + +## Decision 5: Extend existing guardrails with a tenant-owned query guard and a reusable wrong-tenant regression matrix + +- Decision: The implementation should extend the existing guard strategy, not invent a parallel system. Concretely, add a lightweight tenant-owned query guard for representative surfaces and pair it with a reusable wrong-tenant regression matrix covering index, detail, row action, bulk action, relation manager, and safe-search scenarios. +- Rationale: The repo already has architectural guards such as `AdminTenantResolverGuardTest`, `NoAdHocFilamentAuthPatternsTest`, and multiple RBAC parity tests. Reusing that style keeps the hardening enforceable in CI without demanding a one-off audit every time a resource changes. +- Alternatives considered: + - Exhaustive grep guard against every `where('tenant_id'...)` in the repo: rejected because legitimate service-layer and job-level tenant filters would create too much noise. + - Regression tests without a guard: rejected because new files could reintroduce forbidden patterns silently. + +## Decision 6: Relation managers must anchor scope to the owner record, then prove tenant congruence on action targets + +- Decision: Relation managers and embedded tables should derive their allowed dataset from the owner record relationship and add explicit tenant congruence checks on action targets instead of depending only on ambient `Tenant::current()` state. +- Rationale: The current codebase shows both patterns. Some relation managers scope only through the page tenant, while others rely on owner-record relationships plus manual congruence checks. The owner-record anchor is more robust because it prevents drift when a forged record identifier is submitted or when page context and related-record lookup diverge. +- Alternatives considered: + - Ambient tenant only via `Tenant::current()`: rejected because it is vulnerable to broader related-record paths when owner-record and action-target parity is not enforced. + - No relation-manager-specific rule: rejected because relation managers are one of the stated wrong-tenant risk surfaces in the spec. \ No newline at end of file diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/spec.md b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/spec.md new file mode 100644 index 0000000..30001ae --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/spec.md @@ -0,0 +1,186 @@ +# Feature Specification: Tenant-Owned Query Canon and Wrong-Tenant Guards + +**Feature Branch**: `150-tenant-owned-query-canon-and-wrong-tenant-guards` +**Created**: 2026-03-17 +**Status**: Draft +**Input**: User description: "zieh den strategisch sinnvollsten nächsten spec auf spec candidates" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant + canonical-view +- **Primary Routes**: + - Tenant-owned resource lists, detail pages, and actions under `/admin/t/{tenant}/...` + - Workspace-admin canonical viewers and direct record URLs under `/admin/...` where tenant-owned records may still be opened safely + - Global search and deep-link entry paths for in-scope tenant-owned resources + - Relation-manager and embedded table surfaces that resolve tenant-owned records inside a tenant-bound page +- **Data Ownership**: + - In-scope records remain tenant-owned records inside the existing workspace boundary. + - Workspace-owned monitoring and audit surfaces remain workspace-owned, but any lookup or drill-down that opens a tenant-owned record must follow the same tenant-owned query rules. + - This feature introduces no new business domain and no new ownership model. It defines one canonical way to resolve, list, inspect, and act on tenant-owned records. +- **RBAC**: + - Workspace membership remains the first boundary. + - Tenant entitlement remains required before any tenant-owned record may be listed, resolved, or acted on. + - Capability checks continue to gate protected actions after workspace and tenant scope have been established. + - Non-members or users outside the relevant tenant scope remain deny-as-not-found. In-scope users lacking the required capability remain forbidden for protected actions. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Tenant-owned resource lists and relation tables default to the route or selected tenant context already established by the page. Workspace-admin canonical viewers must never infer a broader tenant scope when tenant context is missing; they either resolve the specific entitled record safely or return deny-as-not-found. +- **Explicit entitlement checks preventing cross-tenant leakage**: Every in-scope list query, record lookup, row action, bulk action, relation-manager query, and global-search result must validate workspace membership and tenant entitlement against the record's actual owner tenant before any data, labels, or action affordances are shown. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Trust tenant-owned lists and detail links (Priority: P1) + +As an operator, I want tenant-owned resources to show only records from the tenant I am currently entitled to, so that lists, detail pages, and bookmarks never drift into another tenant's data. + +**Why this priority**: This is the core isolation promise for tenant-owned data. If record lookup can drift across tenants, every downstream governance surface becomes unreliable. + +**Independent Test**: Can be fully tested by preparing equivalent records in two tenants, opening the list and a direct detail link in one tenant context, and confirming that only the in-scope tenant's record can be listed or opened. + +**Acceptance Scenarios**: + +1. **Given** an operator is entitled to tenant A and tenant B contains a record with the same resource type, **When** the operator opens the tenant A list, **Then** only tenant A records appear. +2. **Given** an operator has a bookmarked detail URL for a tenant B record while operating in tenant A, **When** the operator opens the URL without tenant B entitlement, **Then** the request is denied as not found and no tenant B details are revealed. +3. **Given** an operator is entitled to the record's tenant, **When** the operator opens a direct detail URL, **Then** the detail page resolves the correct record without needing a broader fallback query. + +--- + +### User Story 2 - Block wrong-tenant mutations and bulk actions (Priority: P1) + +As an operator, I want row actions, bulk actions, and related-record actions to use the same tenant boundary as the list I am looking at, so that I cannot accidentally trigger a mutation against another tenant's record. + +**Why this priority**: Read-scope isolation is not enough if a hidden wrong-tenant action path can still mutate data. + +**Independent Test**: Can be fully tested by attempting row actions, bulk actions, and relation-manager actions against foreign-tenant records and confirming they never execute. + +**Acceptance Scenarios**: + +1. **Given** a tenant-owned list is scoped to tenant A, **When** a foreign-tenant record identifier is submitted to a row action or bulk action path, **Then** the action is refused before any mutation occurs. +2. **Given** a tenant-bound page exposes a relation manager or embedded table, **When** the operator opens related records or triggers actions, **Then** all related queries and actions remain bound to the owning tenant. +3. **Given** an operator is entitled to the tenant but lacks the required capability, **When** the operator triggers a protected action on an in-scope record, **Then** the system returns forbidden rather than widening or hiding the record lookup. + +--- + +### User Story 3 - Maintain one canonical query pattern (Priority: P2) + +As a maintainer, I want one canonical query and record-resolution pattern for tenant-owned model families, so that future resources do not keep reintroducing ad hoc tenant filters and uneven wrong-tenant tests. + +**Why this priority**: The real strategic value is architectural consistency. Local `tenant_id` filtering fixes do not scale. + +**Independent Test**: Can be fully tested by applying the same wrong-tenant regression scenarios to multiple representative resource families and verifying they all follow the same allow and deny behavior. + +**Acceptance Scenarios**: + +1. **Given** two different tenant-owned resource families are in scope, **When** wrong-tenant index, detail, row action, and bulk action scenarios are exercised, **Then** both families follow the same canonical scope rules. +2. **Given** a new tenant-owned resource is added later, **When** it adopts the canonical query pattern, **Then** maintainers can reuse the same regression expectations instead of inventing a new local rule. + +### Edge Cases + +- A direct URL points to a valid record that belongs to the same workspace but a different tenant than the current route tenant. +- A row action or bulk action receives forged record identifiers that were never visible in the current table. +- A relation manager or embedded table inherits a correct page tenant but uses a broader related-record lookup path. +- Global search or linked table columns can resolve a tenant-owned record without first visiting the list page. +- Persisted table state or remembered filters reference a record from a tenant the operator no longer has access to. +- A workspace-admin canonical viewer can legitimately open a tenant-owned record only when the operator is entitled to that record's tenant; otherwise the record must disappear safely. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new long-running work, and no new scheduled work. It is a query and authorization hardening feature for tenant-owned reads and mutations. Because it changes server-side record resolution and protected action behavior, it must explicitly define tenant isolation, deny semantics, and regression coverage. Existing audit behavior for sensitive mutations remains in force; this feature prevents wrong-tenant execution rather than creating a new mutation type. + +**Constitution alignment (OPS-UX):** This feature does not create or reuse `OperationRun` types. Monitoring and run observability are unaffected except where a workspace-admin surface opens a tenant-owned record and must apply the same tenant-owned lookup rule. + +**Constitution alignment (RBAC-UX):** This feature changes authorization behavior in tenant-bound `/admin/t/{tenant}/...` flows and in workspace-admin canonical viewers under `/admin/...` that open tenant-owned records. Cross-plane broadening is forbidden. Non-members and users not entitled to the relevant tenant scope remain deny-as-not-found. Users already in the correct scope but lacking a required capability remain forbidden for protected actions. Authorization must be enforced server-side for list queries, record lookup, global search, row actions, bulk actions, and relation-manager actions. The canonical capability registry remains the only capability source. Global search must remain tenant-safe and non-member-safe. Existing destructive actions remain confirmation-gated; this feature does not introduce a new destructive action family. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake or synchronous outbound login behavior is involved. + +**Constitution alignment (BADGE-001):** If in-scope resources show status or outcome badges, those meanings remain centralized. This feature changes which records are reachable, not the meaning of their badges. + +**Constitution alignment (UI-NAMING-001):** Operator-facing verbs remain existing resource verbs such as `View`, `Edit`, `Delete`, `Restore`, or domain-specific actions. No new vocabulary is needed. The important naming rule is that deny behavior must never leak foreign-tenant hints through button text, search labels, or empty-state copy. + +**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament Resources, RelationManagers, and Pages by standardizing how they query and resolve tenant-owned records. The Action Surface Contract remains satisfied because visible actions stay domain-owned and this feature changes only scope correctness, action reachability, and regression expectations. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** In-scope Filament screens keep their current layouts. The UX obligation for this feature is that lists, detail pages, relation tables, and empty states reflect the true tenant scope consistently and never imply access to a foreign tenant record. + +### Functional Requirements + +- **FR-150-001**: The system MUST define one canonical query-entry rule for every in-scope tenant-owned model family. +- **FR-150-002**: In-scope tenant-owned resource lists MUST derive their records from the canonical tenant-owned query rule rather than from ad hoc page-local filtering. +- **FR-150-003**: In-scope direct record lookup and detail-page resolution MUST use the same tenant-owned query rule as the corresponding list flow. +- **FR-150-004**: In-scope row actions, bulk actions, and relation-manager actions MUST resolve their target records through the same tenant-owned query rule as the visible surface that exposed them. +- **FR-150-005**: The system MUST prevent any in-scope protected action from executing against a record owned by a foreign tenant, even when a forged record identifier is submitted. +- **FR-150-006**: Global search for in-scope tenant-owned resources MUST either obey the same tenant-owned query rule as list and detail flows or be disabled for that resource. +- **FR-150-007**: Workspace-admin canonical viewers that open tenant-owned records MUST verify entitlement to the record's owning tenant before any tenant-owned data is rendered. +- **FR-150-008**: Tenant-bound pages MUST treat the route tenant as the visible scope anchor and MUST NOT silently widen record lookup beyond that tenant. +- **FR-150-009**: When the operator lacks workspace membership or tenant entitlement for the target record, the system MUST return deny-as-not-found rather than a broader fallback or a revealing error. +- **FR-150-010**: When the operator is in the correct workspace and tenant scope but lacks the required capability for a protected action, the system MUST return forbidden semantics for that action. +- **FR-150-011**: The product MUST define which model families count as mandatory tenant-owned families for this canonical query rule in the first rollout slice. +- **FR-150-012**: The first rollout slice MUST cover at least one representative set of tier-1 tenant-owned resources across list, detail, row action, bulk action, relation-manager, and global-search paths. +- **FR-150-013**: In-scope relation managers and embedded tables MUST not use broader related-record lookups than the tenant-bound page that contains them. +- **FR-150-014**: Persisted table state, filters, and search inputs MUST NOT reintroduce access to foreign-tenant records when the current tenant scope changes. +- **FR-150-015**: The product MUST define a documented exception mechanism for legitimate edge cases where a surface is not tenant-owned even though it references tenant data. +- **FR-150-016**: New in-scope code MUST NOT introduce fresh page-local tenant filtering where the canonical tenant-owned query rule is required. +- **FR-150-017**: The feature MUST add focused regression coverage for wrong-tenant index, detail, row action, bulk action, and relation-manager scenarios. +- **FR-150-018**: The feature MUST add focused regression coverage for at least one positive in-scope path and one negative wrong-tenant path per representative tenant-owned family. +- **FR-150-019**: The feature MUST add a lightweight guardrail that helps detect new forbidden query patterns on in-scope tenant-owned surfaces without generating high-noise false positives. +- **FR-150-020**: The feature MUST leave a documented residual inventory of tenant-owned surfaces that are already compliant, explicitly exempt, or intentionally deferred. + +### Non-Goals + +- Redesigning the workspace and tenant panel information architecture +- Reworking workspace-owned monitoring surfaces that do not open tenant-owned records +- Introducing a new business domain, tenancy model, or permission model +- Replacing all ad hoc filters everywhere in the codebase in one pass +- Broadly rewriting already-compliant resources only for stylistic consistency + +### Assumptions + +- Specs 135 and 136 already established the broader canonical tenant-context direction for admin and tenant-panel flows. +- Spec 149 already covers execution-time reauthorization for queued mutation work; this feature is the complementary hardening layer for record lookup and action targeting. +- The product already has a meaningful set of tier-1 tenant-owned resources where wrong-tenant regressions are worth enforcing systematically. +- Existing destructive actions already have domain-level confirmation and audit behavior; this feature ensures they cannot reach foreign-tenant records. + +### Dependencies + +- Canonical tenant context direction from Specs 135 and 136 +- Existing workspace membership and tenant entitlement enforcement +- Existing tenant-owned Filament resources, relation managers, and canonical viewers that can be exercised through focused regression tests +- Existing global-search configuration for tenant-owned resources + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant-owned resources | Tenant-bound lists and detail pages under `/admin/t/{tenant}/...` | Existing header actions unchanged | Table rows and record detail links remain the inspect affordance | Existing domain actions only | Existing grouped bulk actions only | Existing empty states remain | Existing view actions only | Existing save and cancel semantics remain | Existing audit behavior only | This feature changes which records are reachable, not the action inventory. Row and bulk actions must use the same tenant-owned record source as the list. | +| Relation managers and embedded tenant-owned tables | Tenant-bound pages and record detail screens | Existing page actions unchanged | Embedded table rows and relation links remain the inspect affordance | Existing domain actions only | Existing grouped bulk actions only | Existing empty states remain | Existing view actions only | Existing save and cancel semantics remain | Existing audit behavior only | Related-record queries must not widen beyond the owning tenant of the page. | +| Workspace-admin canonical viewers for tenant-owned records | Direct record viewers and deep-link pages under `/admin/...` | Existing non-destructive header actions unchanged | Direct record view remains the inspect affordance | Existing domain actions only when entitled | None new | Existing safe not-found behavior remains | Existing view actions only | Existing save and cancel semantics remain | Existing audit behavior only | Exemption: this surface may open a tenant-owned record outside `/admin/t/{tenant}/...`, but only through explicit entitled record lookup, never through broader list-style fallback. | +| Global search entry points for tenant-owned resources | Global search results and linked resource destinations | None | Search result title and link | None new | None | Not applicable | Not applicable | Not applicable | No new audit event | Search remains allowed only where the result can follow the same tenant-owned lookup rule safely. | + +### Key Entities *(include if feature involves data)* + +- **Tenant-Owned Record**: Any record whose visibility and mutability are bound to a specific tenant inside a workspace. +- **Canonical Tenant-Owned Query Rule**: The single allowed way to list, resolve, and act on a tenant-owned record family. +- **Wrong-Tenant Access Path**: Any index, detail, search, row action, bulk action, or related-record path that attempts to reach a record outside the current entitled tenant scope. +- **Sensitive Tenant Action Path**: A protected mutation or high-impact action that must prove the target record belongs to the current entitled tenant scope before it executes. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-150-001**: In focused rollout coverage, 100% of covered tenant-owned resource lists return only records from the entitled tenant scope. +- **SC-150-002**: In focused wrong-tenant regression coverage, 100% of covered direct detail and deep-link attempts to foreign-tenant records resolve as deny-as-not-found. +- **SC-150-003**: In focused wrong-tenant mutation coverage, 0 covered row actions, bulk actions, or relation-manager actions execute against a foreign-tenant record. +- **SC-150-004**: In focused authorization coverage, 100% of covered in-scope capability denials return forbidden semantics without widening or hiding the underlying in-scope record lookup. +- **SC-150-005**: In the first rollout slice, every covered tenant-owned family passes the same positive-path and wrong-tenant-path regression matrix for index, detail, and action behavior. +- **SC-150-006**: The agreed guardrail flags new forbidden tenant-owned query patterns on covered surfaces before release without requiring a full codebase security audit. + +## Risks + +- Fixing only list queries while leaving direct record lookup or action targeting broader would create false confidence. +- An overly broad guardrail could become noisy and get ignored. +- Treating workspace-admin canonical viewers as ordinary tenant-bound lists would break legitimate deep-link use cases. +- Failing to define documented exemptions would push maintainers back toward ad hoc exceptions in code. + +## Final Direction + +Tenant-owned data must have one scope rule, not five slightly different ones. This feature defines a canonical query and lookup contract for tenant-owned records, then backs it with wrong-tenant regression coverage so list pages, deep links, relation managers, and protected actions all enforce the same tenant boundary. diff --git a/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/tasks.md b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/tasks.md new file mode 100644 index 0000000..0b23663 --- /dev/null +++ b/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/tasks.md @@ -0,0 +1,187 @@ +# Tasks: Tenant-Owned Query Canon and Wrong-Tenant Guards + +**Input**: Design documents from `/specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Tests are REQUIRED for this feature because it changes runtime authorization, query scoping, relation-manager behavior, and global-search safety in a Laravel/Pest codebase. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish the rollout inventory and shared scaffolding used by all stories. + +- [X] T001 Create the first-slice tenant-owned family inventory in `app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php` +- [X] T002 [P] Create shared tenant-owned scope scaffolding in `app/Support/WorkspaceIsolation/TenantOwnedQueryScope.php` and `app/Support/WorkspaceIsolation/TenantOwnedRecordResolver.php` +- [X] T003 [P] Create the feature guard baseline in `tests/Feature/Guards/TenantOwnedQueryGuardTest.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Put the canonical query and exception model in place before resource-specific work starts. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T004 Implement the canonical tenant-owned query contract in `app/Support/WorkspaceIsolation/TenantOwnedQueryScope.php`, `app/Support/WorkspaceIsolation/TenantOwnedRecordResolver.php`, and `app/Filament/Concerns/InteractsWithTenantOwnedRecords.php` +- [X] T005 [P] Wire panel-aware tenant resolution into the shared helper in `app/Filament/Concerns/ResolvesPanelTenantContext.php` and `app/Support/OperateHub/OperateHubShell.php` +- [X] T006 [P] Encode first-slice search posture and explicit scope exceptions in `app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php`, `app/Support/WorkspaceIsolation/TenantOwnedTables.php`, and `app/Filament/Concerns/ScopesGlobalSearchToTenant.php` +- [X] T007 Update the architectural guard allowlists for the shared tenant-owned helper entry points in `tests/Feature/Guards/AdminTenantResolverGuardTest.php` and `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` + +**Checkpoint**: The shared tenant-owned query canon exists, first-slice search posture and exceptions are explicit, and user story work can proceed in parallel. + +--- + +## Phase 3: User Story 1 - Trust tenant-owned lists and detail links (Priority: P1) 🎯 MVP + +**Goal**: Ensure tenant-owned lists, detail pages, direct links, and safe-search paths only resolve records from the entitled tenant scope. + +**Independent Test**: An operator entitled to two tenants can list and open only the correct tenant's records on covered surfaces, while wrong-tenant direct links resolve as 404 without leaking data. + +### Tests for User Story 1 + +- [X] T008 [P] [US1] Extend Entra group index, detail, and safe-search wrong-tenant coverage in `tests/Feature/Filament/EntraGroupAdminScopeTest.php` +- [X] T009 [P] [US1] Add covered tenant-owned list, detail, and persisted tenant-state parity tests in `tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php`, `tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` +- [X] T010 [P] [US1] Extend policy and policy-version search posture coverage in `tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php` and `tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php` + +### Implementation for User Story 1 + +- [X] T011 [US1] Migrate `app/Filament/Resources/EntraGroupResource.php` to the shared tenant-owned query and explicit record-resolution helper, and keep tenant-sensitive persisted table state aligned through `app/Support/Filament/CanonicalAdminTenantFilterState.php` +- [X] T012 [P] [US1] Migrate `app/Filament/Resources/BackupScheduleResource.php`, `app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php`, and `app/Filament/Resources/BackupSetResource.php` to the shared tenant-owned query helper and canonical tenant-state sync +- [X] T013 [P] [US1] Migrate `app/Filament/Resources/RestoreRunResource.php` and `app/Filament/Resources/InventoryItemResource.php` to the shared tenant-owned query helper while preserving tenant-safe persisted filter/search state through `app/Support/Filament/CanonicalAdminTenantFilterState.php` +- [X] T014 [P] [US1] Migrate `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`, and `app/Filament/Resources/PolicyVersionResource.php` to the shared tenant-owned query, search-posture helper, and tenant-state-safe list behavior +- [X] T015 [US1] Harden covered direct-view and canonical-viewer lookup paths in `routes/web.php`, `app/Policies/EntraGroupPolicy.php`, `app/Policies/BackupSchedulePolicy.php`, and `app/Policies/FindingPolicy.php` + +**Checkpoint**: Covered tenant-owned list, detail, and direct-link paths are consistent and independently testable without story 2 or story 3 work. + +--- + +## Phase 4: User Story 2 - Block wrong-tenant mutations and bulk actions (Priority: P1) + +**Goal**: Ensure row actions, bulk actions, and relation-manager actions cannot target foreign-tenant records, even with forged identifiers. + +**Independent Test**: Covered relation managers and protected actions reject foreign-tenant record IDs with no side effects, while in-scope capability denials still return 403 semantics. + +### Tests for User Story 2 + +- [X] T016 [P] [US2] Extend backup item row-action and bulk-action wrong-tenant coverage in `tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php` and `tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php` +- [X] T017 [P] [US2] Extend relation-manager and protected-action wrong-tenant coverage in `tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php` and `tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php` +- [X] T018 [P] [US2] Add representative protected-action 404 and 403 parity tests in `tests/Feature/Findings/FindingRbacTest.php` and `tests/Feature/RunAuthorizationTenantIsolationTest.php` + +### Implementation for User Story 2 + +- [X] T019 [US2] Anchor `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` actions to owner-record scope and reject forged foreign-tenant record IDs +- [X] T020 [P] [US2] Anchor `app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php` and `app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php` to owner-record scope and action-target congruence +- [X] T021 [US2] Normalize covered 404 versus 403 semantics in `app/Policies/BackupSchedulePolicy.php`, `app/Policies/FindingPolicy.php`, and `app/Policies/EntraGroupPolicy.php` +- [X] T022 [US2] Route covered protected mutation entry points through the shared tenant-owned record resolver in `app/Filament/Resources/BackupScheduleResource.php`, `app/Filament/Resources/FindingResource.php`, and `app/Filament/Resources/RestoreRunResource.php` + +**Checkpoint**: Covered mutation and relation-manager flows are independently safe against wrong-tenant execution and can be validated without story 3 guardrail work. + +--- + +## Phase 5: User Story 3 - Maintain one canonical query pattern (Priority: P2) + +**Goal**: Make the tenant-owned query canon reusable and enforceable for future resource work. + +**Independent Test**: The shared guardrail fails on new forbidden tenant-owned query patterns, and the covered resource families share the same regression matrix for index, detail, action, relation-manager, and search posture behavior. + +### Tests for User Story 3 + +- [X] T023 [P] [US3] Implement the tenant-owned query architectural guard in `tests/Feature/Guards/TenantOwnedQueryGuardTest.php` +- [X] T024 [P] [US3] Extend cross-resource wrong-tenant matrix and Filament action-surface contract coverage in `tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php`, `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, and `tests/Feature/Guards/ActionSurfaceContractTest.php` + +### Implementation for User Story 3 + +- [X] T025 [US3] Finalize the reusable tenant-owned family registry, scope-exception map, and residual rollout inventory in `app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php`, `app/Support/WorkspaceIsolation/TenantOwnedTables.php`, and `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/data-model.md` +- [X] T026 [US3] Integrate safe-search posture, Action Surface Contract verification, and explicit action-surface exemptions with `app/Filament/Concerns/ScopesGlobalSearchToTenant.php`, `app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, and `tests/Feature/Guards/ActionSurfaceContractTest.php` +- [X] T027 [US3] Remove remaining first-slice ad hoc tenant-owned query paths and align tenant-sensitive list-page state sync in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`, `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php` + +**Checkpoint**: The canonical query pattern is reusable, guard-protected, and independently enforceable for future resource rollouts. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Validate the completed rollout and keep the branch releasable. + +- [X] T028 [P] Run the focused validation suite from `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/quickstart.md` +- [X] T029 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies, can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories. +- **User Story 1 (Phase 3)**: Starts after Foundational completion. +- **User Story 2 (Phase 4)**: Starts after Foundational completion and may reuse the shared helper from US1, but remains independently testable. +- **User Story 3 (Phase 5)**: Starts after Foundational completion and should land after at least one representative family uses the shared canon. +- **Polish (Phase 6)**: Runs after the desired story phases are complete. + +### User Story Dependencies + +- **US1**: No dependency on other stories. This is the MVP slice. +- **US2**: Depends only on the foundational helper layer, not on US1 completion for conceptual correctness. +- **US3**: Depends on the foundational helper layer and benefits from US1 and US2 landing first so the guard inventory reflects real adoption. + +### Within Each User Story + +- Tests MUST be written and fail before implementation. +- Shared helper adoption and tenant-sensitive persisted-state handling must happen before resource-specific cleanups. +- Policy and action-target hardening must happen before relation-manager or bulk-action cleanup is considered complete. + +### Parallel Opportunities + +- T002 and T003 can run in parallel. +- T005 and T006 can run in parallel. +- US1 test tasks T008, T009, and T010 can run in parallel. +- US1 implementation tasks T012, T013, and T014 can run in parallel after T004 through T006. +- US2 test tasks T016, T017, and T018 can run in parallel. +- US2 implementation tasks T020 and T021 can run in parallel after T019 begins anchoring the relation-manager pattern. +- US3 tasks T023 and T024 can run in parallel. + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch the first-slice list/detail regressions together: +Task: "Extend Entra group index, detail, and safe-search wrong-tenant coverage in tests/Feature/Filament/EntraGroupAdminScopeTest.php" +Task: "Add covered tenant-owned list, detail, and persisted tenant-state parity tests in tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php, tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php, and tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php" +Task: "Extend policy and policy-version search posture coverage in tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php and tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php" + +# Then migrate independent resource groups in parallel: +Task: "Migrate app/Filament/Resources/BackupScheduleResource.php, app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php, and app/Filament/Resources/BackupSetResource.php to the shared tenant-owned query helper and canonical tenant-state sync" +Task: "Migrate app/Filament/Resources/RestoreRunResource.php and app/Filament/Resources/InventoryItemResource.php to the shared tenant-owned query helper while preserving tenant-safe persisted filter/search state" +Task: "Migrate app/Filament/Resources/FindingResource.php, app/Filament/Resources/FindingResource/Pages/ListFindings.php, app/Filament/Resources/PolicyResource.php, app/Filament/Resources/PolicyResource/Pages/ListPolicies.php, and app/Filament/Resources/PolicyVersionResource.php to the shared tenant-owned query, search-posture helper, and tenant-state-safe list behavior" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate the covered list, detail, direct-link, and safe-search paths. + +### Incremental Delivery + +1. Ship the shared helper and first-slice list/detail adoption. +2. Add protected-action and relation-manager hardening. +3. Add the architectural guard, action-surface verification, and residual inventory so future tenant-owned surfaces inherit the pattern. + +### Team Strategy + +1. One developer lands the foundational helper layer. +2. A second developer can migrate covered resources for US1 while another hardens relation managers for US2. +3. A final pass lands the guard, action-surface verification, and residual inventory once real first-slice adoption is in place. + +## Notes + +- [P] tasks are limited to work on different files with no incomplete dependency overlap. +- US1 is the recommended MVP because it proves the canonical lookup rule on visible read paths first. +- US2 focuses on side-effect safety after the read-path canon exists. +- US3 converts the implementation into a reusable and enforceable repository pattern. \ No newline at end of file diff --git a/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php b/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php index 8ba4406..a21ff0f 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Gate; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; function getBackupScheduleEmptyStateAction(Testable $component, string $name): ?Action { @@ -118,3 +119,56 @@ function getBackupScheduleEmptyStateAction(Testable $component, string $name): ? Gate::forUser($user)->authorize('forceDelete', $schedule->fresh()); })->toThrow(AuthorizationException::class); }); + +it('returns 404 and mutates nothing when forged foreign-tenant schedule keys are mounted', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $foreignTenant = Tenant::factory()->create(); + + $activeForeignSchedule = BackupSchedule::query()->create([ + 'tenant_id' => $foreignTenant->id, + 'name' => 'Foreign active schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $trashedForeignSchedule = BackupSchedule::query()->create([ + 'tenant_id' => $foreignTenant->id, + 'name' => 'Foreign trashed schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + $trashedForeignSchedule->delete(); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $activeComponent = Livewire::test(ListBackupSchedules::class); + + expect(fn () => $activeComponent->instance()->mountTableAction('archive', (string) $activeForeignSchedule->getKey())) + ->toThrow(NotFoundHttpException::class); + + $trashedComponent = Livewire::test(ListBackupSchedules::class) + ->filterTable(TrashedFilter::class, false); + + expect(fn () => $trashedComponent->instance()->mountTableAction('restore', (string) $trashedForeignSchedule->getKey())) + ->toThrow(NotFoundHttpException::class); + + expect(fn () => $trashedComponent->instance()->mountTableAction('forceDelete', (string) $trashedForeignSchedule->getKey())) + ->toThrow(NotFoundHttpException::class); + + expect($activeForeignSchedule->fresh()?->trashed())->toBeFalse(); + expect(BackupSchedule::withTrashed()->find($trashedForeignSchedule->getKey()))->not->toBeNull(); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php b/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php new file mode 100644 index 0000000..d89462e --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php @@ -0,0 +1,81 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => $name, + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); +} + +it('shows only operation runs belonging to the owner backup schedule', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $scheduleA = makeBackupScheduleForTenant($tenant, 'Schedule A'); + $scheduleB = makeBackupScheduleForTenant($tenant, 'Schedule B'); + + $runA = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'backup_schedule_run', + 'context' => ['backup_schedule_id' => (int) $scheduleA->getKey()], + ]); + $runB = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'backup_schedule_run', + 'context' => ['backup_schedule_id' => (int) $scheduleB->getKey()], + ]); + + Livewire::test(BackupScheduleOperationRunsRelationManager::class, [ + 'ownerRecord' => $scheduleA, + 'pageClass' => EditBackupSchedule::class, + ]) + ->assertCanSeeTableRecords([$runA]) + ->assertCanNotSeeTableRecords([$runB]); +}); + +it('returns 404 when a forged same-tenant run key is mounted on the wrong schedule relation manager', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $scheduleA = makeBackupScheduleForTenant($tenant, 'Schedule A'); + $scheduleB = makeBackupScheduleForTenant($tenant, 'Schedule B'); + + $foreignRun = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'backup_schedule_run', + 'context' => ['backup_schedule_id' => (int) $scheduleB->getKey()], + ]); + + $component = Livewire::test(BackupScheduleOperationRunsRelationManager::class, [ + 'ownerRecord' => $scheduleA, + 'pageClass' => EditBackupSchedule::class, + ]); + + expect(fn () => $component->instance()->mountTableAction('view', (string) $foreignRun->getKey())) + ->toThrow(NotFoundHttpException::class); +}); diff --git a/tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php b/tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php index 03e1609..fc6b516 100644 --- a/tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php +++ b/tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php @@ -93,6 +93,34 @@ function canonicalAdminTenantRequest(): Request ]); }); +it('preserves existing non-tenant filters when canonical tenant state seeds a tenant filter', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + Filament::setTenant(null, true); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ]); + session()->put('filament.provider.filters', [ + 'status' => ['value' => 'active'], + ]); + + app(CanonicalAdminTenantFilterState::class)->sync( + 'filament.provider.filters', + request: canonicalAdminTenantRequest(), + tenantFilterName: 'tenant', + tenantAttribute: 'external_id', + ); + + expect(session()->get('filament.provider.filters')) + ->toMatchArray([ + 'status' => ['value' => 'active'], + 'tenant' => ['value' => (string) $tenant->external_id], + ]); +}); + it('does not persist an empty filter bag when only canonical tenant state is synced', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/tests/Feature/Filament/EntraGroupAdminScopeTest.php b/tests/Feature/Filament/EntraGroupAdminScopeTest.php index 55da51d..9f434e2 100644 --- a/tests/Feature/Filament/EntraGroupAdminScopeTest.php +++ b/tests/Feature/Filament/EntraGroupAdminScopeTest.php @@ -3,11 +3,13 @@ declare(strict_types=1); use App\Filament\Resources\EntraGroupResource; +use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups; use App\Models\EntraGroup; use App\Models\Tenant; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; uses(RefreshDatabase::class); @@ -86,3 +88,46 @@ ->get(EntraGroupResource::getUrl('view', ['record' => $groupB], panel: 'admin')) ->assertNotFound(); }); + +it('keeps persisted admin group search inside the remembered canonical tenant after tenant changes', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $groupA = EntraGroup::factory()->for($tenantA)->create([ + 'display_name' => 'Shared Search Group', + ]); + + $groupB = EntraGroup::factory()->for($tenantB)->create([ + 'display_name' => 'Shared Search Group', + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + Livewire::actingAs($user)->test(ListEntraGroups::class) + ->searchTable('Shared Search') + ->assertCanSeeTableRecords([$groupA]) + ->assertCanNotSeeTableRecords([$groupB]); + + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantB->getKey(), + ]); + + Livewire::actingAs($user)->test(ListEntraGroups::class) + ->assertSet('tableSearch', 'Shared Search') + ->assertCanSeeTableRecords([$groupB]) + ->assertCanNotSeeTableRecords([$groupA]); +}); diff --git a/tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php b/tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php index 444a3d3..39969a1 100644 --- a/tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php +++ b/tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php @@ -3,6 +3,10 @@ declare(strict_types=1); use App\Filament\Resources\PolicyResource; +use App\Models\Policy; +use App\Models\Tenant; +use App\Support\Workspaces\WorkspaceContext; +use Filament\Facades\Filament; it('disables policy global search explicitly for the rollout', function (): void { $property = new \ReflectionProperty(PolicyResource::class, 'isGloballySearchable'); @@ -10,3 +14,25 @@ expect($property->getValue())->toBeFalse(); }); + +it('returns no policy global-search results even with a remembered canonical admin tenant', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + Policy::factory()->for($tenant)->create([ + 'display_name' => 'Admin search policy', + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ]); + + expect(PolicyResource::getGlobalSearchResults('Admin search policy')) + ->toHaveCount(0); +}); diff --git a/tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php b/tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php index 3fd7fde..f973a7f 100644 --- a/tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php +++ b/tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php @@ -3,6 +3,11 @@ declare(strict_types=1); use App\Filament\Resources\PolicyVersionResource; +use App\Models\Policy; +use App\Models\PolicyVersion; +use App\Models\Tenant; +use App\Support\Workspaces\WorkspaceContext; +use Filament\Facades\Filament; it('disables policy-version global search explicitly for the rollout', function (): void { $property = new \ReflectionProperty(PolicyVersionResource::class, 'isGloballySearchable'); @@ -10,3 +15,27 @@ expect($property->getValue())->toBeFalse(); }); + +it('returns no policy-version global-search results even with a remembered canonical admin tenant', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $policy = Policy::factory()->for($tenant)->create(); + + PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => 7, + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ]); + + expect(PolicyVersionResource::getGlobalSearchResults('7')) + ->toHaveCount(0); +}); diff --git a/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php b/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php index 6fef92a..11af10b 100644 --- a/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php +++ b/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions; use App\Models\Policy; use App\Models\PolicyVersion; @@ -39,3 +40,36 @@ ->assertCanSeeTableRecords([$versionA]) ->assertCanNotSeeTableRecords([$versionB]); }); + +it('returns not found for admin policy-version detail outside the remembered canonical tenant', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $policyA = Policy::factory()->for($tenantA)->create(); + $policyB = Policy::factory()->for($tenantB)->create(); + + $versionA = PolicyVersion::factory()->for($tenantA)->for($policyA)->create(); + $versionB = PolicyVersion::factory()->for($tenantB)->for($policyB)->create(); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + $session = [ + WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ], + ]; + + $this->withSession($session) + ->get(PolicyVersionResource::getUrl('view', ['record' => $versionA], panel: 'admin')) + ->assertSuccessful(); + + $this->withSession($session) + ->get(PolicyVersionResource::getUrl('view', ['record' => $versionB], panel: 'admin')) + ->assertNotFound(); +}); diff --git a/tests/Feature/Filament/TenantMakeCurrentTest.php b/tests/Feature/Filament/TenantMakeCurrentTest.php index 52929e5..d072eaa 100644 --- a/tests/Feature/Filament/TenantMakeCurrentTest.php +++ b/tests/Feature/Filament/TenantMakeCurrentTest.php @@ -5,6 +5,7 @@ use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; +use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -17,6 +18,7 @@ ]); $second = Tenant::factory()->create([ + 'workspace_id' => (int) $first->workspace_id, 'status' => 'active', ]); @@ -29,6 +31,7 @@ ]); Filament::setTenant($first, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $first->workspace_id); Livewire::test(ChooseTenant::class) ->call('selectTenant', $second->getKey()) diff --git a/tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php b/tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php new file mode 100644 index 0000000..9e4111e --- /dev/null +++ b/tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php @@ -0,0 +1,246 @@ + (int) $tenant->workspace_id, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ], + ]; +} + +dataset('tenant-owned-list-pages', [ + 'policy list' => [ + ListPolicies::class, + static fn (Tenant $tenant, string $label): Policy => Policy::factory()->for($tenant)->create([ + 'display_name' => $label, + ]), + ], + 'policy-version list' => [ + ListPolicyVersions::class, + static function (Tenant $tenant, string $label): PolicyVersion { + $policy = Policy::factory()->for($tenant)->create([ + 'display_name' => $label.' policy', + ]); + + return PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => random_int(1, 9999), + ]); + }, + ], + 'backup-schedule list' => [ + ListBackupSchedules::class, + static function (Tenant $tenant, string $label): BackupSchedule { + return BackupSchedule::create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => $label, + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => now()->addHour(), + ]); + }, + ], + 'backup-set list' => [ + ListBackupSets::class, + static fn (Tenant $tenant, string $label): BackupSet => BackupSet::factory()->for($tenant)->create([ + 'name' => $label, + ]), + ], + 'restore-run list' => [ + ListRestoreRuns::class, + static function (Tenant $tenant, string $label): RestoreRun { + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'name' => $label.' backup set', + ]); + + return RestoreRun::factory()->for($tenant)->for($backupSet)->create(); + }, + ], + 'inventory-item list' => [ + ListInventoryItems::class, + static fn (Tenant $tenant, string $label): InventoryItem => InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => $label, + ]), + ], + 'finding list' => [ + ListFindings::class, + static fn (Tenant $tenant, string $label): Finding => Finding::factory()->for($tenant)->create(), + ], + 'entra-group list' => [ + ListEntraGroups::class, + static fn (Tenant $tenant, string $label): EntraGroup => EntraGroup::factory()->for($tenant)->create([ + 'display_name' => $label, + ]), + ], +]); + +dataset('tenant-owned-detail-pages', [ + 'policy view' => [ + PolicyResource::class, + 'view', + static fn (Tenant $tenant, string $label): Policy => Policy::factory()->for($tenant)->create([ + 'display_name' => $label, + ]), + ], + 'policy-version view' => [ + PolicyVersionResource::class, + 'view', + static function (Tenant $tenant, string $label): PolicyVersion { + $policy = Policy::factory()->for($tenant)->create([ + 'display_name' => $label.' policy', + ]); + + return PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => random_int(1, 9999), + ]); + }, + ], + 'backup-set view' => [ + BackupSetResource::class, + 'view', + static fn (Tenant $tenant, string $label): BackupSet => BackupSet::factory()->for($tenant)->create([ + 'name' => $label, + ]), + ], + 'restore-run view' => [ + RestoreRunResource::class, + 'view', + static function (Tenant $tenant, string $label): RestoreRun { + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'name' => $label.' backup set', + ]); + + return RestoreRun::factory()->for($tenant)->for($backupSet)->create(); + }, + ], + 'inventory-item view' => [ + InventoryItemResource::class, + 'view', + static fn (Tenant $tenant, string $label): InventoryItem => InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => $label, + ]), + ], + 'finding view' => [ + FindingResource::class, + 'view', + static fn (Tenant $tenant, string $label): Finding => Finding::factory()->for($tenant)->create(), + ], + 'entra-group view' => [ + EntraGroupResource::class, + 'view', + static fn (Tenant $tenant, string $label): EntraGroup => EntraGroup::factory()->for($tenant)->create([ + 'display_name' => $label, + ]), + ], + 'backup-schedule edit' => [ + BackupScheduleResource::class, + 'edit', + static function (Tenant $tenant, string $label): BackupSchedule { + return BackupSchedule::create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => $label, + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => now()->addHour(), + ]); + }, + ], +]); + +it('scopes covered tenant-owned admin lists to the remembered canonical tenant', function (string $pageClass, Closure $makeRecord): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $allowed = $makeRecord($tenantA, 'Allowed record'); + $blocked = $makeRecord($tenantB, 'Blocked record'); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + Livewire::actingAs($user)->test($pageClass) + ->assertCanSeeTableRecords([$allowed]) + ->assertCanNotSeeTableRecords([$blocked]); +})->with('tenant-owned-list-pages'); + +it('returns not found for covered tenant-owned admin detail pages outside the remembered canonical tenant', function (string $resourceClass, string $page, Closure $makeRecord): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $allowed = $makeRecord($tenantA, 'Allowed record'); + $blocked = $makeRecord($tenantB, 'Blocked record'); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + $session = tenantOwnedAdminSession($tenantA); + + $this->withSession($session) + ->get($resourceClass::getUrl($page, ['record' => $allowed], panel: 'admin')) + ->assertSuccessful(); + + $this->withSession($session) + ->get($resourceClass::getUrl($page, ['record' => $blocked], panel: 'admin')) + ->assertNotFound(); +})->with('tenant-owned-detail-pages'); diff --git a/tests/Feature/Findings/FindingAdminTenantParityTest.php b/tests/Feature/Findings/FindingAdminTenantParityTest.php index 522248b..f3bbb2c 100644 --- a/tests/Feature/Findings/FindingAdminTenantParityTest.php +++ b/tests/Feature/Findings/FindingAdminTenantParityTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource\Pages\ListFindings; use App\Models\Finding; use App\Models\Tenant; @@ -42,3 +43,33 @@ ->assertCanSeeTableRecords([$findingA]) ->assertCanNotSeeTableRecords([$findingB]); }); + +it('returns not found for admin finding detail outside the remembered canonical tenant', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'manager'); + $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager'); + + $findingA = Finding::factory()->for($tenantA)->create(); + $findingB = Finding::factory()->for($tenantB)->create(); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + $session = [ + WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ], + ]; + + $this->withSession($session) + ->get(FindingResource::getUrl('view', ['record' => $findingA], panel: 'admin')) + ->assertSuccessful(); + + $this->withSession($session) + ->get(FindingResource::getUrl('view', ['record' => $findingB], panel: 'admin')) + ->assertNotFound(); +}); diff --git a/tests/Feature/Findings/FindingRbacTest.php b/tests/Feature/Findings/FindingRbacTest.php index d7f6976..ac4b401 100644 --- a/tests/Feature/Findings/FindingRbacTest.php +++ b/tests/Feature/Findings/FindingRbacTest.php @@ -64,3 +64,22 @@ expect($triaged->status)->toBe(Finding::STATUS_TRIAGED); }); + +it('returns 404 and mutates nothing when a forged foreign-tenant finding action is mounted', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $foreignTenant = \App\Models\Tenant::factory()->create(); + $foreignFinding = Finding::factory()->for($foreignTenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $component = Livewire::test(ListFindings::class); + + expect(fn () => $component->instance()->mountTableAction('triage', (string) $foreignFinding->getKey())) + ->toThrow(NotFoundHttpException::class); + + expect($foreignFinding->fresh()?->status)->toBe(Finding::STATUS_NEW); +}); diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index 5907582..951a351 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -27,6 +27,7 @@ use App\Support\Ui\ActionSurface\ActionSurfaceValidator; use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; +use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; @@ -173,6 +174,54 @@ } }); +it('requires every first-slice tenant-owned resource to be discovered without relying on baseline action-surface exemptions', function (): void { + $components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents()) + ->keyBy('className'); + + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) { + $resourceClass = $family['resource']; + + expect($components->has($resourceClass)) + ->toBeTrue("{$familyName} resource should be discoverable by the action-surface validator."); + + $hasDeclaration = method_exists($resourceClass, 'actionSurfaceDeclaration'); + $hasBaselineExemption = $baselineExemptions->hasClass($resourceClass); + + expect($hasDeclaration || $hasBaselineExemption) + ->toBeTrue("{$familyName} resource must either define actionSurfaceDeclaration() or carry an explicit baseline exemption."); + + if ($hasDeclaration) { + expect($hasBaselineExemption) + ->toBeFalse("{$familyName} resource should not keep a stale baseline exemption once actionSurfaceDeclaration() exists."); + + continue; + } + + expect(trim((string) $baselineExemptions->reasonForClass($resourceClass))) + ->not->toBe('', "{$familyName} resource baseline exemption reason must stay explicit."); + } +}); + +it('keeps first-slice tenant-owned action-surface exemptions registry-backed and explicit', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach (TenantOwnedModelFamilies::actionSurfaceBaselineExemptions() as $className => $reason) { + expect($baselineExemptions->reasonForClass($className)) + ->toBe($reason); + } + + foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) { + if ($family['action_surface'] !== 'baseline_exemption') { + continue; + } + + expect(trim($family['action_surface_reason'])) + ->not->toBe('', "{$familyName} baseline exemption reason must stay explicit in the registry."); + } +}); + it('documents the guided alert delivery empty state without introducing a list-header CTA', function (): void { $declaration = AlertDeliveryResource::actionSurfaceDeclaration(); diff --git a/tests/Feature/Guards/AdminTenantResolverGuardTest.php b/tests/Feature/Guards/AdminTenantResolverGuardTest.php index d8d8292..525b6a2 100644 --- a/tests/Feature/Guards/AdminTenantResolverGuardTest.php +++ b/tests/Feature/Guards/AdminTenantResolverGuardTest.php @@ -41,7 +41,6 @@ function adminTenantResolverExceptionFiles(): array 'app/Filament/Pages/ChooseTenant.php', 'app/Http/Controllers/SelectTenantController.php', 'app/Support/Middleware/EnsureFilamentTenantSelected.php', - 'app/Filament/Resources/EntraGroupResource.php', 'app/Filament/Concerns/ResolvesPanelTenantContext.php', ]; } @@ -82,10 +81,16 @@ function adminTenantResolverExceptionFiles(): array } }); -it('keeps the mixed-surface entra group resource explicit about admin and tenant-panel resolution', function (): void { +it('keeps the shared panel resolver explicit about admin and tenant-panel resolution', function (): void { + $resolverContents = file_get_contents(base_path('app/Filament/Concerns/ResolvesPanelTenantContext.php')); $contents = file_get_contents(base_path('app/Filament/Resources/EntraGroupResource.php')); + expect($resolverContents)->not->toBeFalse() + ->and($resolverContents)->toContain('tenantOwnedPanelContext(request())') + ->and($resolverContents)->toContain('Tenant::current()'); + expect($contents)->not->toBeFalse() - ->and($contents)->toContain('activeEntitledTenant(request())') - ->and($contents)->toContain('Tenant::current()'); + ->and($contents)->toContain('use ResolvesPanelTenantContext;') + ->and($contents)->not->toContain('activeEntitledTenant(request())') + ->and($contents)->not->toContain('Tenant::current()'); }); diff --git a/tests/Feature/Guards/FilamentTableStandardsGuardTest.php b/tests/Feature/Guards/FilamentTableStandardsGuardTest.php index 8d7f45f..daed4d5 100644 --- a/tests/Feature/Guards/FilamentTableStandardsGuardTest.php +++ b/tests/Feature/Guards/FilamentTableStandardsGuardTest.php @@ -176,6 +176,14 @@ 'CanonicalAdminTenantFilterState::class', '->sync(', ], + 'app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php' => [ + 'CanonicalAdminTenantFilterState::class', + '->sync(', + ], + 'app/Filament/Resources/PolicyResource/Pages/ListPolicies.php' => [ + 'CanonicalAdminTenantFilterState::class', + '->sync(', + ], 'app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php' => [ 'CanonicalAdminTenantFilterState::class', '->sync(', diff --git a/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php index e61dcb3..2b86bd9 100644 --- a/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php +++ b/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php @@ -114,3 +114,26 @@ "Ad-hoc Filament auth patterns found (remove allowlist entries as you migrate):\n".implode("\n", $hits) ); }); + +it('keeps shared tenant-owned helper entry points free of ad-hoc authorization patterns', function (): void { + $sharedEntryPoints = [ + 'app/Filament/Concerns/InteractsWithTenantOwnedRecords.php', + 'app/Filament/Concerns/ResolvesPanelTenantContext.php', + ]; + + $forbiddenPatterns = [ + '/\\bGate::\\b/', + '/\\babort_(?:if|unless)\\b/', + ]; + + foreach ($sharedEntryPoints as $relativePath) { + $contents = file_get_contents(base_path($relativePath)); + + expect($contents)->not->toBeFalse(); + + foreach ($forbiddenPatterns as $pattern) { + expect(preg_match($pattern, (string) $contents)) + ->toBe(0, "Shared tenant-owned helper entry point should stay free of ad-hoc auth patterns: {$relativePath}"); + } + } +}); diff --git a/tests/Feature/Guards/TenantOwnedQueryGuardTest.php b/tests/Feature/Guards/TenantOwnedQueryGuardTest.php new file mode 100644 index 0000000..6d54d17 --- /dev/null +++ b/tests/Feature/Guards/TenantOwnedQueryGuardTest.php @@ -0,0 +1,108 @@ +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(); + } +}); diff --git a/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php b/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php index a7910ad..7d72353 100644 --- a/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php +++ b/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php @@ -2,9 +2,16 @@ declare(strict_types=1); +use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\OperationRunResource; +use App\Filament\Resources\PolicyResource; +use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\TenantResource; +use App\Models\EntraGroup; +use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -48,3 +55,51 @@ function adminGlobalSearchTitles($results): array it('keeps operation runs out of admin global search regardless of remembered context state', function (): void { expect(OperationRunResource::canGloballySearch())->toBeFalse(); }); + +it('keeps representative first-slice admin global-search behavior aligned to the family registry postures', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + EntraGroup::factory()->for($tenantA)->create([ + 'display_name' => 'Registry Search Group', + ]); + + EntraGroup::factory()->for($tenantB)->create([ + 'display_name' => 'Registry Search Group', + ]); + + $policy = Policy::factory()->for($tenantA)->create([ + 'display_name' => 'Registry Search Policy', + ]); + + PolicyVersion::factory()->for($tenantA)->for($policy)->create([ + 'version_number' => 77, + ]); + + $this->actingAs($user); + + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + expect(TenantOwnedModelFamilies::searchPostureForModel(EntraGroup::class))->toBe('scoped'); + expect(TenantOwnedModelFamilies::searchPostureForModel(Policy::class))->toBe('disabled'); + expect(TenantOwnedModelFamilies::searchPostureForModel(PolicyVersion::class))->toBe('disabled'); + + expect(adminGlobalSearchTitles(EntraGroupResource::getGlobalSearchResults('Registry Search'))) + ->toBe(['Registry Search Group']); + + expect(PolicyResource::getGlobalSearchResults('Registry Search Policy'))->toHaveCount(0); + expect(PolicyVersionResource::getGlobalSearchResults('77'))->toHaveCount(0); +}); diff --git a/tests/Feature/Rbac/AdminTenantOwnedPolicyContextTest.php b/tests/Feature/Rbac/AdminTenantOwnedPolicyContextTest.php new file mode 100644 index 0000000..5994e86 --- /dev/null +++ b/tests/Feature/Rbac/AdminTenantOwnedPolicyContextTest.php @@ -0,0 +1,167 @@ +create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $groupA = EntraGroup::factory()->for($tenantA)->create(); + $groupB = EntraGroup::factory()->for($tenantB)->create(); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + expect(Gate::forUser($user)->allows('view', $groupA))->toBeTrue(); + expect(Gate::forUser($user)->allows('view', $groupB))->toBeFalse(); + + try { + Gate::forUser($user)->authorize('view', $groupB); + + $this->fail('Expected canonical wrong-tenant group authorization to be denied.'); + } catch (AuthorizationException $exception) { + expect($exception->status())->toBe(404); + } +}); + +it('keeps BackupSchedulePolicy scoped to the remembered canonical admin tenant', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $scheduleA = BackupSchedule::create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'name' => 'Allowed schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'name' => 'Blocked schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '11:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + expect(Gate::forUser($user)->allows('view', $scheduleA))->toBeTrue(); + expect(Gate::forUser($user)->allows('view', $scheduleB))->toBeFalse(); + + try { + Gate::forUser($user)->authorize('delete', $scheduleB); + + $this->fail('Expected canonical wrong-tenant schedule authorization to be denied.'); + } catch (AuthorizationException $exception) { + expect($exception->status())->toBe(404); + } +}); + +it('keeps FindingPolicy scoped to the remembered canonical admin tenant', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'manager'); + $tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager'); + + $findingA = Finding::factory()->for($tenantA)->create(); + $findingB = Finding::factory()->for($tenantB)->create(); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + expect(Gate::forUser($user)->allows('view', $findingA))->toBeTrue(); + expect(Gate::forUser($user)->allows('view', $findingB))->toBeFalse(); + + try { + Gate::forUser($user)->authorize('triage', $findingB); + + $this->fail('Expected canonical wrong-tenant finding authorization to be denied.'); + } catch (AuthorizationException $exception) { + expect($exception->status())->toBe(404); + } +}); + +it('keeps in-scope capability denials forbidden while wrong-tenant denials stay not found', function (): void { + $tenant = Tenant::factory()->create(); + [$readonly, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $schedule = BackupSchedule::create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Readonly schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $finding = Finding::factory()->for($tenant)->create(); + + $this->actingAs($readonly); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ]); + + expect(fn () => Gate::forUser($readonly)->authorize('delete', $schedule)) + ->toThrow(AuthorizationException::class); + + expect(fn () => Gate::forUser($readonly)->authorize('triage', $finding)) + ->toThrow(AuthorizationException::class); +}); diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php index 6729529..74b4f9f 100644 --- a/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php +++ b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php @@ -8,13 +8,16 @@ use App\Filament\Resources\PolicyVersionResource; use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\User; use App\Models\WorkspaceMembership; use Filament\Actions\Action; use Filament\Facades\Filament; +use Illuminate\Support\Facades\Queue; use Livewire\Livewire; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; it('returns 404 for non-members before capability checks on backup item actions', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); @@ -118,3 +121,35 @@ && $action->getUrl() === PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant); }, $backupItem); }); + +it('returns 404 and queues nothing when a forged foreign-tenant row action record is submitted', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $foreignTenant = \App\Models\Tenant::factory()->create(); + $foreignBackupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $foreignTenant->getKey(), + ]); + $foreignBackupItem = BackupItem::factory()->for($foreignBackupSet)->for($foreignTenant)->create(); + + $component = Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]); + + expect(fn () => $component->instance()->mountTableAction('remove', (string) $foreignBackupItem->getKey())) + ->toThrow(NotFoundHttpException::class); + + Queue::assertNothingPushed(); + + expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php index 6bc9e6c..7eb9fcf 100644 --- a/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php +++ b/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php @@ -4,12 +4,16 @@ use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; +use App\Jobs\RemovePoliciesFromBackupSetJob; use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use Filament\Actions\Action; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; use Livewire\Livewire; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; uses(RefreshDatabase::class); @@ -95,4 +99,43 @@ ->assertTableActionHidden('addPolicies') ->assertTableBulkActionHidden('bulk_remove'); }); + + it('returns 404 and queues nothing when a forged foreign-tenant bulk selection is submitted', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Primary backup', + ]); + + $inScopeItem = BackupItem::factory()->for($backupSet)->for($tenant)->create(); + + $foreignTenant = \App\Models\Tenant::factory()->create(); + $foreignBackupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $foreignTenant->getKey(), + 'name' => 'Foreign backup', + ]); + $foreignBackupItem = BackupItem::factory()->for($foreignBackupSet)->for($foreignTenant)->create(); + + $component = Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertTableBulkActionVisible('bulk_remove') + ->assertTableBulkActionEnabled('bulk_remove', [$inScopeItem]); + + expect(fn () => $component->instance()->mountTableBulkAction('bulk_remove', [(string) $inScopeItem->getKey(), (string) $foreignBackupItem->getKey()])) + ->toThrow(NotFoundHttpException::class); + + Queue::assertNothingPushed(); + Queue::assertNotPushed(RemovePoliciesFromBackupSetJob::class); + + expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse(); + }); }); diff --git a/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php index e5c12b3..ed4bc16 100644 --- a/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php +++ b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php @@ -7,6 +7,7 @@ use App\Models\InventoryItem; use App\Models\Tenant; use App\Models\User; +use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Livewire\Livewire; @@ -104,4 +105,57 @@ ->assertCanSeeTableRecords([$tenantAStale]) ->assertCanNotSeeTableRecords([$tenantAFresh, $tenantBStale]); }); + + it('keeps persisted admin inventory search and filters inside the remembered canonical tenant after tenant changes', function () { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $tenantARecord = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'display_name' => 'Shared Windows Device', + 'platform' => 'windows', + 'last_seen_at' => now()->subDays(3), + ]); + + $tenantBRecord = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'display_name' => 'Shared Windows Device', + 'platform' => 'windows', + 'last_seen_at' => now()->subDays(3), + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + Livewire::actingAs($user)->test(ListInventoryItems::class) + ->searchTable('Shared Windows') + ->filterTable('platform', 'windows') + ->filterTable('stale', '1') + ->assertCanSeeTableRecords([$tenantARecord]) + ->assertCanNotSeeTableRecords([$tenantBRecord]); + + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantB->getKey(), + ]); + + Livewire::actingAs($user)->test(ListInventoryItems::class) + ->assertSet('tableSearch', 'Shared Windows') + ->assertSet('tableFilters.platform.value', 'windows') + ->assertSet('tableFilters.stale.value', '1') + ->assertCanSeeTableRecords([$tenantBRecord]) + ->assertCanNotSeeTableRecords([$tenantARecord]); + }); }); diff --git a/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php index a0b6755..b36d100 100644 --- a/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php +++ b/tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php @@ -6,9 +6,11 @@ use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\RestoreRun; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; uses(RefreshDatabase::class); @@ -90,4 +92,36 @@ ->call('$refresh') ->assertTableActionHidden('restore_to_intune', $version); }); + + it('returns 404 and starts no restore when a forged foreign-tenant version key is mounted', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $foreignTenant = \App\Models\Tenant::factory()->create(); + $foreignPolicy = Policy::factory()->create([ + 'tenant_id' => $foreignTenant->id, + ]); + $foreignVersion = PolicyVersion::factory()->create([ + 'tenant_id' => $foreignTenant->id, + 'policy_id' => $foreignPolicy->id, + 'metadata' => [], + ]); + + $component = Livewire::actingAs($user) + ->test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]); + + expect(fn () => $component->instance()->mountTableAction('restore_to_intune', (string) $foreignVersion->getKey())) + ->toThrow(NotFoundHttpException::class); + + expect(RestoreRun::query()->doesntExist())->toBeTrue(); + }); }); diff --git a/tests/Feature/RunAuthorizationTenantIsolationTest.php b/tests/Feature/RunAuthorizationTenantIsolationTest.php index 398187a..dcb10a8 100644 --- a/tests/Feature/RunAuthorizationTenantIsolationTest.php +++ b/tests/Feature/RunAuthorizationTenantIsolationTest.php @@ -3,7 +3,10 @@ declare(strict_types=1); use App\Filament\Pages\Monitoring\Operations; +use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns; +use App\Models\BackupSet; use App\Models\OperationRun; +use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; @@ -206,3 +209,39 @@ ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) ->assertForbidden(); }); + +test('tenant-scoped restore run actions return 404 for forged foreign-tenant run keys', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + ]); + + $foreignRun = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'owner@example.com', + ]); + + Filament::setTenant($tenantA, true); + + $component = Livewire::actingAs($user) + ->test(ListRestoreRuns::class); + + expect(fn () => $component->instance()->mountTableAction('archive', (string) $foreignRun->getKey())) + ->toThrow(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + expect($foreignRun->fresh()?->trashed())->toBeFalse(); +}); diff --git a/tests/Unit/Support/WorkspaceIsolation/TenantOwnedQueryScopeTest.php b/tests/Unit/Support/WorkspaceIsolation/TenantOwnedQueryScopeTest.php new file mode 100644 index 0000000..92989ff --- /dev/null +++ b/tests/Unit/Support/WorkspaceIsolation/TenantOwnedQueryScopeTest.php @@ -0,0 +1,111 @@ +create(); + $foreignTenant = Tenant::factory()->create(); + + $ownedRecord = Policy::factory()->create(['tenant_id' => $tenant->getKey()]); + Policy::factory()->create(['tenant_id' => $foreignTenant->getKey()]); + + $records = app(TenantOwnedQueryScope::class) + ->apply(Policy::query(), $tenant) + ->pluck('id') + ->all(); + + expect($records)->toBe([(int) $ownedRecord->getKey()]); +}); + +it('fails closed when no tenant context is available', function (): void { + Tenant::factory()->count(2)->create()->each(function (Tenant $tenant): void { + Policy::factory()->create(['tenant_id' => $tenant->getKey()]); + }); + + $count = app(TenantOwnedQueryScope::class) + ->apply(Policy::query(), null) + ->count(); + + expect($count)->toBe(0); +}); + +it('resolves only records inside the already-scoped tenant query', function (): void { + $tenant = Tenant::factory()->create(); + $foreignTenant = Tenant::factory()->create(); + + $ownedRecord = Policy::factory()->create(['tenant_id' => $tenant->getKey()]); + $foreignRecord = Policy::factory()->create(['tenant_id' => $foreignTenant->getKey()]); + + $resolver = app(TenantOwnedRecordResolver::class); + $scopedQuery = app(TenantOwnedQueryScope::class)->apply(Policy::query(), $tenant); + + expect($resolver->resolve($scopedQuery, $ownedRecord)) + ->toBeInstanceOf(Model::class) + ->and($resolver->resolve($scopedQuery, $ownedRecord)?->is($ownedRecord))->toBeTrue(); + + expect($resolver->resolve($scopedQuery, $foreignRecord))->toBeNull(); +}); + +it('lets the shared filament trait expose the scoped query and resolver helpers', function (): void { + $tenant = Tenant::factory()->create(); + $foreignTenant = Tenant::factory()->create(); + + $ownedRecord = Policy::factory()->create(['tenant_id' => $tenant->getKey()]); + Policy::factory()->create(['tenant_id' => $foreignTenant->getKey()]); + + TestTenantOwnedPolicyResource::fakeTenant($tenant); + + expect(TestTenantOwnedPolicyResource::queryViaTrait()->pluck('id')->all()) + ->toBe([(int) $ownedRecord->getKey()]); + + expect(TestTenantOwnedPolicyResource::resolveViaTrait($ownedRecord)?->is($ownedRecord)) + ->toBeTrue(); +}); + +class TestTenantOwnedPolicyResource extends Resource +{ + use InteractsWithTenantOwnedRecords; + + protected static ?string $model = Policy::class; + + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + + protected static ?Tenant $fakeTenant = null; + + public static function fakeTenant(?Tenant $tenant): void + { + static::$fakeTenant = $tenant; + } + + public static function queryViaTrait() + { + return static::getTenantOwnedEloquentQuery(); + } + + public static function resolveViaTrait(Model|int|string|null $record): ?Model + { + return static::resolveTenantOwnedRecord($record); + } + + protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant + { + return static::$fakeTenant; + } + + public static function form(Schema $schema): Schema + { + return $schema; + } +}