From 2bf5de4663b480eb631c4197e48034984fb90a3f Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 11 Feb 2026 13:02:03 +0000 Subject: [PATCH] 085-tenant-operate-hub (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Consolidates the “Tenant Operate Hub” work (Spec 085) and the follow-up adjustments from the 086 session merge into a single branch ready to merge into dev. Primary focus: stabilize Ops/Operate Hub UX flows, tighten/align authorization semantics, and make the full Sail test suite green. Key Changes Ops UX / Verification Readonly members can view verification operation runs (reports) while starting verification remains restricted. Normalized failure reason-code handling and aligned UX expectations with the provider reason-code taxonomy. Onboarding wizard UX “Start verification” CTA is hidden while a verification run is active; “Refresh” is shown during in-progress runs. Treats provider_permission_denied as a blocking reason (while keeping legacy compatibility). Test + fixture hardening Standardized use of default provider connection fixtures in tests where sync/restore flows require it. Fixed multiple Filament URL/tenant-context test cases to avoid 404s and reduce tenancy routing brittleness. Policy sync / restore safety Enrollment configuration type collision classification tests now exercise the real sync path (with required provider connection present). Restore edge-case safety tests updated to reflect current provider-connection requirements. Testing vendor/bin/sail artisan test --compact (green) vendor/bin/sail bin pint --dirty (green) Notes Includes merged 086 session work already (no separate PR needed). Co-authored-by: Ahmed Darrazi Co-authored-by: Ahmed Darrazi Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/103 --- .github/agents/copilot-instructions.md | 8 +- ...TenantpilotDispatchDirectoryGroupsSync.php | 48 +- app/Filament/Pages/Monitoring/Alerts.php | 13 + app/Filament/Pages/Monitoring/AuditLog.php | 13 + app/Filament/Pages/Monitoring/Operations.php | 50 ++ .../TenantlessOperationRunViewer.php | 45 +- .../ManagedTenantOnboardingWizard.php | 8 +- .../Resources/BackupScheduleResource.php | 363 +++---------- ...upScheduleOperationRunsRelationManager.php | 96 ++++ app/Filament/Resources/BackupSetResource.php | 2 + .../Pages/ListEntraGroups.php | 44 +- .../Pages/ListEntraGroupSyncRuns.php | 77 --- .../Pages/ViewEntraGroupSyncRun.php | 13 + app/Filament/Resources/FindingResource.php | 2 + .../Resources/InventoryItemResource.php | 16 +- .../Pages/ListInventoryItems.php | 53 +- .../Resources/InventorySyncRunResource.php | 2 + .../Pages/ViewInventorySyncRun.php | 13 + .../Resources/OperationRunResource.php | 17 +- app/Filament/Resources/PolicyResource.php | 2 + .../Resources/PolicyVersionResource.php | 2 + .../Resources/ProviderConnectionResource.php | 90 +++- .../Pages/EditProviderConnection.php | 51 +- app/Filament/Resources/RestoreRunResource.php | 69 ++- app/Filament/Resources/TenantResource.php | 463 ++++++++-------- .../ClearTenantContextController.php | 10 +- .../Middleware/EnsureWorkspaceSelected.php | 13 + app/Jobs/EntraGroupSyncJob.php | 197 ++++--- app/Jobs/ExecuteRestoreRunJob.php | 20 +- app/Jobs/ProviderConnectionHealthCheckJob.php | 26 +- app/Jobs/RunBackupScheduleJob.php | 503 +++++++++++++++++- app/Jobs/RunInventorySyncJob.php | 222 ++++---- app/Jobs/SyncPoliciesJob.php | 2 +- app/Jobs/SyncRoleDefinitionsJob.php | 123 +++++ .../SyncRestoreRunToOperationRun.php | 31 +- app/Models/BackupSchedule.php | 11 + app/Models/EntraRoleDefinition.php | 24 + app/Models/InventoryItem.php | 5 + app/Models/RestoreRun.php | 5 + .../RunStatusChangedNotification.php | 2 +- app/Policies/OperationRunPolicy.php | 37 ++ app/Policies/ProviderConnectionPolicy.php | 19 +- app/Providers/Filament/AdminPanelProvider.php | 13 +- .../Filament/TenantPanelProvider.php | 19 +- .../BackupScheduleDispatcher.php | 47 +- .../Directory/EntraGroupSyncService.php | 44 +- .../Directory/RoleDefinitionsSyncService.php | 257 +++++++++ app/Services/Graph/GraphContractRegistry.php | 12 + .../Inventory/InventoryMissingService.php | 24 +- .../Inventory/InventorySyncService.php | 321 +++++------ app/Services/OperationRunService.php | 58 ++ .../Providers/ProviderOperationStartGate.php | 20 + .../EnsureFilamentTenantSelected.php | 24 +- app/Support/OperateHub/OperateHubShell.php | 131 +++++ app/Support/OperationCatalog.php | 2 + app/Support/OperationRunType.php | 2 + .../OperationRunCapabilityResolver.php | 32 ++ .../Providers/ProviderNextStepsRegistry.php | 4 + .../StaleQueuedVerificationReportFactory.php | 90 ++++ config/graph_contracts.php | 5 + .../factories/EntraRoleDefinitionFactory.php | 31 ++ ...ckup_schedule_scheduled_operation_runs.php | 48 ++ ...on_run_id_to_inventory_sync_runs_table.php | 34 ++ ..._run_id_to_entra_group_sync_runs_table.php | 34 ++ ...n_run_id_to_backup_schedule_runs_table.php | 34 ++ ...ration_run_id_to_inventory_items_table.php | 34 ++ ...operation_run_id_to_restore_runs_table.php | 33 ++ ...38_create_entra_role_definitions_table.php | 37 ++ phpunit.xml | 2 +- .../pages/monitoring/alerts.blade.php | 11 +- .../pages/monitoring/audit-log.blade.php | 11 +- .../filament/partials/context-bar.blade.php | 12 +- routes/web.php | 19 +- .../checklists/requirements.md | 34 ++ .../contracts/openapi.yaml | 76 +++ specs/085-tenant-operate-hub/data-model.md | 63 +++ specs/085-tenant-operate-hub/plan.md | 123 +++++ specs/085-tenant-operate-hub/quickstart.md | 39 ++ specs/085-tenant-operate-hub/research.md | 70 +++ specs/085-tenant-operate-hub/spec.md | 194 +++++++ specs/085-tenant-operate-hub/tasks.md | 192 +++++++ .../checklists/requirements.md | 34 ++ .../contracts/README.md | 41 ++ .../data-model.md | 131 +++++ .../plan.md | 109 ++++ .../quickstart.md | 42 ++ .../research.md | 87 +++ .../spec.md | 160 ++++++ .../tasks.md | 143 +++++ .../DispatchIdempotencyTest.php | 49 +- .../RunBackupScheduleJobTest.php | 62 +-- .../RunNowRetryActionsTest.php | 265 ++++++--- tests/Feature/BulkSyncPoliciesTest.php | 2 + .../DependencyExtractionFeatureTest.php | 4 + .../DeviceComplianceScriptPolicyTypeTest.php | 2 + .../NoLiveGraphOnRenderTest.php | 2 +- .../ScheduledSyncDispatchTest.php | 26 +- .../StartSyncFromGroupsPageTest.php | 20 +- .../Feature/DirectoryGroups/StartSyncTest.php | 11 +- .../SyncJobUpsertsGroupsTest.php | 34 +- .../SyncRetentionPurgeTest.php | 21 +- .../TenantGroupSelectorsDbOnlyTest.php | 48 ++ ...tSecurityIntentRestoreSanitizationTest.php | 2 + .../EndpointSecurityPolicyRestore023Test.php | 4 + tests/Feature/ExecuteRestoreRunJobTest.php | 57 +- .../ConditionalAccessPreviewOnlyTest.php | 2 + .../EnrollmentRestrictionsPreviewOnlyTest.php | 4 + .../EntraGroupSyncRunResourceTest.php | 74 +-- .../GroupPolicyConfigurationHydrationTest.php | 2 + .../GroupPolicyConfigurationRestoreTest.php | 2 + .../Filament/ODataTypeMismatchTest.php | 2 + tests/Feature/Filament/PolicyListingTest.php | 2 +- ...olicySettingsStandardRendersArraysTest.php | 2 +- .../PolicyVersionRestoreToIntuneTest.php | 2 + .../PolicyVersionRestoreViaWizardTest.php | 4 +- .../Filament/PolicyVersionSettingsTest.php | 2 +- .../Feature/Filament/RestoreExecutionTest.php | 6 + .../Filament/RestoreWizardGraphSafetyTest.php | 4 +- .../ScriptPoliciesNormalizedDisplayTest.php | 12 +- .../SettingsCatalogPolicyHydrationTest.php | 4 + .../SettingsCatalogPolicySyncTest.php | 26 +- ...gsCatalogRestoreApplySettingsPatchTest.php | 2 + .../Filament/SettingsCatalogRestoreTest.php | 43 +- ...SettingsCatalogSettingsTableRenderTest.php | 6 +- .../TenantPortfolioContextSwitchTest.php | 2 +- .../Feature/Filament/TenantRbacWizardTest.php | 21 +- ...enantRoleDefinitionsSelectorDbOnlyTest.php | 48 ++ .../WindowsUpdateProfilesRestoreTest.php | 44 +- .../Filament/WindowsUpdateRingRestoreTest.php | 32 +- .../Inventory/InventorySyncButtonTest.php | 98 ++-- .../Inventory/InventorySyncServiceTest.php | 166 ++++-- .../Inventory/RunInventorySyncJobTest.php | 68 +-- .../Feature/InventoryItemDependenciesTest.php | 20 +- .../AppProtectionPolicySyncFilteringTest.php | 2 + .../Jobs/PolicySyncIgnoredRevivalTest.php | 4 + .../ManagedTenantOnboardingWizardTest.php | 68 +++ .../AuthorizationSemanticsTest.php | 3 +- .../Monitoring/HeaderContextBarTest.php | 1 - .../Monitoring/MonitoringOperationsTest.php | 50 ++ .../OperationsCanonicalUrlsTest.php | 17 +- .../Monitoring/OperationsTenantScopeTest.php | 48 ++ tests/Feature/OperationRunServiceTest.php | 72 +++ .../Operations/LegacyRunRedirectTest.php | 88 +++ .../TenantlessOperationRunViewerTest.php | 43 ++ .../OpsUx/CanonicalViewRunLinksTest.php | 11 + .../Feature/OpsUx/FailureSanitizationTest.php | 2 +- tests/Feature/OpsUx/OperateHubShellTest.php | 264 +++++++++ .../RestoreExecuteOperationRunSyncTest.php | 2 + .../RestoreExecutionOperationRunSyncTest.php | 20 +- ...rollmentConfigurationTypeCollisionTest.php | 3 + .../PolicyVersionViewAssignmentsTest.php | 4 +- .../ProviderConnectionHealthCheckJobTest.php | 98 ++++ ...ProviderGatewayRuntimeSmokeSpec081Test.php | 32 +- .../EntraGroupSyncRunsUiEnforcementTest.php | 27 +- .../OnboardingWizardUiEnforcementTest.php | 12 +- .../Rbac/RegisterTenantAuthorizationTest.php | 9 + .../Rbac/TenantAdminAuthorizationTest.php | 5 + .../RestoreAssignmentApplicationTest.php | 6 + tests/Feature/RestoreAuditLoggingTest.php | 16 +- .../Feature/RestoreGraphErrorMetadataTest.php | 2 + tests/Feature/RestoreRunWizardExecuteTest.php | 18 +- tests/Feature/RestoreScopeTagMappingTest.php | 2 + .../RestoreUnknownPolicyTypeSafetyTest.php | 1 + tests/Feature/RunStartAuthorizationTest.php | 104 ++++ ...nitoringDoesNotMutateTenantContextTest.php | 44 ++ .../Spec085/DenyAsNotFoundSemanticsTest.php | 63 +++ .../Spec085/OperationsIndexHeaderTest.php | 83 +++ .../Spec085/RunDetailBackAffordanceTest.php | 93 ++++ ...enantNavigationMonitoringShortcutsTest.php | 40 ++ .../TenantRBAC/RoleDefinitionsSyncNowTest.php | 38 ++ .../TenantRBAC/TenantBootstrapAssignTest.php | 5 + .../TermsAndConditionsPolicyTypeTest.php | 2 + tests/Pest.php | 40 ++ tests/Unit/Auth/NoRoleStringChecksTest.php | 8 +- ...ProviderConnectionTenantResolutionTest.php | 43 ++ ...ionResourceLivewireTenantInferenceTest.php | 36 ++ ...erConnectionPolicyTenantResolutionTest.php | 36 ++ .../ProviderNextStepsRegistryTest.php | 60 +++ tests/Unit/TenantResourceConsentUrlTest.php | 32 ++ 179 files changed, 7036 insertions(+), 1723 deletions(-) create mode 100644 app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php create mode 100644 app/Jobs/SyncRoleDefinitionsJob.php create mode 100644 app/Models/EntraRoleDefinition.php create mode 100644 app/Services/Directory/RoleDefinitionsSyncService.php create mode 100644 app/Support/OperateHub/OperateHubShell.php create mode 100644 app/Support/Operations/OperationRunCapabilityResolver.php create mode 100644 app/Support/Verification/StaleQueuedVerificationReportFactory.php create mode 100644 database/factories/EntraRoleDefinitionFactory.php create mode 100644 database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php create mode 100644 database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php create mode 100644 database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php create mode 100644 database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php create mode 100644 database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php create mode 100644 database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php create mode 100644 database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php create mode 100644 specs/085-tenant-operate-hub/checklists/requirements.md create mode 100644 specs/085-tenant-operate-hub/contracts/openapi.yaml create mode 100644 specs/085-tenant-operate-hub/data-model.md create mode 100644 specs/085-tenant-operate-hub/plan.md create mode 100644 specs/085-tenant-operate-hub/quickstart.md create mode 100644 specs/085-tenant-operate-hub/research.md create mode 100644 specs/085-tenant-operate-hub/spec.md create mode 100644 specs/085-tenant-operate-hub/tasks.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/checklists/requirements.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/contracts/README.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/data-model.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/plan.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/quickstart.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/research.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/spec.md create mode 100644 specs/086-retire-legacy-runs-into-operation-runs/tasks.md create mode 100644 tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php create mode 100644 tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php create mode 100644 tests/Feature/Monitoring/MonitoringOperationsTest.php create mode 100644 tests/Feature/Operations/LegacyRunRedirectTest.php create mode 100644 tests/Feature/OpsUx/OperateHubShellTest.php create mode 100644 tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php create mode 100644 tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php create mode 100644 tests/Feature/Spec085/OperationsIndexHeaderTest.php create mode 100644 tests/Feature/Spec085/RunDetailBackAffordanceTest.php create mode 100644 tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php create mode 100644 tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php create mode 100644 tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php create mode 100644 tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php create mode 100644 tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php create mode 100644 tests/Unit/Providers/ProviderNextStepsRegistryTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 1af0594..5bc8751 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -24,6 +24,9 @@ ## Active Technologies - PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract) - PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification) - PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification) +- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub) +- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub) +- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub) - PHP 8.4.15 (feat/005-bulk-operations) @@ -43,9 +46,10 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4) +- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 +- 085-tenant-operate-hub: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail - 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` - 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 - - diff --git a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php index 8c684cc..54f88ce 100644 --- a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php +++ b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php @@ -2,10 +2,10 @@ namespace App\Console\Commands; +use App\Services\OperationRunService; use App\Models\Tenant; use Carbon\CarbonImmutable; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class TenantpilotDispatchDirectoryGroupsSync extends Command { @@ -46,27 +46,37 @@ public function handle(): int $skipped = 0; foreach ($tenants as $tenant) { - $inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => $slotKey, - 'status' => 'pending', - 'initiator_user_id' => null, - 'created_at' => $now, - 'updated_at' => $now, - ]); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'directory_groups.sync', + identityInputs: [ + 'selection_key' => $selectionKey, + 'slot_key' => $slotKey, + ], + context: [ + 'selection_key' => $selectionKey, + 'slot_key' => $slotKey, + 'trigger' => 'scheduled', + ], + initiator: null, + ); - if ($inserted === 1) { - $created++; - - dispatch(new \App\Jobs\EntraGroupSyncJob( - tenantId: $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: $slotKey, - )); - } else { + if (! $opRun->wasRecentlyCreated) { $skipped++; + continue; } + + $created++; + + dispatch(new \App\Jobs\EntraGroupSyncJob( + tenantId: $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: $slotKey, + runId: null, + operationRun: $opRun, + )); } $this->info(sprintf( diff --git a/app/Filament/Pages/Monitoring/Alerts.php b/app/Filament/Pages/Monitoring/Alerts.php index 31b4305..c09fbda 100644 --- a/app/Filament/Pages/Monitoring/Alerts.php +++ b/app/Filament/Pages/Monitoring/Alerts.php @@ -4,7 +4,9 @@ namespace App\Filament\Pages\Monitoring; +use App\Support\OperateHub\OperateHubShell; use BackedEnum; +use Filament\Actions\Action; use Filament\Pages\Page; use UnitEnum; @@ -25,4 +27,15 @@ class Alerts extends Page protected static ?string $title = 'Alerts'; protected string $view = 'filament.pages.monitoring.alerts'; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return app(OperateHubShell::class)->headerActions( + scopeActionName: 'operate_hub_scope_alerts', + returnActionName: 'operate_hub_return_alerts', + ); + } } diff --git a/app/Filament/Pages/Monitoring/AuditLog.php b/app/Filament/Pages/Monitoring/AuditLog.php index 31794c2..25b27d0 100644 --- a/app/Filament/Pages/Monitoring/AuditLog.php +++ b/app/Filament/Pages/Monitoring/AuditLog.php @@ -4,7 +4,9 @@ namespace App\Filament\Pages\Monitoring; +use App\Support\OperateHub\OperateHubShell; use BackedEnum; +use Filament\Actions\Action; use Filament\Pages\Page; use UnitEnum; @@ -25,4 +27,15 @@ class AuditLog extends Page protected static ?string $title = 'Audit Log'; protected string $view = 'filament.pages.monitoring.audit-log'; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return app(OperateHubShell::class)->headerActions( + scopeActionName: 'operate_hub_scope_audit_log', + returnActionName: 'operate_hub_return_audit_log', + ); + } } diff --git a/app/Filament/Pages/Monitoring/Operations.php b/app/Filament/Pages/Monitoring/Operations.php index f2a80a9..2f2b139 100644 --- a/app/Filament/Pages/Monitoring/Operations.php +++ b/app/Filament/Pages/Monitoring/Operations.php @@ -7,10 +7,14 @@ use App\Filament\Resources\OperationRunResource; use App\Filament\Widgets\Operations\OperationsKpiHeader; use App\Models\OperationRun; +use App\Models\Tenant; +use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; +use Filament\Actions\Action; +use Filament\Facades\Filament; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Filament\Pages\Page; @@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array ]; } + /** + * @return array + */ + protected function getHeaderActions(): array + { + $operateHubShell = app(OperateHubShell::class); + + $actions = [ + Action::make('operate_hub_scope_operations') + ->label($operateHubShell->scopeLabel(request())) + ->color('gray') + ->disabled(), + ]; + + $activeTenant = $operateHubShell->activeEntitledTenant(request()); + + if ($activeTenant instanceof Tenant) { + $actions[] = Action::make('operate_hub_back_to_tenant_operations') + ->label('Back to '.$activeTenant->name) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant)); + + $actions[] = Action::make('operate_hub_show_all_tenants') + ->label('Show all tenants') + ->color('gray') + ->action(function (): void { + Filament::setTenant(null, true); + + app(WorkspaceContext::class)->clearLastTenantId(request()); + + $this->removeTableFilter('tenant_id'); + + $this->redirect('/admin/operations'); + }); + } + + return $actions; + } + public function updatedActiveTab(): void { $this->resetPage(); @@ -61,6 +105,8 @@ public function table(Table $table): Table ->query(function (): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); + $query = OperationRun::query() ->with('user') ->latest('id') @@ -71,6 +117,10 @@ public function table(Table $table): Table ->when( ! $workspaceId, fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ) + ->when( + $activeTenant instanceof Tenant, + fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()), ); return $this->applyActiveTab($query); diff --git a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index f88beca..ce7c3aa 100644 --- a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -9,17 +9,20 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Auth\CapabilityResolver; +use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunLinks; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Schema; -use Illuminate\Support\Facades\Gate; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str; class TenantlessOperationRunViewer extends Page { + use AuthorizesRequests; + protected static bool $shouldRegisterNavigation = false; protected static bool $isDiscovered = false; @@ -37,16 +40,42 @@ class TenantlessOperationRunViewer extends Page */ protected function getHeaderActions(): array { + $operateHubShell = app(OperateHubShell::class); + $actions = [ - Action::make('refresh') - ->label('Refresh') - ->icon('heroicon-o-arrow-path') + Action::make('operate_hub_scope_run_detail') + ->label($operateHubShell->scopeLabel(request())) ->color('gray') - ->url(fn (): string => isset($this->run) - ? route('admin.operations.view', ['run' => (int) $this->run->getKey()]) - : route('admin.operations.index')), + ->disabled(), ]; + $activeTenant = $operateHubShell->activeEntitledTenant(request()); + + if ($activeTenant instanceof Tenant) { + $actions[] = Action::make('operate_hub_back_to_tenant_run_detail') + ->label('← Back to '.$activeTenant->name) + ->color('gray') + ->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant)); + + $actions[] = Action::make('operate_hub_show_all_operations') + ->label('Show all operations') + ->color('gray') + ->url(fn (): string => route('admin.operations.index')); + } else { + $actions[] = Action::make('operate_hub_back_to_operations') + ->label('Back to Operations') + ->color('gray') + ->url(fn (): string => route('admin.operations.index')); + } + + $actions[] = Action::make('refresh') + ->label('Refresh') + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->url(fn (): string => isset($this->run) + ? route('admin.operations.view', ['run' => (int) $this->run->getKey()]) + : route('admin.operations.index')); + if (! isset($this->run)) { return $actions; } @@ -87,7 +116,7 @@ public function mount(OperationRun $run): void abort(403); } - Gate::forUser($user)->authorize('view', $run); + $this->authorize('view', $run); $this->run = $run->loadMissing(['workspace', 'tenant', 'user']); } diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 69b9f43..cfac15a 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -347,7 +347,7 @@ public function content(Schema $schema): Schema SchemaActions::make([ Action::make('wizardStartVerification') ->label('Start verification') - ->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress') + ->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive()) ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START) ? null @@ -629,6 +629,10 @@ private function verificationStatus(): string return 'in_progress'; } + if ($run->outcome === OperationRunOutcome::Blocked->value) { + return 'blocked'; + } + if ($run->outcome === OperationRunOutcome::Succeeded->value) { return 'ready'; } @@ -658,7 +662,7 @@ private function verificationStatus(): string continue; } - if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied'], true)) { + if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) { return 'blocked'; } } diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index cd05f7e..2808630 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -4,10 +4,10 @@ use App\Exceptions\InvalidPolicyTypeException; use App\Filament\Resources\BackupScheduleResource\Pages; +use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager; use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\Tenant; use App\Models\User; use App\Rules\SupportedPolicyTypesRule; @@ -26,7 +26,6 @@ use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use BackedEnum; -use Carbon\CarbonImmutable; use DateTimeZone; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -50,7 +49,6 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -58,6 +56,10 @@ class BackupScheduleResource extends Resource { + protected static ?string $model = BackupSchedule::class; + + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; @@ -384,104 +386,38 @@ public static function table(Table $table): Table /** @var OperationRunService $operationRunService */ $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.run_now', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Run already queued') - ->body('This schedule already has a queued or running backup.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Run already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ + context: [ 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + 'trigger' => 'run_now', + ], + initiator: $userModel, + ); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'run_now', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); @@ -519,104 +455,38 @@ public static function table(Table $table): Table /** @var OperationRunService $operationRunService */ $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.retry', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $userModel - ); - - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Retry already queued') - ->body('This schedule already has a queued or running retry.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - return; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - Notification::make() - ->title('Retry already queued') - ->body('Please wait a moment and try again.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($operationRun, $tenant)), - ]) - ->send(); - - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule retry run.', - ], - ], - ); - - return; - } - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ + context: [ 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + 'trigger' => 'retry', + ], + initiator: $userModel, + ); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'retry', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); @@ -640,6 +510,7 @@ public static function table(Table $table): Table DeleteAction::make() ) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->destructive() ->apply(), ])->icon('heroicon-o-ellipsis-vertical'), ]) @@ -674,96 +545,52 @@ public static function table(Table $table): Table $bulkRun = null; - $createdRunIds = []; + $createdOperationRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.run_now', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $user + context: [ + 'backup_schedule_id' => (int) $record->getKey(), + 'trigger' => 'bulk_run_now', + ], + initiator: $user, ); - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule run.', - ], - ], - ); - - continue; - } - - $createdRunIds[] = (int) $run->id; - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + $createdOperationRunIds[] = (int) $operationRun->getKey(); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'bulk_run_now', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }, emitQueuedNotification: false); } $notification = Notification::make() ->title('Runs dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + ->body(sprintf('Queued %d run(s).', count($createdOperationRunIds))); - if (count($createdRunIds) === 0) { + if (count($createdOperationRunIds) === 0) { $notification->warning(); } else { $notification->success(); @@ -779,7 +606,7 @@ public static function table(Table $table): Table $notification->send(); - if (count($createdRunIds) > 0) { + if (count($createdOperationRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); } }) @@ -815,96 +642,52 @@ public static function table(Table $table): Table $bulkRun = null; - $createdRunIds = []; + $createdOperationRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { - $operationRun = $operationRunService->ensureRun( + $nonce = (string) Str::uuid(); + $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'backup_schedule.retry', - inputs: [ + identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), + 'nonce' => $nonce, ], - initiator: $user + context: [ + 'backup_schedule_id' => (int) $record->getKey(), + 'trigger' => 'bulk_retry', + ], + initiator: $user, ); - if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) { - continue; - } - - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - $run = null; - - for ($i = 0; $i < 5; $i++) { - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'user_id' => $userId, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - break; - } catch (UniqueConstraintViolationException) { - $scheduledFor = $scheduledFor->addMinute(); - } - } - - if (! $run instanceof BackupScheduleRun) { - $operationRunService->updateRun( - $operationRun, - status: 'completed', - outcome: 'failed', - summaryCounts: [ - 'backup_schedule_id' => (int) $record->getKey(), - ], - failures: [ - [ - 'code' => 'SCHEDULE_CONFLICT', - 'message' => 'Unable to queue a unique backup schedule retry run.', - ], - ], - ); - - continue; - } - - $createdRunIds[] = (int) $run->id; - - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_id' => (int) $record->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + $createdOperationRunIds[] = (int) $operationRun->getKey(); app(AuditLogger::class)->log( tenant: $tenant, action: 'backup_schedule.run_dispatched_manual', - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success', context: [ 'metadata' => [ 'backup_schedule_id' => $record->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), 'trigger' => 'bulk_retry', ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey())); }, emitQueuedNotification: false); } $notification = Notification::make() ->title('Retries dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + ->body(sprintf('Queued %d run(s).', count($createdOperationRunIds))); - if (count($createdRunIds) === 0) { + if (count($createdOperationRunIds) === 0) { $notification->warning(); } else { $notification->success(); @@ -920,7 +703,7 @@ public static function table(Table $table): Table $notification->send(); - if (count($createdRunIds) > 0) { + if (count($createdOperationRunIds) > 0) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); } }) @@ -931,6 +714,7 @@ public static function table(Table $table): Table DeleteBulkAction::make('bulk_delete') ) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) + ->destructive() ->apply(), ]), ]); @@ -949,6 +733,7 @@ public static function getEloquentQuery(): Builder public static function getRelations(): array { return [ + BackupScheduleOperationRunsRelationManager::class, BackupScheduleRunsRelationManager::class, ]; } diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php new file mode 100644 index 0000000..3b112bc --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php @@ -0,0 +1,96 @@ +exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.'); + } + + public function table(Table $table): Table + { + return $table + ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())) + ->defaultSort('created_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('created_at') + ->label('Enqueued') + ->dateTime(), + + Tables\Columns\TextColumn::make('type') + ->label('Type') + ->formatStateUsing([OperationCatalog::class, 'label']), + + Tables\Columns\TextColumn::make('status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) + ->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), + + Tables\Columns\TextColumn::make('outcome') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) + ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) + ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), + + Tables\Columns\TextColumn::make('counts') + ->label('Counts') + ->getStateUsing(function (OperationRun $record): string { + $counts = is_array($record->summary_counts) ? $record->summary_counts : []; + + $total = (int) ($counts['total'] ?? 0); + $succeeded = (int) ($counts['succeeded'] ?? 0); + $failed = (int) ($counts['failed'] ?? 0); + + if ($total === 0 && $succeeded === 0 && $failed === 0) { + return '—'; + } + + return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed); + }), + ]) + ->filters([]) + ->headerActions([]) + ->actions([ + Actions\Action::make('view') + ->label('View') + ->icon('heroicon-o-eye') + ->url(function (OperationRun $record): string { + $tenant = Tenant::currentOrFail(); + + return OperationRunLinks::view($record, $tenant); + }) + ->openUrlInNewTab(true), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index e92e30c..c64e6f0 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -43,6 +43,8 @@ class BackupSetResource extends Resource { protected static ?string $model = BackupSet::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index 998064e..f1c68ee 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -5,7 +5,6 @@ use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupSyncRunResource; use App\Jobs\EntraGroupSyncJob; -use App\Models\EntraGroupSyncRun; use App\Models\Tenant; use App\Models\User; use App\Services\Directory\EntraGroupSelection; @@ -47,11 +46,15 @@ protected function getHeaderActions(): array // --- Phase 3: Canonical Operation Run Start --- /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( + $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, type: 'directory_groups.sync', - inputs: ['selection_key' => $selectionKey], - initiator: $user + identityInputs: ['selection_key' => $selectionKey], + context: [ + 'selection_key' => $selectionKey, + 'trigger' => 'manual', + ], + initiator: $user, ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { @@ -70,42 +73,11 @@ protected function getHeaderActions(): array } // ---------------------------------------------- - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); - - if ($existing instanceof EntraGroupSyncRun) { - Notification::make() - ->title('Group sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->sendToDatabase($user) - ->send(); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - dispatch(new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: $selectionKey, slotKey: null, - runId: (int) $run->getKey(), + runId: null, operationRun: $opRun )); diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php index ff0fecb..563d411 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -3,86 +3,9 @@ namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages; use App\Filament\Resources\EntraGroupSyncRunResource; -use App\Jobs\EntraGroupSyncJob; -use App\Models\EntraGroupSyncRun; -use App\Models\Tenant; -use App\Models\User; -use App\Notifications\RunStatusChangedNotification; -use App\Services\Directory\EntraGroupSelection; -use App\Support\Auth\Capabilities; -use App\Support\Rbac\UiEnforcement; -use App\Support\Rbac\UiTooltips; -use Filament\Actions\Action; use Filament\Resources\Pages\ListRecords; class ListEntraGroupSyncRuns extends ListRecords { protected static string $resource = EntraGroupSyncRunResource::class; - - protected function getHeaderActions(): array - { - return [ - UiEnforcement::forAction( - Action::make('sync_groups') - ->label('Sync Groups') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->action(function (): void { - $user = auth()->user(); - $tenant = Tenant::current(); - - if (! $user instanceof User || ! $tenant instanceof Tenant) { - return; - } - - $selectionKey = EntraGroupSelection::allGroupsV1(); - - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); - - if ($existing instanceof EntraGroupSyncRun) { - $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; - - $user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'run_type' => 'directory_groups', - 'run_id' => (int) $existing->getKey(), - 'status' => $normalizedStatus, - ])); - - return; - } - - $run = EntraGroupSyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'slot_key' => null, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: (int) $run->getKey(), - )); - - $user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'run_type' => 'directory_groups', - 'run_id' => (int) $run->getKey(), - 'status' => 'queued', - ])); - }) - ) - ->requireCapability(Capabilities::TENANT_SYNC) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(), - ]; - } } diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php index d48840f..9785d84 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php @@ -3,9 +3,22 @@ namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages; use App\Filament\Resources\EntraGroupSyncRunResource; +use App\Models\EntraGroupSyncRun; +use App\Support\OperationRunLinks; use Filament\Resources\Pages\ViewRecord; class ViewEntraGroupSyncRun extends ViewRecord { protected static string $resource = EntraGroupSyncRunResource::class; + + public function mount(int|string $record): void + { + parent::mount($record); + + $legacyRun = $this->getRecord(); + + if ($legacyRun instanceof EntraGroupSyncRun && is_numeric($legacyRun->operation_run_id)) { + $this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id)); + } + } } diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 968c6e6..b612330 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -38,6 +38,8 @@ class FindingResource extends Resource { protected static ?string $model = Finding::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string|UnitEnum|null $navigationGroup = 'Drift'; diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 5eb93f1..408ecbf 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -37,6 +37,8 @@ class InventoryItemResource extends Resource { protected static ?string $model = InventoryItem::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static ?string $cluster = InventoryCluster::class; protected static ?int $navigationSort = 1; @@ -125,8 +127,20 @@ public static function infolist(Schema $schema): Schema ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), TextEntry::make('external_id')->label('External ID'), TextEntry::make('last_seen_at')->label('Last seen')->dateTime(), + TextEntry::make('last_seen_operation_run_id') + ->label('Last inventory sync') + ->visible(fn (InventoryItem $record): bool => filled($record->last_seen_operation_run_id)) + ->url(function (InventoryItem $record): ?string { + if (! $record->last_seen_operation_run_id) { + return null; + } + + return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]); + }) + ->openUrlInNewTab(), TextEntry::make('last_seen_run_id') - ->label('Last sync run') + ->label('Last inventory sync (legacy)') + ->visible(fn (InventoryItem $record): bool => blank($record->last_seen_operation_run_id) && filled($record->last_seen_run_id)) ->url(function (InventoryItem $record): ?string { if (! $record->last_seen_run_id) { return null; diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index e15908a..536b706 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -5,7 +5,6 @@ use App\Filament\Resources\InventoryItemResource; use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Jobs\RunInventorySyncJob; -use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Models\User; use App\Services\Intune\AuditLogger; @@ -152,11 +151,16 @@ protected function getHeaderActions(): array /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( + $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, type: 'inventory.sync', - inputs: $computed['selection'], - initiator: $user + identityInputs: [ + 'selection_hash' => $computed['selection_hash'], + ], + context: array_merge($computed['selection'], [ + 'selection_hash' => $computed['selection_hash'], + ]), + initiator: $user, ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { @@ -176,57 +180,26 @@ protected function getHeaderActions(): array return; } - // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) - $existing = InventorySyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $computed['selection_hash']) - ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) - ->first(); - - // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. - if ($existing instanceof InventorySyncRun) { - Notification::make() - ->title('Inventory sync already active') - ->body('A matching inventory sync run is already pending or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); - - $policyTypes = $computed['selection']['policy_types'] ?? []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } - $auditLogger->log( tenant: $tenant, action: 'inventory.sync.dispatched', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, + 'operation_run_id' => (int) $opRun->getKey(), + 'selection_hash' => $computed['selection_hash'], ], ], actorId: $user->id, actorEmail: $user->email, actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $opRun->getKey(), ); - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { + $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $opRun): void { RunInventorySyncJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->id, operationRun: $opRun ); }); diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index a489753..84f7e54 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -32,6 +32,8 @@ class InventorySyncRunResource extends Resource { protected static ?string $model = InventorySyncRun::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static bool $shouldRegisterNavigation = true; protected static ?string $cluster = InventoryCluster::class; diff --git a/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php b/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php index 3dbb0a9..98e5b2b 100644 --- a/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php +++ b/app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php @@ -3,9 +3,22 @@ namespace App\Filament\Resources\InventorySyncRunResource\Pages; use App\Filament\Resources\InventorySyncRunResource; +use App\Models\InventorySyncRun; +use App\Support\OperationRunLinks; use Filament\Resources\Pages\ViewRecord; class ViewInventorySyncRun extends ViewRecord { protected static string $resource = InventorySyncRunResource::class; + + public function mount(int|string $record): void + { + parent::mount($record); + + $legacyRun = $this->getRecord(); + + if ($legacyRun instanceof InventorySyncRun && is_numeric($legacyRun->operation_run_id)) { + $this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id)); + } + } } diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 09dab67..deedb2f 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -10,6 +10,7 @@ use App\Models\VerificationCheckAcknowledgement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\OperateHub\OperateHubShell; use App\Support\OperationCatalog; use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; @@ -317,6 +318,14 @@ public static function table(Table $table): Table Tables\Filters\SelectFilter::make('tenant_id') ->label('Tenant') ->options(function (): array { + $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); + + if ($activeTenant instanceof Tenant) { + return [ + (string) $activeTenant->getKey() => $activeTenant->getFilamentName(), + ]; + } + $user = auth()->user(); if (! $user instanceof User) { @@ -330,19 +339,19 @@ public static function table(Table $table): Table ->all(); }) ->default(function (): ?string { - $tenant = Filament::getTenant(); + $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); - if (! $tenant instanceof Tenant) { + if (! $activeTenant instanceof Tenant) { return null; } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) { + if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) { return null; } - return (string) $tenant->getKey(); + return (string) $activeTenant->getKey(); }) ->searchable(), Tables\Filters\SelectFilter::make('type') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 96ca454..86cd290 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -52,6 +52,8 @@ class PolicyResource extends Resource { protected static ?string $model = Policy::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 15f1eeb..b2d2234 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -51,6 +51,8 @@ class PolicyVersionResource extends Resource { protected static ?string $model = PolicyVersion::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index 14d62f4..7abd062 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -18,6 +18,7 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; +use App\Support\Providers\ProviderReasonCodes; use App\Support\Rbac\UiEnforcement; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; @@ -84,7 +85,61 @@ protected static function resolveScopedTenant(): ?Tenant ->first(); } - return Tenant::current(); + $externalId = static::resolveTenantExternalIdFromLivewireRequest(); + + if (is_string($externalId) && $externalId !== '') { + return Tenant::query() + ->where('external_id', $externalId) + ->first(); + } + + $filamentTenant = \Filament\Facades\Filament::getTenant(); + + return $filamentTenant instanceof Tenant ? $filamentTenant : null; + } + + private static function resolveTenantExternalIdFromLivewireRequest(): ?string + { + if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) { + return null; + } + + try { + $url = \Livewire\Livewire::originalUrl(); + + if (is_string($url) && $url !== '') { + $externalId = static::extractTenantExternalIdFromUrl($url); + + if (is_string($externalId) && $externalId !== '') { + return $externalId; + } + } + } catch (\Throwable) { + // Ignore and fall back to referer. + } + + $referer = request()->headers->get('referer'); + + if (! is_string($referer) || $referer === '') { + return null; + } + + return static::extractTenantExternalIdFromUrl($referer); + } + + private static function extractTenantExternalIdFromUrl(string $url): ?string + { + $path = parse_url($url, PHP_URL_PATH); + + if (! is_string($path) || $path === '') { + $path = $url; + } + + if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) { + return null; + } + + return (string) $matches[1]; } public static function form(Schema $schema): Schema @@ -589,15 +644,18 @@ public static function table(Table $table): Table } $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; $previousStatus = (string) $record->status; + $status = $hadCredentials ? 'connected' : 'error'; + $errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing; + $errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.'; + $record->update([ 'status' => $status, 'health_status' => 'unknown', 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, + 'last_error_reason_code' => $errorReasonCode, + 'last_error_message' => $errorMessage, ]); $user = auth()->user(); @@ -716,9 +774,13 @@ public static function getEloquentQuery(): Builder return $query->whereRaw('1 = 0'); } + if ($tenantId === null) { + return $query->whereRaw('1 = 0'); + } + return $query ->where('workspace_id', (int) $workspaceId) - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->where('tenant_id', $tenantId) ->latest('id'); } @@ -736,6 +798,10 @@ public static function getPages(): array */ public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string { + if (array_key_exists('tenant', $parameters) && blank($parameters['tenant'])) { + unset($parameters['tenant']); + } + if (! array_key_exists('tenant', $parameters)) { if ($tenant instanceof Tenant) { $parameters['tenant'] = $tenant->external_id; @@ -746,6 +812,20 @@ public static function getUrl(?string $name = null, array $parameters = [], bool if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) { $parameters['tenant'] = $resolvedTenant->external_id; } + + $record = $parameters['record'] ?? null; + + if (! array_key_exists('tenant', $parameters) && $record instanceof ProviderConnection) { + $recordTenant = $record->tenant; + + if (! $recordTenant instanceof Tenant) { + $recordTenant = Tenant::query()->whereKey($record->tenant_id)->first(); + } + + if ($recordTenant instanceof Tenant) { + $parameters['tenant'] = $recordTenant->external_id; + } + } } $panel ??= 'admin'; diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 8b70473..5662e27 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -28,10 +28,29 @@ class EditProviderConnection extends EditRecord { protected static string $resource = ProviderConnectionResource::class; + public ?string $scopedTenantExternalId = null; + protected bool $shouldMakeDefault = false; protected bool $defaultWasChanged = false; + public function mount($record): void + { + parent::mount($record); + + $tenant = request()->route('tenant'); + + if ($tenant instanceof Tenant) { + $this->scopedTenantExternalId = (string) $tenant->external_id; + + return; + } + + if (is_string($tenant) && $tenant !== '') { + $this->scopedTenantExternalId = $tenant; + } + } + protected function mutateFormDataBeforeSave(array $data): array { $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); @@ -42,9 +61,16 @@ protected function mutateFormDataBeforeSave(array $data): array protected function afterSave(): void { - $tenant = $this->currentTenant(); $record = $this->getRecord(); + $tenant = $record instanceof ProviderConnection + ? ($record->tenant ?? $this->currentTenant()) + : $this->currentTenant(); + + if (! $tenant instanceof Tenant) { + return; + } + $changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at'])); if ($this->shouldMakeDefault && ! $record->is_default) { @@ -602,15 +628,18 @@ protected function getHeaderActions(): array } $hadCredentials = $record->credential()->exists(); - $status = $hadCredentials ? 'connected' : 'needs_consent'; $previousStatus = (string) $record->status; + $status = $hadCredentials ? 'connected' : 'needs_consent'; + $errorReasonCode = null; + $errorMessage = null; + $record->update([ 'status' => $status, 'health_status' => 'unknown', 'last_health_check_at' => null, - 'last_error_reason_code' => null, - 'last_error_message' => null, + 'last_error_reason_code' => $errorReasonCode, + 'last_error_message' => $errorMessage, ]); $user = auth()->user(); @@ -640,8 +669,8 @@ protected function getHeaderActions(): array if (! $hadCredentials) { Notification::make() - ->title('Connection enabled (credentials missing)') - ->body('Add credentials before running checks or operations.') + ->title('Connection enabled (needs consent)') + ->body('Grant admin consent before running checks or operations.') ->warning() ->send(); @@ -744,7 +773,9 @@ protected function getFormActions(): array protected function handleRecordUpdate(Model $record, array $data): Model { - $tenant = $this->currentTenant(); + $tenant = $record instanceof ProviderConnection + ? ($record->tenant ?? $this->currentTenant()) + : $this->currentTenant(); $user = auth()->user(); @@ -767,6 +798,12 @@ protected function handleRecordUpdate(Model $record, array $data): Model private function currentTenant(): ?Tenant { + if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') { + return Tenant::query() + ->where('external_id', $this->scopedTenantExternalId) + ->first(); + } + $tenant = request()->route('tenant'); if ($tenant instanceof Tenant) { diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index f0ee904..6b74f83 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -36,6 +36,7 @@ use Filament\Actions\ActionGroup; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -60,6 +61,17 @@ class RestoreRunResource extends Resource { protected static ?string $model = RestoreRun::class; + protected static ?string $tenantOwnershipRelationshipName = 'tenant'; + + public static function shouldRegisterNavigation(): bool + { + if (Filament::getCurrentPanel()?->getId() === 'admin') { + return false; + } + + return parent::shouldRegisterNavigation(); + } + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; @@ -876,7 +888,27 @@ public static function table(Table $table): Table status: 'success', ); - ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + $initiator = auth()->user(); + $initiator = $initiator instanceof \App\Models\User ? $initiator : null; + + $opRun = $runs->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => (int) $newRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'is_dry_run' => (bool) ($newRun->is_dry_run ?? false), + ], + initiator: $initiator, + ); + + if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { + $newRun->update(['operation_run_id' => $opRun->getKey()]); + } + + ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun); $auditLogger->log( tenant: $tenant, @@ -896,6 +928,11 @@ public static function table(Table $table): Table OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OperationUxPresenter::queuedToast('restore.execute') + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) ->send(); return; @@ -1727,7 +1764,35 @@ public static function createRestoreRun(array $data): RestoreRun status: 'success', ); - ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + $initiator = auth()->user(); + $initiator = $initiator instanceof \App\Models\User ? $initiator : null; + + $opRun = $runs->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), + ], + initiator: $initiator, + ); + + if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { + $restoreRun->update(['operation_run_id' => $opRun->getKey()]); + } + + ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun); + + OperationUxPresenter::queuedToast('restore.execute') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); return $restoreRun->refresh(); } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index fe16ec1..578ad70 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -7,12 +7,16 @@ use App\Http\Controllers\RbacDelegatedAuthController; use App\Jobs\BulkTenantSyncJob; use App\Jobs\SyncPoliciesJob; +use App\Models\EntraGroup; +use App\Models\EntraRoleDefinition; +use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; use App\Models\WorkspaceMembership; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; +use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacOnboardingService; @@ -46,9 +50,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use Throwable; use UnitEnum; class TenantResource extends Resource @@ -949,18 +951,14 @@ public static function rbacAction(): Actions\Action ->optionsLimit(20) ->searchDebounce(400) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::roleSearchOptions($record, $search)) - ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::formatRoleLabel( - static::resolveRoleName($record, $value), - $value ?? '' - )) - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) + ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::roleLabelFromCache($record, $value)) ->helperText(fn (?Tenant $record) => static::roleSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchRolesAction($record)) + ->hintAction(fn (?Tenant $record) => static::syncRoleDefinitionsAction()) ->hint('Wizard grants Intune RBAC roles only. "Intune Administrator" is an Entra directory role and is not assigned here.') ->noSearchResultsMessage('No Intune RBAC roleDefinitions found (tenant may restrict RBAC or missing permission).') ->loadingMessage('Loading roles...') ->afterStateUpdated(function (Set $set, ?string $state, ?Tenant $record) { - $set('role_display_name', static::resolveRoleName($record, $state)); + $set('role_display_name', static::roleNameFromCache($record, $state)); }), Forms\Components\Hidden::make('role_display_name') ->dehydrated(), @@ -982,11 +980,9 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('scope') === 'scope_group') ->required(fn (Get $get) => $get('scope') === 'scope_group') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) - ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) + ->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) - ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) + ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value)) ->noSearchResultsMessage('No security groups found') ->loadingMessage('Searching groups...'), Forms\Components\Select::make('group_mode') @@ -1011,11 +1007,9 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('group_mode') === 'existing') ->required(fn (Get $get) => $get('group_mode') === 'existing') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) - ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) + ->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) - ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) + ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value)) ->noSearchResultsMessage('No security groups found') ->loadingMessage('Searching groups...'), ]) @@ -1052,8 +1046,10 @@ public static function rbacAction(): Actions\Action abort(403); } - $cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId()); - $token = Cache::get($cacheKey); + $userCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), null); + $sessionCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId()); + + $token = Cache::get($sessionCacheKey) ?? Cache::get($userCacheKey); if (! $token) { Notification::make() @@ -1078,7 +1074,8 @@ public static function rbacAction(): Actions\Action $result = $service->run($record, $data, $user, $token); - Cache::forget($cacheKey); + Cache::forget($sessionCacheKey); + Cache::forget($userCacheKey); if ($result['status'] === 'success') { Notification::make() @@ -1133,6 +1130,10 @@ public static function adminConsentUrl(Tenant $tenant): ?string { $tenantId = $tenant->graphTenantId(); $clientId = $tenant->app_client_id; + + if (! is_string($clientId) || trim($clientId) === '') { + $clientId = static::resolveProviderClientIdForConsent($tenant); + } $redirectUri = route('admin.consent.callback'); $state = sprintf('tenantpilot|%s', $tenant->id); @@ -1162,6 +1163,36 @@ public static function adminConsentUrl(Tenant $tenant): ?string return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query); } + private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string + { + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->orderByDesc('is_default') + ->orderBy('id') + ->first(); + + if (! $connection instanceof ProviderConnection) { + return null; + } + + $payload = $connection->credential?->payload; + + if (! is_array($payload)) { + return null; + } + + $clientId = $payload['client_id'] ?? null; + + if (! is_string($clientId)) { + return null; + } + + $clientId = trim($clientId); + + return $clientId !== '' ? $clientId : null; + } + public static function entraUrl(Tenant $tenant): ?string { if ($tenant->app_client_id) { @@ -1195,23 +1226,16 @@ private static function delegatedToken(?Tenant $tenant): ?string private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Action { - if (! $tenant) { - return null; - } - - return Actions\Action::make('login_to_load_roles') - ->label('Login to load roles') - ->url(route('admin.rbac.start', [ - 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', [ - 'record' => $tenant, - ]), - ])); + return null; } public static function roleSearchHelper(?Tenant $tenant): ?string { - return static::delegatedToken($tenant) ? null : 'Login to load roles'; + if (! $tenant) { + return null; + } + + return static::delegatedToken($tenant) === null ? 'Login to search roles' : null; } /** @@ -1219,122 +1243,112 @@ public static function roleSearchHelper(?Tenant $tenant): ?string */ public static function roleSearchOptions(?Tenant $tenant, string $search): array { + $token = static::delegatedToken($tenant); + + if ($token !== null) { + return static::searchRoleDefinitionsDelegated($tenant, $search, $token); + } + return static::searchRoleDefinitions($tenant, $search); } + /** + * @return array + */ + private static function searchRoleDefinitionsDelegated(?Tenant $tenant, string $search, string $token): array + { + if (! $tenant || mb_strlen($search) < 2) { + return []; + } + + $needle = mb_strtolower($search); + + /** @var GraphClientInterface $graph */ + $graph = app(GraphClientInterface::class); + + $response = $graph->request('GET', 'deviceManagement/roleDefinitions', [ + 'access_token' => $token, + ]); + + if ($response->failed()) { + return []; + } + + $roles = is_array($response->data['value'] ?? null) ? $response->data['value'] : []; + + $results = []; + + foreach ($roles as $role) { + $id = is_string($role['id'] ?? null) ? (string) $role['id'] : null; + $displayName = is_string($role['displayName'] ?? null) ? (string) $role['displayName'] : null; + + if (! $id || ! $displayName) { + continue; + } + + if (! str_contains(mb_strtolower($displayName), $needle)) { + continue; + } + + $results[$id] = static::formatRoleLabel($displayName, $id); + } + + ksort($results); + + return array_slice($results, 0, 20, true); + } + /** * @return array */ private static function searchRoleDefinitions(?Tenant $tenant, string $search): array { - if (! $tenant) { + if (! $tenant || mb_strlen($search) < 2) { return []; } - $token = static::delegatedToken($tenant); + $needle = mb_strtolower($search); - if (! $token) { - return []; - } - - if (Str::contains(Str::lower($search), 'intune administrator')) { - Notification::make() - ->title('Intune Administrator is a directory role') - ->body('Das ist eine Entra Directory Role, nicht Intune RBAC; wird vom Wizard nicht vergeben.') - ->warning() - ->persistent() - ->send(); - } - - $filter = mb_strlen($search) >= 2 - ? sprintf("startswith(displayName,'%s')", static::escapeOdataValue($search)) - : null; - - $query = [ - '$select' => 'id,displayName,isBuiltIn', - '$top' => 20, - ]; - - if ($filter) { - $query['$filter'] = $filter; - } - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'deviceManagement/roleDefinitions', - [ - 'query' => $query, - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyRoleLookupFailure(); - - return []; - } - - if ($response->failed()) { - static::notifyRoleLookupFailure(); - - return []; - } - - $roles = collect($response->data['value'] ?? []) - ->filter(fn (array $role) => filled($role['id'] ?? null)) - ->mapWithKeys(fn (array $role) => [ - $role['id'] => static::formatRoleLabel($role['displayName'] ?? null, $role['id']), + return EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->whereRaw('lower(display_name) like ?', [$needle.'%']) + ->orderBy('display_name') + ->limit(20) + ->get(['entra_id', 'display_name']) + ->mapWithKeys(fn (EntraRoleDefinition $role): array => [ + (string) $role->entra_id => static::formatRoleLabel((string) $role->display_name, (string) $role->entra_id), ]) ->all(); - - if (empty($roles)) { - static::logEmptyRoleDefinitions($tenant, $response->data['value'] ?? []); - } - - return $roles; } - private static function resolveRoleName(?Tenant $tenant, ?string $roleId): ?string + public static function roleLabelFromCache(?Tenant $tenant, ?string $roleId): ?string { if (! $tenant || blank($roleId)) { return $roleId; } - $token = static::delegatedToken($tenant); + $displayName = EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->where('entra_id', $roleId) + ->value('display_name'); - if (! $token) { + $displayName = is_string($displayName) && $displayName !== '' ? $displayName : $roleId; + + return static::formatRoleLabel($displayName, $roleId); + } + + private static function roleNameFromCache(?Tenant $tenant, ?string $roleId): ?string + { + if (! $tenant || blank($roleId)) { return $roleId; } - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - "deviceManagement/roleDefinitions/{$roleId}", - [ - 'query' => [ - '$select' => 'id,displayName', - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyRoleLookupFailure(); + $displayName = EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->where('entra_id', $roleId) + ->value('display_name'); - return $roleId; - } - - if ($response->failed()) { - static::notifyRoleLookupFailure(); - - return $roleId; - } - - $displayName = $response->data['displayName'] ?? null; - $id = $response->data['id'] ?? $roleId; - - return $displayName ?: $id; + return is_string($displayName) && $displayName !== '' ? $displayName : $roleId; } private static function formatRoleLabel(?string $displayName, string $id): string @@ -1344,58 +1358,9 @@ private static function formatRoleLabel(?string $displayName, string $id): strin return trim(($displayName ?: 'RBAC role').$suffix); } - private static function escapeOdataValue(string $value): string - { - return str_replace("'", "''", $value); - } - - private static function notifyRoleLookupFailure(): void - { - Notification::make() - ->title('Role lookup failed') - ->body('Delegated session may have expired. Login again to load Intune RBAC roles.') - ->danger() - ->send(); - } - - private static function logEmptyRoleDefinitions(Tenant $tenant, array $roles): void - { - $names = collect($roles)->pluck('displayName')->filter()->take(5)->values()->all(); - - Log::warning('rbac.role_definitions.empty', [ - 'tenant_id' => $tenant->id, - 'count' => count($roles), - 'sample' => $names, - ]); - - try { - app(AuditLogger::class)->log( - tenant: $tenant, - action: 'rbac.roles.empty', - resourceType: 'tenant', - resourceId: (string) $tenant->id, - status: 'warning', - context: ['metadata' => ['count' => count($roles), 'sample' => $names]], - ); - } catch (Throwable) { - Log::notice('rbac.role_definitions.audit_failed', ['tenant_id' => $tenant->id]); - } - } - private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Action { - if (! $tenant) { - return null; - } - - return Actions\Action::make('login_to_search_groups') - ->label('Login to search groups') - ->url(route('admin.rbac.start', [ - 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', [ - 'record' => $tenant, - ]), - ])); + return null; } public static function groupSearchHelper(?Tenant $tenant): ?string @@ -1404,7 +1369,7 @@ public static function groupSearchHelper(?Tenant $tenant): ?string return null; } - return static::delegatedToken($tenant) ? null : 'Login to search groups'; + return static::delegatedToken($tenant) === null ? 'Login to search groups' : null; } /** @@ -1418,76 +1383,116 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra $token = static::delegatedToken($tenant); - if (! $token) { - return []; + if ($token !== null) { + /** @var GraphClientInterface $graph */ + $graph = app(GraphClientInterface::class); + + $response = $graph->request('GET', 'groups', [ + 'access_token' => $token, + 'query' => [ + '$filter' => sprintf("startswith(displayName,'%s') and securityEnabled eq true", addslashes($search)), + ], + ]); + + if ($response->failed()) { + return []; + } + + $groups = is_array($response->data['value'] ?? null) ? $response->data['value'] : []; + $results = []; + + foreach ($groups as $group) { + $id = is_string($group['id'] ?? null) ? (string) $group['id'] : null; + $displayName = is_string($group['displayName'] ?? null) ? (string) $group['displayName'] : null; + + if (! $id || ! $displayName) { + continue; + } + + $results[$id] = EntraGroupLabelResolver::formatLabel($displayName, $id); + } + + ksort($results); + + return array_slice($results, 0, 20, true); } - $filter = sprintf( - "securityEnabled eq true and startswith(displayName,'%s')", - static::escapeOdataValue($search) - ); + $needle = mb_strtolower($search); - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'groups', - [ - 'query' => [ - '$select' => 'id,displayName', - '$top' => 20, - '$filter' => $filter, - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - return []; - } - - if ($response->failed()) { - return []; - } - - return collect($response->data['value'] ?? []) - ->filter(fn (array $group) => filled($group['id'] ?? null)) - ->mapWithKeys(fn (array $group) => [ - (string) $group['id'] => EntraGroupLabelResolver::formatLabel($group['displayName'] ?? null, (string) $group['id']), + return EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->whereRaw('lower(display_name) like ?', [$needle.'%']) + ->orderBy('display_name') + ->limit(20) + ->get(['entra_id', 'display_name']) + ->mapWithKeys(fn (EntraGroup $group): array => [ + (string) $group->entra_id => EntraGroupLabelResolver::formatLabel((string) $group->display_name, (string) $group->entra_id), ]) ->all(); } - private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?string + public static function groupLabelFromCache(?Tenant $tenant, ?string $groupId): ?string { if (! $tenant || blank($groupId)) { return $groupId; } - $token = static::delegatedToken($tenant); + $resolver = app(EntraGroupLabelResolver::class); - if (! $token) { - return $groupId; - } + return $resolver->resolveOne($tenant, $groupId); + } - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'groups/'.$groupId, - [] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - return $groupId; - } + public static function syncRoleDefinitionsAction(): Actions\Action + { + return Actions\Action::make('sync_role_definitions') + ->label('Sync now') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (?Tenant $record): bool => $record instanceof Tenant && $record->isActive()) + ->action(function (Tenant $record, RoleDefinitionsSyncService $syncService): void { + $user = auth()->user(); - if ($response->failed()) { - return $groupId; - } + if (! $user instanceof User) { + abort(403); + } - return EntraGroupLabelResolver::formatLabel( - $response->data['displayName'] ?? null, - $response->data['id'] ?? $groupId - ); + if (! $user->canAccessTenant($record)) { + abort(404); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) { + abort(403); + } + + $opRun = $syncService->startManualSync($record, $user); + + $runUrl = OperationRunLinks::tenantlessView($opRun); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Role definitions sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + + return; + } + + OperationUxPresenter::queuedToast('directory_role_definitions.sync') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + }); } } diff --git a/app/Http/Controllers/ClearTenantContextController.php b/app/Http/Controllers/ClearTenantContextController.php index 839a415..d82ee7b 100644 --- a/app/Http/Controllers/ClearTenantContextController.php +++ b/app/Http/Controllers/ClearTenantContextController.php @@ -17,6 +17,14 @@ public function __invoke(Request $request): RedirectResponse app(WorkspaceContext::class)->clearLastTenantId($request); - return redirect()->to('/admin/operations'); + $previousUrl = url()->previous(); + + $previousHost = parse_url((string) $previousUrl, PHP_URL_HOST); + + if ($previousHost !== null && $previousHost !== $request->getHost()) { + return redirect()->to('/admin/operations'); + } + + return redirect()->to((string) $previousUrl); } } diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index 7c634da..f0b45b4 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response $canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class); + if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) { + abort(404); + } + if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) { abort(404); } @@ -101,4 +105,13 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; } + + private function isOperateHubPath(string $path): bool + { + return in_array($path, [ + '/admin/operations', + '/admin/alerts', + '/admin/audit-log', + ], true); + } } diff --git a/app/Jobs/EntraGroupSyncJob.php b/app/Jobs/EntraGroupSyncJob.php index e5353c4..2466301 100644 --- a/app/Jobs/EntraGroupSyncJob.php +++ b/app/Jobs/EntraGroupSyncJob.php @@ -40,38 +40,59 @@ public function middleware(): array public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void { + if (! $this->operationRun) { + $this->fail(new RuntimeException('OperationRun context is required for EntraGroupSyncJob.')); + + return; + } + $tenant = Tenant::query()->find($this->tenantId); if (! $tenant instanceof Tenant) { throw new RuntimeException('Tenant not found.'); } - $run = $this->resolveRun($tenant); + $legacyRun = $this->resolveLegacyRun($tenant); - if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) { - // Already ran? - return; + if ($legacyRun instanceof EntraGroupSyncRun) { + if ($legacyRun->status !== EntraGroupSyncRun::STATUS_PENDING) { + return; + } + + $legacyRun->update([ + 'status' => EntraGroupSyncRun::STATUS_RUNNING, + 'started_at' => CarbonImmutable::now('UTC'), + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'directory_groups.sync.started', + context: [ + 'selection_key' => $legacyRun->selection_key, + 'run_id' => $legacyRun->getKey(), + 'slot_key' => $legacyRun->slot_key, + ], + actorId: $legacyRun->initiator_user_id, + status: 'success', + resourceType: 'entra_group_sync_run', + resourceId: (string) $legacyRun->getKey(), + ); + } else { + $auditLogger->log( + tenant: $tenant, + action: 'directory_groups.sync.started', + context: [ + 'selection_key' => $this->selectionKey, + 'slot_key' => $this->slotKey, + ], + actorId: $this->operationRun->user_id, + status: 'success', + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); } - $run->update([ - 'status' => EntraGroupSyncRun::STATUS_RUNNING, - 'started_at' => CarbonImmutable::now('UTC'), - ]); + $result = $syncService->sync($tenant, $this->selectionKey); - $auditLogger->log( - tenant: $tenant, - action: 'directory_groups.sync.started', - context: [ - 'selection_key' => $run->selection_key, - 'run_id' => $run->getKey(), - 'slot_key' => $run->slot_key, - ], - actorId: $run->initiator_user_id, - status: 'success', - resourceType: 'entra_group_sync_run', - resourceId: (string) $run->getKey(), - ); - - $result = $syncService->sync($tenant, $run); $terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED; @@ -81,43 +102,80 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog $terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL; } - $run->update([ - 'status' => $terminalStatus, - 'pages_fetched' => $result['pages_fetched'], - 'items_observed_count' => $result['items_observed_count'], - 'items_upserted_count' => $result['items_upserted_count'], - 'error_count' => $result['error_count'], - 'safety_stop_triggered' => $result['safety_stop_triggered'], - 'safety_stop_reason' => $result['safety_stop_reason'], - 'error_code' => $result['error_code'], - 'error_category' => $result['error_category'], - 'error_summary' => $result['error_summary'], - 'finished_at' => CarbonImmutable::now('UTC'), - ]); + if ($legacyRun instanceof EntraGroupSyncRun) { + $legacyRun->update([ + 'status' => $terminalStatus, + 'pages_fetched' => $result['pages_fetched'], + 'items_observed_count' => $result['items_observed_count'], + 'items_upserted_count' => $result['items_upserted_count'], + 'error_count' => $result['error_count'], + 'safety_stop_triggered' => $result['safety_stop_triggered'], + 'safety_stop_reason' => $result['safety_stop_reason'], + 'error_code' => $result['error_code'], + 'error_category' => $result['error_category'], + 'error_summary' => $result['error_summary'], + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + } - // Update OperationRun with stats - if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); - $opOutcome = match ($terminalStatus) { - EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded', - EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded', - EntraGroupSyncRun::STATUS_FAILED => 'failed', - default => 'failed' - }; + $opOutcome = match ($terminalStatus) { + EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded', + EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded', + EntraGroupSyncRun::STATUS_FAILED => 'failed', + default => 'failed', + }; - $opService->updateRun( - $this->operationRun, - 'completed', - $opOutcome, - [ - 'fetched' => $result['items_observed_count'], - 'upserted' => $result['items_upserted_count'], - 'errors' => $result['error_count'], + $failures = []; + if (is_string($result['error_code']) && $result['error_code'] !== '') { + $failures[] = [ + 'code' => $result['error_code'], + 'message' => is_string($result['error_summary']) ? $result['error_summary'] : 'Directory groups sync failed.', + ]; + } + + $opService->updateRun( + $this->operationRun, + 'completed', + $opOutcome, + [ + // NOTE: summary_counts are normalized to a fixed whitelist for Ops UX. + // Keep keys aligned with App\Support\OpsUx\OperationSummaryKeys. + 'total' => $result['items_observed_count'], + 'processed' => $result['items_observed_count'], + 'updated' => $result['items_upserted_count'], + 'failed' => $result['error_count'], + ], + $failures, + ); + + if ($legacyRun instanceof EntraGroupSyncRun) { + $auditLogger->log( + tenant: $tenant, + action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED + ? 'directory_groups.sync.succeeded' + : ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL + ? 'directory_groups.sync.partial' + : 'directory_groups.sync.failed'), + context: [ + 'selection_key' => $legacyRun->selection_key, + 'run_id' => $legacyRun->getKey(), + 'slot_key' => $legacyRun->slot_key, + 'pages_fetched' => $legacyRun->pages_fetched, + 'items_observed_count' => $legacyRun->items_observed_count, + 'items_upserted_count' => $legacyRun->items_upserted_count, + 'error_code' => $legacyRun->error_code, + 'error_category' => $legacyRun->error_category, ], - $result['error_summary'] ? [['code' => $result['error_code'] ?? 'ERR', 'message' => json_encode($result['error_summary'])]] : [] + actorId: $legacyRun->initiator_user_id, + status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', + resourceType: 'entra_group_sync_run', + resourceId: (string) $legacyRun->getKey(), ); + + return; } $auditLogger->log( @@ -128,23 +186,22 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog ? 'directory_groups.sync.partial' : 'directory_groups.sync.failed'), context: [ - 'selection_key' => $run->selection_key, - 'run_id' => $run->getKey(), - 'slot_key' => $run->slot_key, - 'pages_fetched' => $run->pages_fetched, - 'items_observed_count' => $run->items_observed_count, - 'items_upserted_count' => $run->items_upserted_count, - 'error_code' => $run->error_code, - 'error_category' => $run->error_category, + 'selection_key' => $this->selectionKey, + 'slot_key' => $this->slotKey, + 'pages_fetched' => $result['pages_fetched'], + 'items_observed_count' => $result['items_observed_count'], + 'items_upserted_count' => $result['items_upserted_count'], + 'error_code' => $result['error_code'], + 'error_category' => $result['error_category'], ], - actorId: $run->initiator_user_id, + actorId: $this->operationRun->user_id, status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', - resourceType: 'entra_group_sync_run', - resourceId: (string) $run->getKey(), + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); } - private function resolveRun(Tenant $tenant): EntraGroupSyncRun + private function resolveLegacyRun(Tenant $tenant): ?EntraGroupSyncRun { if ($this->runId !== null) { $run = EntraGroupSyncRun::query() @@ -156,7 +213,7 @@ private function resolveRun(Tenant $tenant): EntraGroupSyncRun return $run; } - throw new RuntimeException('EntraGroupSyncRun not found.'); + return null; } if ($this->slotKey !== null) { @@ -170,9 +227,9 @@ private function resolveRun(Tenant $tenant): EntraGroupSyncRun return $run; } - throw new RuntimeException('EntraGroupSyncRun not found for slot.'); + return null; } - throw new RuntimeException('Job missing runId/slotKey.'); + return null; } } diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php index 044a537..013e3aa 100644 --- a/app/Jobs/ExecuteRestoreRunJob.php +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Listeners\SyncRestoreRunToOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\User; use App\Notifications\RunStatusChangedNotification; @@ -22,20 +23,37 @@ class ExecuteRestoreRunJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + public function __construct( public int $restoreRunId, public ?string $actorEmail = null, public ?string $actorName = null, - ) {} + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void { + if (! $this->operationRun) { + $this->fail(new \RuntimeException('OperationRun context is required for ExecuteRestoreRunJob.')); + + return; + } + $restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId); if (! $restoreRun) { return; } + if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $this->operationRun->getKey()) { + RestoreRun::withoutEvents(function () use ($restoreRun): void { + $restoreRun->forceFill(['operation_run_id' => $this->operationRun?->getKey()])->save(); + }); + } + if ($restoreRun->status !== RestoreRunStatus::Queued->value) { return; } diff --git a/app/Jobs/ProviderConnectionHealthCheckJob.php b/app/Jobs/ProviderConnectionHealthCheckJob.php index 3c53475..0b6d740 100644 --- a/app/Jobs/ProviderConnectionHealthCheckJob.php +++ b/app/Jobs/ProviderConnectionHealthCheckJob.php @@ -16,6 +16,7 @@ use App\Support\Audit\AuditActionId; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Verification\TenantPermissionCheckClusters; use App\Support\Verification\VerificationReportWriter; @@ -230,13 +231,36 @@ public function handle( return; } + $reasonCode = is_string($result->reasonCode) && $result->reasonCode !== '' + ? $result->reasonCode + : 'unknown_error'; + + $nextSteps = app(ProviderNextStepsRegistry::class)->forReason( + $tenant, + $reasonCode, + $connection, + ); + + if ($reasonCode === ProviderReasonCodes::ProviderConsentMissing) { + $run = $runs->finalizeBlockedRun( + $this->operationRun, + reasonCode: $reasonCode, + nextSteps: $nextSteps, + message: $result->message ?? 'Admin consent is required before verification can proceed.', + ); + + $this->logVerificationCompletion($tenant, $user, $run, $report); + + return; + } + $run = $runs->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, failures: [[ 'code' => 'provider.connection.check.failed', - 'reason_code' => $result->reasonCode ?? 'unknown_error', + 'reason_code' => $reasonCode, 'message' => $result->message ?? 'Health check failed.', ]], ); diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php index 26dfc0d..26964b0 100644 --- a/app/Jobs/RunBackupScheduleJob.php +++ b/app/Jobs/RunBackupScheduleJob.php @@ -26,6 +26,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; class RunBackupScheduleJob implements ShouldQueue { @@ -38,6 +39,7 @@ class RunBackupScheduleJob implements ShouldQueue public function __construct( public int $backupScheduleRunId, ?OperationRun $operationRun = null, + public ?int $backupScheduleId = null, ) { $this->operationRun = $operationRun; } @@ -55,6 +57,26 @@ public function handle( AuditLogger $auditLogger, RunErrorMapper $errorMapper, ): void { + if (! $this->operationRun) { + $this->fail(new \RuntimeException('OperationRun context is required for RunBackupScheduleJob.')); + + return; + } + + if ($this->backupScheduleId !== null) { + $this->handleFromScheduleId( + backupScheduleId: $this->backupScheduleId, + policySyncService: $policySyncService, + backupService: $backupService, + policyTypeResolver: $policyTypeResolver, + scheduleTimeService: $scheduleTimeService, + auditLogger: $auditLogger, + errorMapper: $errorMapper, + ); + + return; + } + $run = BackupScheduleRun::query() ->with(['schedule', 'tenant', 'user']) ->find($this->backupScheduleRunId); @@ -74,10 +96,6 @@ public function handle( $tenant = $run->tenant; - if ($tenant instanceof Tenant) { - $this->resolveOperationRunFromContext($tenant, $run); - } - if ($this->operationRun) { $this->operationRun->update([ 'context' => array_merge($this->operationRun->context ?? [], [ @@ -347,6 +365,464 @@ public function handle( } } + private function handleFromScheduleId( + int $backupScheduleId, + PolicySyncService $policySyncService, + BackupService $backupService, + PolicyTypeResolver $policyTypeResolver, + ScheduleTimeService $scheduleTimeService, + AuditLogger $auditLogger, + RunErrorMapper $errorMapper, + ): void { + $schedule = BackupSchedule::query() + ->with('tenant') + ->find($backupScheduleId); + + if (! $schedule instanceof BackupSchedule) { + $this->markOperationRunFailed( + run: $this->operationRun, + summaryCounts: [], + reasonCode: 'schedule_not_found', + reason: 'Schedule not found.', + ); + + return; + } + + $tenant = $schedule->tenant; + + if (! $tenant instanceof Tenant) { + $this->markOperationRunFailed( + run: $this->operationRun, + summaryCounts: [], + reasonCode: 'tenant_not_found', + reason: 'Tenant not found.', + ); + + return; + } + + $this->operationRun->update([ + 'context' => array_merge($this->operationRun->context ?? [], [ + 'backup_schedule_id' => (int) $schedule->getKey(), + ]), + ]); + + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + + if ($this->operationRun->status === 'queued') { + $operationRunService->updateRun($this->operationRun, 'running'); + } + + $lock = Cache::lock("backup_schedule:{$schedule->id}", 900); + + if (! $lock->get()) { + $nowUtc = CarbonImmutable::now('UTC'); + + $this->finishSchedule( + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'total' => 0, + 'processed' => 0, + 'failed' => 1, + ], + failures: [ + [ + 'code' => 'concurrent_run', + 'message' => 'Another run is already in progress for this schedule.', + ], + ], + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorMessage: 'Another run is already in progress for this schedule.', + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_skipped', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + 'reason' => 'concurrent_run', + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + status: 'partial' + ); + + return; + } + + try { + $nowUtc = CarbonImmutable::now('UTC'); + + $this->notifyScheduleRunStarted(tenant: $tenant, schedule: $schedule); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_started', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + status: 'success' + ); + + $runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? [])); + $validTypes = $runtime['valid']; + $unknownTypes = $runtime['unknown']; + + if (empty($validTypes)) { + $this->finishSchedule( + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'total' => 0, + 'processed' => 0, + 'failed' => 1, + ], + failures: [ + [ + 'code' => 'unknown_policy_type', + 'message' => 'All configured policy types are unknown.', + ], + ], + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorMessage: 'All configured policy types are unknown.', + ); + + return; + } + + $supported = array_values(array_filter( + config('tenantpilot.supported_policy_types', []), + fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true), + )); + + $syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported); + + $policyIds = $syncReport['synced'] ?? []; + $syncFailures = $syncReport['failures'] ?? []; + + $backupSet = $backupService->createBackupSet( + tenant: $tenant, + policyIds: $policyIds, + actorEmail: null, + actorName: null, + name: 'Scheduled backup: '.$schedule->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: (bool) ($schedule->include_foundations ?? false), + ); + + $status = match ($backupSet->status) { + 'completed' => BackupScheduleRun::STATUS_SUCCESS, + 'partial' => BackupScheduleRun::STATUS_PARTIAL, + 'failed' => BackupScheduleRun::STATUS_FAILED, + default => BackupScheduleRun::STATUS_SUCCESS, + }; + + $errorCode = null; + $errorMessage = null; + + if (! empty($unknownTypes)) { + $status = BackupScheduleRun::STATUS_PARTIAL; + $errorCode = 'UNKNOWN_POLICY_TYPE'; + $errorMessage = 'Some configured policy types are unknown and were skipped.'; + } + + $policiesTotal = count($policyIds); + $policiesBackedUp = (int) ($backupSet->item_count ?? 0); + $failedCount = max(0, $policiesTotal - $policiesBackedUp); + + $summaryCounts = [ + 'total' => $policiesTotal, + 'processed' => $policiesTotal, + 'succeeded' => $policiesBackedUp, + 'failed' => $failedCount, + 'skipped' => 0, + 'created' => 1, + 'updated' => $policiesBackedUp, + 'items' => $policiesTotal, + ]; + + $failures = []; + + if (is_string($errorMessage) && $errorMessage !== '') { + $failures[] = [ + 'code' => strtolower((string) ($errorCode ?: 'backup_schedule_error')), + 'message' => $errorMessage, + ]; + } + + if (is_array($syncFailures)) { + foreach ($syncFailures as $failure) { + if (! is_array($failure)) { + continue; + } + + $policyType = (string) ($failure['policy_type'] ?? 'unknown'); + $httpStatus = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null; + $errors = $failure['errors'] ?? null; + + $firstErrorMessage = null; + if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) { + $firstErrorMessage = $errors[0]['message'] ?? null; + } + + $message = $httpStatus !== null + ? "{$policyType}: Graph returned {$httpStatus}" + : "{$policyType}: Graph request failed"; + + if (is_string($firstErrorMessage) && $firstErrorMessage !== '') { + $message .= ' - '.trim($firstErrorMessage); + } + + $failures[] = [ + 'code' => $httpStatus !== null ? 'graph_http_'.(string) $httpStatus : 'graph_error', + 'message' => $message, + ]; + } + } + + $this->operationRun->update([ + 'context' => array_merge($this->operationRun->context ?? [], [ + 'backup_schedule_id' => (int) $schedule->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + ]), + ]); + + $outcome = match ($status) { + BackupScheduleRun::STATUS_SUCCESS => 'succeeded', + BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', + default => 'failed', + }; + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: $outcome, + summaryCounts: $summaryCounts, + failures: $failures, + ); + + $this->finishSchedule( + schedule: $schedule, + status: $status, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: $status, + errorMessage: $errorMessage, + ); + + if (in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { + Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); + } + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_finished', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + 'status' => $status, + 'error_code' => $errorCode, + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial' + ); + } catch (\Throwable $throwable) { + $attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1; + $mapped = $errorMapper->map($throwable, $attempt, $this->tries); + + if ($mapped['shouldRetry']) { + $operationRunService->updateRun($this->operationRun, 'running', 'pending'); + + $this->release($mapped['delay']); + + return; + } + + $nowUtc = CarbonImmutable::now('UTC'); + + $this->finishSchedule( + schedule: $schedule, + status: BackupScheduleRun::STATUS_FAILED, + scheduleTimeService: $scheduleTimeService, + nowUtc: $nowUtc, + ); + + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + summaryCounts: [ + 'total' => 0, + 'processed' => 0, + 'failed' => 1, + ], + failures: [ + [ + 'code' => strtolower((string) $mapped['error_code']), + 'message' => (string) $mapped['error_message'], + ], + ], + ); + + $this->notifyScheduleRunFinished( + tenant: $tenant, + schedule: $schedule, + status: BackupScheduleRun::STATUS_FAILED, + errorMessage: (string) $mapped['error_message'], + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_failed', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'operation_run_id' => $this->operationRun->getKey(), + 'error_code' => $mapped['error_code'], + ], + ], + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + status: 'failed' + ); + } finally { + optional($lock)->release(); + } + } + + private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void + { + $userId = $this->operationRun?->user_id; + + if (! $userId) { + return; + } + + $user = \App\Models\User::query()->find($userId); + + if (! $user) { + return; + } + + Notification::make() + ->title('Backup started') + ->body(sprintf('Schedule "%s" has started.', $schedule->name)) + ->info() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ]) + ->sendToDatabase($user); + } + + private function notifyScheduleRunFinished( + Tenant $tenant, + BackupSchedule $schedule, + string $status, + ?string $errorMessage, + ): void { + $userId = $this->operationRun?->user_id; + + if (! $userId) { + return; + } + + $user = \App\Models\User::query()->find($userId); + + if (! $user) { + return; + } + + $title = match ($status) { + BackupScheduleRun::STATUS_SUCCESS => 'Backup completed', + BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)', + BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped', + default => 'Backup failed', + }; + + $notification = Notification::make() + ->title($title) + ->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $status)); + + if (is_string($errorMessage) && $errorMessage !== '') { + $notification->body($notification->getBody()."\n".$errorMessage); + } + + match ($status) { + BackupScheduleRun::STATUS_SUCCESS => $notification->success(), + BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(), + default => $notification->danger(), + }; + + $notification + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ]) + ->sendToDatabase($user); + } + + private function finishSchedule( + BackupSchedule $schedule, + string $status, + ScheduleTimeService $scheduleTimeService, + CarbonImmutable $nowUtc, + ): void { + $schedule->forceFill([ + 'last_run_at' => $nowUtc, + 'last_run_status' => $status, + 'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + } + private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void { $user = $run->user; @@ -527,25 +1003,6 @@ private function markOperationRunFailed( ); } - private function resolveOperationRunFromContext(Tenant $tenant, BackupScheduleRun $run): void - { - if ($this->operationRun) { - return; - } - - $operationRun = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) - ->whereIn('status', ['queued', 'running']) - ->where('context->backup_schedule_run_id', (int) $run->getKey()) - ->latest('id') - ->first(); - - if ($operationRun instanceof OperationRun) { - $this->operationRun = $operationRun; - } - } - private function finishRun( BackupScheduleRun $run, BackupSchedule $schedule, diff --git a/app/Jobs/RunInventorySyncJob.php b/app/Jobs/RunInventorySyncJob.php index 2e8bcb7..574e890 100644 --- a/app/Jobs/RunInventorySyncJob.php +++ b/app/Jobs/RunInventorySyncJob.php @@ -3,7 +3,6 @@ namespace App\Jobs; use App\Jobs\Middleware\TrackOperationRun; -use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; @@ -31,7 +30,6 @@ class RunInventorySyncJob implements ShouldQueue public function __construct( public int $tenantId, public int $userId, - public int $inventorySyncRunId, ?OperationRun $operationRun = null ) { $this->operationRun = $operationRun; @@ -52,6 +50,12 @@ public function middleware(): array */ public function handle(InventorySyncService $inventorySyncService, AuditLogger $auditLogger, OperationRunService $operationRunService): void { + if (! $this->operationRun) { + $this->fail(new RuntimeException('OperationRun context is required for RunInventorySyncJob.')); + + return; + } + $tenant = Tenant::query()->find($this->tenantId); if (! $tenant instanceof Tenant) { throw new RuntimeException('Tenant not found.'); @@ -62,15 +66,9 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $ throw new RuntimeException('User not found.'); } - $run = InventorySyncRun::query()->find($this->inventorySyncRunId); - if (! $run instanceof InventorySyncRun) { - throw new RuntimeException('InventorySyncRun not found.'); - } - - $policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $policyTypes = $context['policy_types'] ?? []; + $policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : []; $processedPolicyTypes = []; $successCount = 0; @@ -81,9 +79,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $ // However, InventorySyncService execution logic might be complex with partial failures. // We might want to explicitly update the OperationRun if partial failures occur. - $run = $inventorySyncService->executePendingRun( - $run, + + $result = $inventorySyncService->executeSelection( + $this->operationRun, $tenant, + $context, function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void { $processedPolicyTypes[] = $policyType; @@ -97,134 +97,90 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe }, ); - if ($run->status === InventorySyncRun::STATUS_SUCCESS) { - if ($this->operationRun) { - $operationRunService->updateRun( - $this->operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, - summaryCounts: [ - 'total' => count($policyTypes), - 'processed' => count($policyTypes), - 'succeeded' => count($policyTypes), - 'failed' => 0, - // Reuse allowed keys for inventory item stats. - 'items' => (int) $run->items_observed_count, - 'updated' => (int) $run->items_upserted_count, - ], - ); - } + $status = (string) ($result['status'] ?? 'failed'); + $errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : []; + $reason = (string) ($errorCodes[0] ?? $status); + + $itemsObserved = (int) ($result['items_observed_count'] ?? 0); + $itemsUpserted = (int) ($result['items_upserted_count'] ?? 0); + $errorsCount = (int) ($result['errors_count'] ?? 0); + + if ($status === 'success') { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => count($policyTypes), + 'failed' => 0, + 'items' => $itemsObserved, + 'updated' => $itemsUpserted, + ], + ); $auditLogger->log( tenant: $tenant, action: 'inventory.sync.completed', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - 'observed' => $run->items_observed_count, - 'upserted' => $run->items_upserted_count, + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'observed' => $itemsObserved, + 'upserted' => $itemsUpserted, ], ], actorId: $user->id, actorEmail: $user->email, actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); return; } - if ($run->status === InventorySyncRun::STATUS_PARTIAL) { - if ($this->operationRun) { - $operationRunService->updateRun( - $this->operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::PartiallySucceeded->value, - summaryCounts: [ - 'total' => count($policyTypes), - 'processed' => count($policyTypes), - 'succeeded' => max(0, count($policyTypes) - (int) $run->errors_count), - 'failed' => (int) $run->errors_count, - 'items' => (int) $run->items_observed_count, - 'updated' => (int) $run->items_upserted_count, - ], - failures: [ - ['code' => 'inventory.partial', 'message' => "Errors: {$run->errors_count}"], - ], - ); - } + if ($status === 'partial') { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => max(0, count($policyTypes) - $errorsCount), + 'failed' => $errorsCount, + 'items' => $itemsObserved, + 'updated' => $itemsUpserted, + ], + failures: [ + ['code' => 'inventory.partial', 'message' => "Errors: {$errorsCount}"], + ], + ); $auditLogger->log( tenant: $tenant, action: 'inventory.sync.partial', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - 'observed' => $run->items_observed_count, - 'upserted' => $run->items_upserted_count, - 'errors' => $run->errors_count, + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'observed' => $itemsObserved, + 'upserted' => $itemsUpserted, + 'errors' => $errorsCount, ], ], actorId: $user->id, actorEmail: $user->email, actorName: $user->name, status: 'failure', - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); return; } - if ($run->status === InventorySyncRun::STATUS_SKIPPED) { - $reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped'); - - if ($this->operationRun) { - $operationRunService->updateRun( - $this->operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Failed->value, - summaryCounts: [ - 'total' => count($policyTypes), - 'processed' => count($policyTypes), - 'succeeded' => 0, - 'failed' => 0, - 'skipped' => count($policyTypes), - ], - failures: [ - ['code' => 'inventory.skipped', 'message' => $reason], - ], - ); - } - - $auditLogger->log( - tenant: $tenant, - action: 'inventory.sync.skipped', - context: [ - 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - 'reason' => $reason, - ], - ], - actorId: $user->id, - actorEmail: $user->email, - actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, - ); - - return; - } - - $reason = (string) (($run->error_codes ?? [])[0] ?? 'failed'); - - $missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes))); - - if ($this->operationRun) { + if ($status === 'skipped') { $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, @@ -232,22 +188,57 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe summaryCounts: [ 'total' => count($policyTypes), 'processed' => count($policyTypes), - 'succeeded' => $successCount, - 'failed' => max($failedCount, count($missingPolicyTypes)), + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => count($policyTypes), ], failures: [ - ['code' => 'inventory.failed', 'message' => $reason], + ['code' => 'inventory.skipped', 'message' => $reason], ], ); + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.skipped', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'reason' => $reason, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + + return; } + $missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes))); + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => $successCount, + 'failed' => max($failedCount, count($missingPolicyTypes)), + ], + failures: [ + ['code' => 'inventory.failed', 'message' => $reason], + ], + ); + $auditLogger->log( tenant: $tenant, action: 'inventory.sync.failed', context: [ 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, + 'operation_run_id' => (int) $this->operationRun->getKey(), 'reason' => $reason, ], ], @@ -255,9 +246,8 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe actorEmail: $user->email, actorName: $user->name, status: 'failure', - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), ); - } } diff --git a/app/Jobs/SyncPoliciesJob.php b/app/Jobs/SyncPoliciesJob.php index af5668d..bac0027 100644 --- a/app/Jobs/SyncPoliciesJob.php +++ b/app/Jobs/SyncPoliciesJob.php @@ -46,7 +46,7 @@ public function handle(PolicySyncService $service, OperationRunService $operatio { $graph = app(GraphClientInterface::class); - if (! config('graph.enabled') || $graph instanceof NullGraphClient) { + if ($graph instanceof NullGraphClient) { if ($this->operationRun) { $operationRunService->updateRun( $this->operationRun, diff --git a/app/Jobs/SyncRoleDefinitionsJob.php b/app/Jobs/SyncRoleDefinitionsJob.php new file mode 100644 index 0000000..34d881b --- /dev/null +++ b/app/Jobs/SyncRoleDefinitionsJob.php @@ -0,0 +1,123 @@ +operationRun = $operationRun; + } + + public function middleware(): array + { + return [new TrackOperationRun]; + } + + /** + * Execute the job. + */ + public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $auditLogger): void + { + if (! $this->operationRun) { + $this->fail(new RuntimeException('OperationRun context is required for SyncRoleDefinitionsJob.')); + + return; + } + + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $auditLogger->log( + tenant: $tenant, + action: 'directory_role_definitions.sync.started', + context: [ + 'tenant_id' => (int) $tenant->getKey(), + ], + actorId: $this->operationRun->user_id, + status: 'success', + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + + $result = $syncService->sync($tenant); + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + + $outcome = 'succeeded'; + + if ($result['error_code'] !== null) { + $outcome = 'failed'; + } elseif ($result['safety_stop_triggered'] === true) { + $outcome = 'partially_succeeded'; + } + + $failures = []; + if (is_string($result['error_code']) && $result['error_code'] !== '') { + $failures[] = [ + 'code' => $result['error_code'], + 'message' => is_string($result['error_summary']) ? $result['error_summary'] : 'Role definitions sync failed.', + ]; + } + + $opService->updateRun( + $this->operationRun, + 'completed', + $outcome, + [ + 'total' => $result['items_observed_count'], + 'processed' => $result['items_observed_count'], + 'updated' => $result['items_upserted_count'], + 'failed' => $result['error_count'], + ], + $failures, + ); + + $auditLogger->log( + tenant: $tenant, + action: $outcome === 'succeeded' + ? 'directory_role_definitions.sync.succeeded' + : ($outcome === 'partially_succeeded' + ? 'directory_role_definitions.sync.partial' + : 'directory_role_definitions.sync.failed'), + context: [ + 'pages_fetched' => $result['pages_fetched'], + 'items_observed_count' => $result['items_observed_count'], + 'items_upserted_count' => $result['items_upserted_count'], + 'error_code' => $result['error_code'], + 'error_category' => $result['error_category'], + 'finished_at' => CarbonImmutable::now('UTC')->toIso8601String(), + ], + actorId: $this->operationRun->user_id, + status: $outcome === 'failed' ? 'failed' : 'success', + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + } +} diff --git a/app/Listeners/SyncRestoreRunToOperationRun.php b/app/Listeners/SyncRestoreRunToOperationRun.php index 5ddd78e..a0c08cf 100644 --- a/app/Listeners/SyncRestoreRunToOperationRun.php +++ b/app/Listeners/SyncRestoreRunToOperationRun.php @@ -2,6 +2,7 @@ namespace App\Listeners; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Services\OperationRunService; use App\Support\RestoreRunStatus; @@ -42,12 +43,30 @@ public function handle(RestoreRun $restoreRun): void 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), ]; - $opRun = $this->service->ensureRun( - tenant: $restoreRun->tenant, - type: 'restore.execute', - inputs: $inputs, - initiator: null - ); + $opRun = null; + + if ($restoreRun->operation_run_id) { + $opRun = OperationRun::query()->whereKey($restoreRun->operation_run_id)->first(); + + if ($opRun?->type !== 'restore.execute') { + $opRun = null; + } + } + + if (! $opRun) { + $opRun = $this->service->ensureRun( + tenant: $restoreRun->tenant, + type: 'restore.execute', + inputs: $inputs, + initiator: null + ); + } + + if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { + RestoreRun::withoutEvents(function () use ($restoreRun, $opRun): void { + $restoreRun->forceFill(['operation_run_id' => $opRun->getKey()])->save(); + }); + } [$opStatus, $opOutcome, $failures] = $this->mapStatus($status); diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php index 66e4e21..81172bf 100644 --- a/app/Models/BackupSchedule.php +++ b/app/Models/BackupSchedule.php @@ -31,4 +31,15 @@ public function runs(): HasMany { return $this->hasMany(BackupScheduleRun::class); } + + public function operationRuns(): HasMany + { + return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id') + ->whereIn('type', [ + 'backup_schedule.run_now', + 'backup_schedule.retry', + 'backup_schedule.scheduled', + ]) + ->where('context->backup_schedule_id', (int) $this->getKey()); + } } diff --git a/app/Models/EntraRoleDefinition.php b/app/Models/EntraRoleDefinition.php new file mode 100644 index 0000000..85e52dc --- /dev/null +++ b/app/Models/EntraRoleDefinition.php @@ -0,0 +1,24 @@ + 'boolean', + 'last_seen_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php index 73b75a8..44035b6 100644 --- a/app/Models/InventoryItem.php +++ b/app/Models/InventoryItem.php @@ -27,4 +27,9 @@ public function lastSeenRun(): BelongsTo { return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id'); } + + public function lastSeenOperationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id'); + } } diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index bf741c9..a340de5 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -38,6 +38,11 @@ public function backupSet(): BelongsTo return $this->belongsTo(BackupSet::class)->withTrashed(); } + public function operationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class); + } + public function scopeDeletable(Builder $query): Builder { return $query->whereIn('status', array_map( diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php index 696d8d0..6805b59 100644 --- a/app/Notifications/RunStatusChangedNotification.php +++ b/app/Notifications/RunStatusChangedNotification.php @@ -68,7 +68,7 @@ public function toDatabase(object $notifiable): array $url = match ($runType) { 'bulk_operation' => OperationRunLinks::view($runId, $tenant), 'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), - 'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + 'directory_groups' => OperationRunLinks::view($runId, $tenant), default => null, }; diff --git a/app/Policies/OperationRunPolicy.php b/app/Policies/OperationRunPolicy.php index ba6d3ee..1140de3 100644 --- a/app/Policies/OperationRunPolicy.php +++ b/app/Policies/OperationRunPolicy.php @@ -3,11 +3,15 @@ namespace App\Policies; use App\Models\OperationRun; +use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Operations\OperationRunCapabilityResolver; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\Response; +use Illuminate\Support\Facades\Gate; class OperationRunPolicy { @@ -56,6 +60,39 @@ public function view(User $user, OperationRun $run): Response|bool } } + $requiredCapability = app(OperationRunCapabilityResolver::class) + ->requiredCapabilityForType((string) $run->type); + + if (! is_string($requiredCapability) || $requiredCapability === '') { + return true; + } + + if (str_starts_with($requiredCapability, 'workspace')) { + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows($requiredCapability, $workspace)) { + return Response::deny(); + } + + return true; + } + + if ($tenantId > 0) { + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant instanceof Tenant) { + return Response::denyAsNotFound(); + } + + if (! Gate::forUser($user)->allows($requiredCapability, $tenant)) { + return Response::deny(); + } + } + return true; } } diff --git a/app/Policies/ProviderConnectionPolicy.php b/app/Policies/ProviderConnectionPolicy.php index 03300cd..1d0a52f 100644 --- a/app/Policies/ProviderConnectionPolicy.php +++ b/app/Policies/ProviderConnectionPolicy.php @@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool return Response::denyAsNotFound(); } - $tenant = $this->currentTenant(); + $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo return Response::denyAsNotFound(); } - $tenant = $this->currentTenant(); + $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo return Response::denyAsNotFound(); } - $tenant = $this->currentTenant(); + $tenant = $this->tenantForConnection($connection) ?? $this->currentTenant(); if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) { return Response::denyAsNotFound(); @@ -152,4 +152,17 @@ private function currentTenant(): ?Tenant return Tenant::current(); } + + private function tenantForConnection(ProviderConnection $connection): ?Tenant + { + if ($connection->relationLoaded('tenant') && $connection->tenant instanceof Tenant) { + return $connection->tenant; + } + + if (is_int($connection->tenant_id) || is_numeric($connection->tenant_id)) { + return Tenant::query()->whereKey((int) $connection->tenant_id)->first(); + } + + return null; + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 874099d..d7e07e7 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -5,8 +5,11 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\NoAccess; use App\Filament\Pages\TenantRequiredPermissions; +use App\Filament\Resources\InventoryItemResource; +use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\TenantResource; use App\Filament\Resources\Workspaces\WorkspaceResource; @@ -37,6 +40,7 @@ class AdminPanelProvider extends PanelProvider public function panel(Panel $panel): Panel { $panel = $panel + ->default() ->id('admin') ->path('admin') ->login(Login::class) @@ -44,8 +48,6 @@ public function panel(Panel $panel): Panel ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); - - WorkspaceResource::registerRoutes($panel); }) ->colors([ 'primary' => Color::Amber, @@ -104,9 +106,15 @@ public function panel(Panel $panel): Panel ) ->resources([ TenantResource::class, + PolicyResource::class, ProviderConnectionResource::class, + InventoryItemResource::class, + WorkspaceResource::class, ]) + ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->pages([ + InventoryCoverage::class, TenantRequiredPermissions::class, ]) ->widgets([ @@ -124,6 +132,7 @@ public function panel(Panel $panel): Panel SubstituteBindings::class, 'ensure-correct-guard:web', 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index bfd280b..49e5166 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -11,6 +11,7 @@ use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\NavigationItem; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; @@ -29,7 +30,6 @@ class TenantPanelProvider extends PanelProvider public function panel(Panel $panel): Panel { $panel = $panel - ->default() ->id('tenant') ->path('admin/t') ->login(Login::class) @@ -40,6 +40,23 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => Color::Amber, ]) + ->navigationItems([ + NavigationItem::make('Runs') + ->url(fn (): string => route('admin.operations.index')) + ->icon('heroicon-o-queue-list') + ->group('Monitoring') + ->sort(10), + NavigationItem::make('Alerts') + ->url(fn (): string => route('admin.monitoring.alerts')) + ->icon('heroicon-o-bell-alert') + ->group('Monitoring') + ->sort(20), + NavigationItem::make('Audit Log') + ->url(fn (): string => route('admin.monitoring.audit-log')) + ->icon('heroicon-o-clipboard-document-list') + ->group('Monitoring') + ->sort(30), + ]) ->renderHook( PanelsRenderHook::HEAD_END, fn () => view('filament.partials.livewire-intercept-shim')->render() diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php index be63a73..ebaad3f 100644 --- a/app/Services/BackupScheduling/BackupScheduleDispatcher.php +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -4,11 +4,11 @@ use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\Tenant; use App\Services\Intune\AuditLogger; +use App\Services\OperationRunService; +use App\Support\OperationRunType; use Carbon\CarbonImmutable; -use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; @@ -17,6 +17,7 @@ class BackupScheduleDispatcher public function __construct( private readonly ScheduleTimeService $scheduleTimeService, private readonly AuditLogger $auditLogger, + private readonly OperationRunService $operationRunService, ) {} /** @@ -62,23 +63,29 @@ public function dispatchDue(?array $tenantIdentifiers = null): array continue; } - $run = null; + $scheduledFor = $slot->startOfMinute(); - try { - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $schedule->tenant_id, - 'scheduled_for' => $slot->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); - } catch (UniqueConstraintViolationException) { - // Idempotency: unique (backup_schedule_id, scheduled_for) + $operationRun = $this->operationRunService->ensureRunWithIdentityStrict( + tenant: $schedule->tenant, + type: OperationRunType::BackupScheduleScheduled->value, + identityInputs: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + ], + context: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'scheduled', + ], + ); + + if (! $operationRun->wasRecentlyCreated) { $skippedRuns++; - Log::debug('Backup schedule run already dispatched for slot.', [ + Log::debug('Backup schedule operation already dispatched for slot.', [ 'schedule_id' => $schedule->id, - 'slot' => $slot->toDateTimeString(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), ]); $schedule->forceFill([ @@ -96,12 +103,12 @@ public function dispatchDue(?array $tenantIdentifiers = null): array context: [ 'metadata' => [ 'backup_schedule_id' => $schedule->id, - 'backup_schedule_run_id' => $run->id, - 'scheduled_for' => $slot->toDateTimeString(), + 'operation_run_id' => $operationRun->getKey(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), ], ], - resourceType: 'backup_schedule_run', - resourceId: (string) $run->id, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), status: 'success' ); @@ -109,7 +116,7 @@ public function dispatchDue(?array $tenantIdentifiers = null): array 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), ])->saveQuietly(); - Bus::dispatch(new RunBackupScheduleJob($run->id)); + Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id)); } return [ diff --git a/app/Services/Directory/EntraGroupSyncService.php b/app/Services/Directory/EntraGroupSyncService.php index e3a437e..79f0353 100644 --- a/app/Services/Directory/EntraGroupSyncService.php +++ b/app/Services/Directory/EntraGroupSyncService.php @@ -3,9 +3,11 @@ namespace App\Services\Directory; use App\Models\EntraGroup; -use App\Models\EntraGroupSyncRun; +use App\Jobs\EntraGroupSyncJob; +use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Services\OperationRunService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphResponse; @@ -18,36 +20,36 @@ public function __construct( private readonly GraphContractRegistry $contracts, ) {} - public function startManualSync(Tenant $tenant, User $user): EntraGroupSyncRun + public function startManualSync(Tenant $tenant, User $user): OperationRun { $selectionKey = EntraGroupSelection::allGroupsV1(); - $existing = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', $selectionKey) - ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) - ->orderByDesc('id') - ->first(); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'directory_groups.sync', + identityInputs: ['selection_key' => $selectionKey], + context: [ + 'selection_key' => $selectionKey, + 'trigger' => 'manual', + ], + initiator: $user, + ); - if ($existing instanceof EntraGroupSyncRun) { - return $existing; + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + return $opRun; } - $run = EntraGroupSyncRun::create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => $selectionKey, - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - - dispatch(new \App\Jobs\EntraGroupSyncJob( + dispatch(new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: $selectionKey, slotKey: null, - runId: (int) $run->getKey(), + runId: null, + operationRun: $opRun, )); - return $run; + return $opRun; } /** @@ -63,7 +65,7 @@ public function startManualSync(Tenant $tenant, User $user): EntraGroupSyncRun * error_summary:?string * } */ - public function sync(Tenant $tenant, EntraGroupSyncRun $run): array + public function sync(Tenant $tenant, string $selectionKey): array { $nowUtc = CarbonImmutable::now('UTC'); diff --git a/app/Services/Directory/RoleDefinitionsSyncService.php b/app/Services/Directory/RoleDefinitionsSyncService.php new file mode 100644 index 0000000..ddb77b4 --- /dev/null +++ b/app/Services/Directory/RoleDefinitionsSyncService.php @@ -0,0 +1,257 @@ +ensureRunWithIdentity( + tenant: $tenant, + type: 'directory_role_definitions.sync', + identityInputs: ['selection_key' => $selectionKey], + context: [ + 'selection_key' => $selectionKey, + 'trigger' => 'manual', + ], + initiator: $user, + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + return $opRun; + } + + dispatch(new SyncRoleDefinitionsJob( + tenantId: (int) $tenant->getKey(), + operationRun: $opRun, + )); + + return $opRun; + } + + /** + * @return array{ + * pages_fetched:int, + * items_observed_count:int, + * items_upserted_count:int, + * error_count:int, + * safety_stop_triggered:bool, + * safety_stop_reason:?string, + * error_code:?string, + * error_category:?string, + * error_summary:?string + * } + */ + public function sync(Tenant $tenant): array + { + $nowUtc = CarbonImmutable::now('UTC'); + + $policyType = $this->contracts->directoryRoleDefinitionsPolicyType(); + $path = $this->contracts->directoryRoleDefinitionsListPath(); + + $contract = $this->contracts->get($policyType); + $query = []; + + if (isset($contract['allowed_select']) && is_array($contract['allowed_select']) && $contract['allowed_select'] !== []) { + $query['$select'] = $contract['allowed_select']; + } + + $pageSize = (int) config('directory_role_definitions.page_size', 200); + if ($pageSize > 0) { + $query['$top'] = $pageSize; + } + + $sanitized = $this->contracts->sanitizeQuery($policyType, $query); + $query = $sanitized['query']; + + $maxPages = (int) config('directory_role_definitions.safety_stop.max_pages', 50); + $maxRuntimeSeconds = (int) config('directory_role_definitions.safety_stop.max_runtime_seconds', 120); + $deadline = $nowUtc->addSeconds(max(1, $maxRuntimeSeconds)); + + $pagesFetched = 0; + $observed = 0; + $upserted = 0; + + $safetyStopTriggered = false; + $safetyStopReason = null; + + $errorCode = null; + $errorCategory = null; + $errorSummary = null; + $errorCount = 0; + + $options = $tenant->graphOptions(); + $useQuery = $query; + $nextPath = $path; + + while ($nextPath) { + if (CarbonImmutable::now('UTC')->greaterThan($deadline)) { + $safetyStopTriggered = true; + $safetyStopReason = 'runtime_exceeded'; + break; + } + + if ($pagesFetched >= $maxPages) { + $safetyStopTriggered = true; + $safetyStopReason = 'max_pages_exceeded'; + break; + } + + $response = $this->requestWithRetry('GET', $nextPath, $options + ['query' => $useQuery]); + + if ($response->failed()) { + [$errorCode, $errorCategory, $errorSummary] = $this->categorizeError($response); + $errorCount = 1; + break; + } + + $pagesFetched++; + + $data = $response->data; + $pageItems = $data['value'] ?? (is_array($data) ? $data : []); + + if (is_array($pageItems)) { + foreach ($pageItems as $item) { + if (! is_array($item)) { + continue; + } + + $entraId = $item['id'] ?? null; + if (! is_string($entraId) || $entraId === '') { + continue; + } + + $displayName = $item['displayName'] ?? null; + $isBuiltIn = (bool) ($item['isBuiltIn'] ?? false); + + $values = [ + 'display_name' => is_string($displayName) ? $displayName : $entraId, + 'is_built_in' => $isBuiltIn, + 'last_seen_at' => $nowUtc, + ]; + + EntraRoleDefinition::query()->updateOrCreate([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => $entraId, + ], $values); + + $observed++; + $upserted++; + } + } + + $nextLink = is_array($data) ? ($data['@odata.nextLink'] ?? null) : null; + + if (! is_string($nextLink) || $nextLink === '') { + break; + } + + $nextPath = $this->stripGraphBaseUrl($nextLink); + $useQuery = []; + } + + $retentionDays = (int) config('directory_role_definitions.retention_days', 90); + if ($retentionDays > 0) { + $cutoff = $nowUtc->subDays($retentionDays); + + EntraRoleDefinition::query() + ->where('tenant_id', $tenant->getKey()) + ->whereNotNull('last_seen_at') + ->where('last_seen_at', '<', $cutoff) + ->delete(); + } + + return [ + 'pages_fetched' => $pagesFetched, + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'error_count' => $errorCount, + 'safety_stop_triggered' => $safetyStopTriggered, + 'safety_stop_reason' => $safetyStopReason, + 'error_code' => $errorCode, + 'error_category' => $errorCategory, + 'error_summary' => $errorSummary, + ]; + } + + private function requestWithRetry(string $method, string $path, array $options): GraphResponse + { + $maxRetries = (int) config('directory_role_definitions.safety_stop.max_retries', 6); + $maxRetries = max(0, $maxRetries); + + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + $response = $this->graph->request($method, $path, $options); + + if ($response->successful()) { + return $response; + } + + $status = (int) ($response->status ?? 0); + + if (! in_array($status, [429, 503], true) || $attempt >= $maxRetries) { + return $response; + } + + $baseDelaySeconds = min(30, 1 << $attempt); + $jitterMillis = random_int(0, 250); + usleep(($baseDelaySeconds * 1000 + $jitterMillis) * 1000); + } + + return new GraphResponse(success: false, data: [], status: 500, errors: [['message' => 'Retry loop exceeded']]); + } + + /** + * @return array{0:string,1:string,2:string} + */ + private function categorizeError(GraphResponse $response): array + { + $status = (int) ($response->status ?? 0); + + if (in_array($status, [401, 403], true)) { + return ['permission_denied', 'permission', 'Graph permission denied for role definitions listing.']; + } + + if ($status === 429) { + return ['throttled', 'throttling', 'Graph throttled the role definitions listing request.']; + } + + if (in_array($status, [500, 502, 503, 504], true)) { + return ['graph_unavailable', 'transient', 'Graph returned a transient server error.']; + } + + return ['graph_request_failed', 'unknown', 'Graph request failed.']; + } + + private function stripGraphBaseUrl(string $nextLink): string + { + $base = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/') + .'/'.trim((string) config('graph.version', 'v1.0'), '/'); + + if (str_starts_with($nextLink, $base)) { + return ltrim((string) substr($nextLink, strlen($base)), '/'); + } + + return ltrim($nextLink, '/'); + } +} diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 3afc766..59b25ea 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -37,6 +37,18 @@ public function directoryGroupsListPath(): string return '/'.ltrim($resource, '/'); } + public function directoryRoleDefinitionsPolicyType(): string + { + return 'directoryRoleDefinitions'; + } + + public function directoryRoleDefinitionsListPath(): string + { + $resource = $this->resourcePath($this->directoryRoleDefinitionsPolicyType()) ?? 'deviceManagement/roleDefinitions'; + + return '/'.ltrim($resource, '/'); + } + /** * @return array */ diff --git a/app/Services/Inventory/InventoryMissingService.php b/app/Services/Inventory/InventoryMissingService.php index b1ff4e6..a14bc7d 100644 --- a/app/Services/Inventory/InventoryMissingService.php +++ b/app/Services/Inventory/InventoryMissingService.php @@ -3,7 +3,7 @@ namespace App\Services\Inventory; use App\Models\InventoryItem; -use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\Tenant; use App\Services\BackupScheduling\PolicyTypeResolver; use Illuminate\Database\Eloquent\Collection; @@ -17,7 +17,7 @@ public function __construct( /** * @param array $selectionPayload - * @return array{latestRun: InventorySyncRun|null, missing: Collection, lowConfidence: bool} + * @return array{latestRun: OperationRun|null, missing: Collection, lowConfidence: bool} */ public function missingForSelection(Tenant $tenant, array $selectionPayload): array { @@ -25,16 +25,12 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar $normalized['policy_types'] = $this->policyTypeResolver->filterRuntime($normalized['policy_types']); $selectionHash = $this->selectionHasher->hash($normalized); - $latestRun = InventorySyncRun::query() + $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $selectionHash) - ->whereIn('status', [ - InventorySyncRun::STATUS_SUCCESS, - InventorySyncRun::STATUS_PARTIAL, - InventorySyncRun::STATUS_FAILED, - InventorySyncRun::STATUS_SKIPPED, - ]) - ->orderByDesc('finished_at') + ->where('type', 'inventory.sync') + ->where('status', 'completed') + ->where('context->selection_hash', $selectionHash) + ->orderByDesc('completed_at') ->orderByDesc('id') ->first(); @@ -51,11 +47,11 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar ->whereIn('policy_type', $normalized['policy_types']) ->where(function ($query) use ($latestRun): void { $query - ->whereNull('last_seen_run_id') - ->orWhere('last_seen_run_id', '!=', $latestRun->getKey()); + ->whereNull('last_seen_operation_run_id') + ->orWhere('last_seen_operation_run_id', '!=', $latestRun->getKey()); }); - $lowConfidence = $latestRun->status !== InventorySyncRun::STATUS_SUCCESS || (bool) ($latestRun->had_errors ?? false); + $lowConfidence = $latestRun->outcome !== 'succeeded'; return [ 'latestRun' => $latestRun, diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 85066f5..3a7c080 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -4,18 +4,21 @@ use App\Models\InventoryItem; use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; -use App\Models\User; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\Graph\GraphResponse; use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderGateway; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use App\Support\Providers\ProviderReasonCodes; -use Carbon\CarbonImmutable; use Illuminate\Contracts\Cache\Lock; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use RuntimeException; use Throwable; @@ -31,7 +34,9 @@ public function __construct( ) {} /** - * Runs an inventory sync inline (no queue), enforcing locks/concurrency and creating an observable run record. + * Runs an inventory sync immediately and persists a corresponding InventorySyncRun. + * + * This is primarily used in tests and for synchronous workflows. * * @param array $selectionPayload */ @@ -41,30 +46,100 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR $normalizedSelection = $computed['selection']; $selectionHash = $computed['selection_hash']; - $now = CarbonImmutable::now('UTC'); + $operationRun = OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()), + 'context' => $normalizedSelection, + 'started_at' => now(), + ]); + + $run = InventorySyncRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'operation_run_id' => (int) $operationRun->getKey(), + 'selection_hash' => $selectionHash, + 'selection_payload' => $normalizedSelection, + 'status' => InventorySyncRun::STATUS_RUNNING, + 'had_errors' => false, + 'started_at' => now(), + ]); + + $result = $this->executeSelection($operationRun, $tenant, $normalizedSelection); + + $status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED); + $hadErrors = (bool) ($result['had_errors'] ?? true); + $errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null; + $errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null; + + $run->update([ + 'status' => $status, + 'had_errors' => $hadErrors, + 'error_codes' => $errorCodes, + 'error_context' => $errorContext, + 'items_observed_count' => (int) ($result['items_observed_count'] ?? 0), + 'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0), + 'errors_count' => (int) ($result['errors_count'] ?? 0), + 'finished_at' => now(), + ]); + + $policyTypes = $normalizedSelection['policy_types'] ?? []; + $policyTypes = is_array($policyTypes) ? $policyTypes : []; + + $operationOutcome = match ($status) { + 'success' => OperationRunOutcome::Succeeded->value, + 'partial' => OperationRunOutcome::PartiallySucceeded->value, + 'skipped' => OperationRunOutcome::Blocked->value, + default => OperationRunOutcome::Failed->value, + }; + + $operationRun->update([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => $operationOutcome, + 'summary_counts' => [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)), + 'failed' => (int) ($result['errors_count'] ?? 0), + 'items' => (int) ($result['items_observed_count'] ?? 0), + 'updated' => (int) ($result['items_upserted_count'] ?? 0), + ], + 'completed_at' => now(), + ]); + + return $run->refresh(); + } + + /** + * Runs an inventory sync (inline), enforcing locks/concurrency. + * + * This method MUST NOT create or update InventorySyncRun rows; OperationRun is canonical. + * + * @param array $selectionPayload + * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed + * @return array{status: string, had_errors: bool, error_codes: list, error_context: array|null, items_observed_count: int, items_upserted_count: int, errors_count: int} + */ + public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array + { + $computed = $this->normalizeAndHashSelection($selectionPayload); + $normalizedSelection = $computed['selection']; + $selectionHash = $computed['selection_hash']; $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); if (! $globalSlot instanceof Lock) { - return $this->createSkippedRun( - tenant: $tenant, - selectionHash: $selectionHash, - selectionPayload: $normalizedSelection, - now: $now, - errorCode: 'concurrency_limit_global', - ); + return $this->skippedResult('concurrency_limit_global'); } $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); if (! $tenantSlot instanceof Lock) { $globalSlot->release(); - return $this->createSkippedRun( - tenant: $tenant, - selectionHash: $selectionHash, - selectionPayload: $normalizedSelection, - now: $now, - errorCode: 'concurrency_limit_tenant', - ); + return $this->skippedResult('concurrency_limit_tenant'); } $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); @@ -72,33 +147,11 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR $tenantSlot->release(); $globalSlot->release(); - return $this->createSkippedRun( - tenant: $tenant, - selectionHash: $selectionHash, - selectionPayload: $normalizedSelection, - now: $now, - errorCode: 'lock_contended', - ); + return $this->skippedResult('lock_contended'); } - $run = InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => null, - 'selection_hash' => $selectionHash, - 'selection_payload' => $normalizedSelection, - 'status' => InventorySyncRun::STATUS_RUNNING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, - 'started_at' => $now, - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - try { - return $this->executeRun($run, $tenant, $normalizedSelection); + return $this->executeSelectionUnderLock($operationRun, $tenant, $normalizedSelection, $onPolicyTypeProcessed); } finally { $selectionLock->release(); $tenantSlot->release(); @@ -135,110 +188,12 @@ public function normalizeAndHashSelection(array $selectionPayload): array ]; } - /** - * Creates a pending run record attributed to the initiating user so the run remains observable even if queue workers are down. - * - * @param array $selectionPayload - */ - public function createPendingRunForUser(Tenant $tenant, User $user, array $selectionPayload): InventorySyncRun - { - $computed = $this->normalizeAndHashSelection($selectionPayload); - - return InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => $user->getKey(), - 'selection_hash' => $computed['selection_hash'], - 'selection_payload' => $computed['selection'], - 'status' => InventorySyncRun::STATUS_PENDING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, - 'started_at' => null, - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - } - - /** - * Executes an existing pending run under locks/concurrency, updating that run to running/skipped/terminal. - */ - /** - * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed - */ - public function executePendingRun(InventorySyncRun $run, Tenant $tenant, ?callable $onPolicyTypeProcessed = null): InventorySyncRun - { - $computed = $this->normalizeAndHashSelection($run->selection_payload ?? []); - $normalizedSelection = $computed['selection']; - $selectionHash = $computed['selection_hash']; - - $now = CarbonImmutable::now('UTC'); - - $run->update([ - 'tenant_id' => $tenant->getKey(), - 'selection_hash' => $selectionHash, - 'selection_payload' => $normalizedSelection, - 'status' => InventorySyncRun::STATUS_RUNNING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, - 'started_at' => $now, - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - - $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); - if (! $globalSlot instanceof Lock) { - return $this->markExistingRunSkipped( - run: $run, - now: $now, - errorCode: 'concurrency_limit_global', - ); - } - - $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); - if (! $tenantSlot instanceof Lock) { - $globalSlot->release(); - - return $this->markExistingRunSkipped( - run: $run, - now: $now, - errorCode: 'concurrency_limit_tenant', - ); - } - - $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); - if (! $selectionLock->get()) { - $tenantSlot->release(); - $globalSlot->release(); - - return $this->markExistingRunSkipped( - run: $run, - now: $now, - errorCode: 'lock_contended', - ); - } - - try { - return $this->executeRun($run, $tenant, $normalizedSelection, $onPolicyTypeProcessed); - } finally { - $selectionLock->release(); - $tenantSlot->release(); - $globalSlot->release(); - } - } - - /** - * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection - */ /** * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed + * @return array{status: string, had_errors: bool, error_codes: list, error_context: array|null, items_observed_count: int, items_upserted_count: int, errors_count: int} */ - private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): InventorySyncRun + private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array { $observed = 0; $upserted = 0; @@ -349,7 +304,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal 'platform' => $typeConfig['platform'] ?? null, 'meta_jsonb' => $meta, 'last_seen_at' => now(), - 'last_seen_run_id' => $run->getKey(), + 'last_seen_operation_run_id' => (int) $operationRun->getKey(), ] ); @@ -368,10 +323,8 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null); } - $status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS; - - $run->update([ - 'status' => $status, + return [ + 'status' => $hadErrors ? 'partial' : 'success', 'had_errors' => $hadErrors, 'error_codes' => array_values(array_unique($errorCodes)), 'error_context' => [ @@ -380,29 +333,39 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal 'items_observed_count' => $observed, 'items_upserted_count' => $upserted, 'errors_count' => $errors, - 'finished_at' => CarbonImmutable::now('UTC'), - ]); - - return $run->refresh(); + ]; } catch (Throwable $throwable) { $errorContext = $this->safeErrorContext($throwable); $errorContext['warnings'] = array_values($warnings); - $run->update([ - 'status' => InventorySyncRun::STATUS_FAILED, + return [ + 'status' => 'failed', 'had_errors' => true, 'error_codes' => ['unexpected_exception'], 'error_context' => $errorContext, 'items_observed_count' => $observed, 'items_upserted_count' => $upserted, 'errors_count' => $errors + 1, - 'finished_at' => CarbonImmutable::now('UTC'), - ]); - - return $run->refresh(); + ]; } } + /** + * @return array{status: string, had_errors: bool, error_codes: list, error_context: array|null, items_observed_count: int, items_upserted_count: int, errors_count: int} + */ + private function skippedResult(string $errorCode): array + { + return [ + 'status' => 'skipped', + 'had_errors' => true, + 'error_codes' => [$errorCode], + 'error_context' => null, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]; + } + private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, array $policyData): bool { $configurationPolicyTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy']; @@ -556,50 +519,6 @@ private function selectionLockKey(Tenant $tenant, string $selectionHash): string return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash); } - /** - * @param array $selectionPayload - */ - private function createSkippedRun( - Tenant $tenant, - string $selectionHash, - array $selectionPayload, - CarbonImmutable $now, - string $errorCode, - ): InventorySyncRun { - return InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => null, - 'selection_hash' => $selectionHash, - 'selection_payload' => $selectionPayload, - 'status' => InventorySyncRun::STATUS_SKIPPED, - 'had_errors' => true, - 'error_codes' => [$errorCode], - 'error_context' => null, - 'started_at' => $now, - 'finished_at' => $now, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - } - - private function markExistingRunSkipped(InventorySyncRun $run, CarbonImmutable $now, string $errorCode): InventorySyncRun - { - $run->update([ - 'status' => InventorySyncRun::STATUS_SKIPPED, - 'had_errors' => true, - 'error_codes' => [$errorCode], - 'error_context' => null, - 'started_at' => $run->started_at ?? $now, - 'finished_at' => $now, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); - - return $run->refresh(); - } - private function mapGraphFailureToErrorCode(GraphResponse $response): string { $status = (int) ($response->status ?? 0); diff --git a/app/Services/OperationRunService.php b/app/Services/OperationRunService.php index e369742..e7596d3 100644 --- a/app/Services/OperationRunService.php +++ b/app/Services/OperationRunService.php @@ -184,6 +184,64 @@ public function ensureRunWithIdentity( } } + public function ensureRunWithIdentityStrict( + Tenant $tenant, + string $type, + array $identityInputs, + array $context, + ?User $initiator = null, + ): OperationRun { + $workspaceId = (int) ($tenant->workspace_id ?? 0); + + if ($workspaceId <= 0) { + throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.'); + } + + $hash = $this->calculateHash($tenant->id, $type, $identityInputs); + + $existing = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('workspace_id', $workspaceId) + ->where('type', $type) + ->where('run_identity_hash', $hash) + ->first(); + + if ($existing instanceof OperationRun) { + return $existing; + } + + try { + return OperationRun::create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->id, + 'user_id' => $initiator?->id, + 'initiator_name' => $initiator?->name ?? 'System', + 'type' => $type, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'run_identity_hash' => $hash, + 'context' => $context, + ]); + } catch (QueryException $e) { + if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) { + throw $e; + } + + $existing = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('workspace_id', $workspaceId) + ->where('type', $type) + ->where('run_identity_hash', $hash) + ->first(); + + if ($existing instanceof OperationRun) { + return $existing; + } + + throw $e; + } + } + /** * Standardized enqueue helper for bulk operations. * diff --git a/app/Services/Providers/ProviderOperationStartGate.php b/app/Services/Providers/ProviderOperationStartGate.php index a4fc1f8..c5721a8 100644 --- a/app/Services/Providers/ProviderOperationStartGate.php +++ b/app/Services/Providers/ProviderOperationStartGate.php @@ -10,6 +10,7 @@ use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Providers\ProviderReasonCodes; use App\Support\Verification\BlockedVerificationReportFactory; +use App\Support\Verification\StaleQueuedVerificationReportFactory; use App\Support\Verification\VerificationReportWriter; use Illuminate\Support\Facades\DB; use InvalidArgumentException; @@ -73,8 +74,27 @@ public function start( ->active() ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) ->orderByDesc('id') + ->lockForUpdate() ->first(); + if ($activeRun instanceof OperationRun) { + if ($this->runs->isStaleQueuedRun($activeRun)) { + $this->runs->failStaleQueuedRun($activeRun); + + if ($activeRun->type === 'provider.connection.check') { + VerificationReportWriter::write( + run: $activeRun, + checks: StaleQueuedVerificationReportFactory::checks($activeRun), + identity: StaleQueuedVerificationReportFactory::identity($activeRun), + ); + + $activeRun->refresh(); + } + + $activeRun = null; + } + } + if ($activeRun instanceof OperationRun) { if ($activeRun->type === $operationType) { return ProviderOperationStartResult::deduped($activeRun); diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 957f718..a8d04f3 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -34,6 +34,12 @@ public function handle(Request $request, Closure $next): Response $existingTenant = Filament::getTenant(); if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) { Filament::setTenant(null, true); + $existingTenant = null; + } + + $user = $request->user(); + if ($existingTenant instanceof Tenant && $user instanceof User && ! $user->canAccessTenant($existingTenant)) { + Filament::setTenant(null, true); } if ($path === '/livewire/update') { @@ -59,7 +65,14 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + $tenantParameter = null; if ($request->route()?->hasParameter('tenant')) { + $tenantParameter = $request->route()->parameter('tenant'); + } elseif (filled($request->query('tenant'))) { + $tenantParameter = $request->query('tenant'); + } + + if ($tenantParameter !== null) { $user = $request->user(); if ($user === null) { @@ -70,12 +83,9 @@ public function handle(Request $request, Closure $next): Response abort(404); } - if (! $panel->hasTenancy()) { - return $next($request); - } - - $tenantParameter = $request->route()->parameter('tenant'); - $tenant = $panel->getTenant($tenantParameter); + $tenant = $tenantParameter instanceof Tenant + ? $tenantParameter + : Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first(); if (! $tenant instanceof Tenant) { abort(404); @@ -129,8 +139,6 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - $user = $request->user(); - if (! $user instanceof User) { $this->configureNavigationForRequest($panel); diff --git a/app/Support/OperateHub/OperateHubShell.php b/app/Support/OperateHub/OperateHubShell.php new file mode 100644 index 0000000..281efc6 --- /dev/null +++ b/app/Support/OperateHub/OperateHubShell.php @@ -0,0 +1,131 @@ +activeEntitledTenant($request); + + if ($activeTenant instanceof Tenant) { + return 'Scope: Tenant — '.$activeTenant->name; + } + + return 'Scope: Workspace — all tenants'; + } + + /** + * @return array{label: string, url: string}|null + */ + public function returnAffordance(?Request $request = null): ?array + { + $activeTenant = $this->activeEntitledTenant($request); + + if ($activeTenant instanceof Tenant) { + return [ + 'label' => 'Back to '.$activeTenant->name, + 'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant), + ]; + } + + return null; + } + + /** + * @return array + */ + public function headerActions( + string $scopeActionName = 'operate_hub_scope', + string $returnActionName = 'operate_hub_return', + ?Request $request = null, + ): array { + $actions = [ + Action::make($scopeActionName) + ->label($this->scopeLabel($request)) + ->color('gray') + ->disabled(), + ]; + + $returnAffordance = $this->returnAffordance($request); + + if (is_array($returnAffordance)) { + $actions[] = Action::make($returnActionName) + ->label($returnAffordance['label']) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url($returnAffordance['url']); + } + + return $actions; + } + + public function activeEntitledTenant(?Request $request = null): ?Tenant + { + return $this->resolveActiveTenant($request); + } + + private function resolveActiveTenant(?Request $request = null): ?Tenant + { + $tenant = Filament::getTenant(); + + if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) { + return $tenant; + } + + $rememberedTenantId = $this->workspaceContext->lastTenantId($request); + + if ($rememberedTenantId === null) { + return null; + } + + $rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first(); + + if (! $rememberedTenant instanceof Tenant) { + return null; + } + + if (! $this->isEntitled($rememberedTenant, $request)) { + return null; + } + + return $rememberedTenant; + } + + private function isEntitled(Tenant $tenant, ?Request $request = null): bool + { + if (! $tenant->isActive()) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = $this->workspaceContext->currentWorkspaceId($request); + + if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) { + return false; + } + + return $this->capabilityResolver->isMember($user, $tenant); + } +} diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index ce8f38e..40af26f 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -30,7 +30,9 @@ public static function labels(): array 'backup_set.force_delete' => 'Delete backup sets', 'backup_schedule.run_now' => 'Backup schedule run', 'backup_schedule.retry' => 'Backup schedule retry', + 'backup_schedule.scheduled' => 'Backup schedule run', 'restore.execute' => 'Restore execution', + 'directory_role_definitions.sync' => 'Role definitions sync', 'restore_run.delete' => 'Delete restore runs', 'restore_run.restore' => 'Restore restore runs', 'restore_run.force_delete' => 'Force delete restore runs', diff --git a/app/Support/OperationRunType.php b/app/Support/OperationRunType.php index c2f2467..1268ce5 100644 --- a/app/Support/OperationRunType.php +++ b/app/Support/OperationRunType.php @@ -13,6 +13,8 @@ enum OperationRunType: string case BackupSetRemovePolicies = 'backup_set.remove_policies'; case BackupScheduleRunNow = 'backup_schedule.run_now'; case BackupScheduleRetry = 'backup_schedule.retry'; + case BackupScheduleScheduled = 'backup_schedule.scheduled'; + case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'; case RestoreExecute = 'restore.execute'; public static function values(): array diff --git a/app/Support/Operations/OperationRunCapabilityResolver.php b/app/Support/Operations/OperationRunCapabilityResolver.php new file mode 100644 index 0000000..b725faf --- /dev/null +++ b/app/Support/Operations/OperationRunCapabilityResolver.php @@ -0,0 +1,32 @@ + Capabilities::TENANT_INVENTORY_SYNC_RUN, + 'directory_groups.sync' => Capabilities::TENANT_SYNC, + 'backup_schedule.run_now', 'backup_schedule.retry', 'backup_schedule.scheduled' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, + 'restore.execute' => Capabilities::TENANT_MANAGE, + 'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE, + + // Viewing verification reports should be possible for readonly members. + // Starting verification is separately guarded by the verification service. + 'provider.connection.check' => Capabilities::PROVIDER_VIEW, + + // Keep legacy / unknown types viewable by membership+entitlement only. + default => null, + }; + } +} diff --git a/app/Support/Providers/ProviderNextStepsRegistry.php b/app/Support/Providers/ProviderNextStepsRegistry.php index a5cebd9..90c27ec 100644 --- a/app/Support/Providers/ProviderNextStepsRegistry.php +++ b/app/Support/Providers/ProviderNextStepsRegistry.php @@ -27,6 +27,10 @@ public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnectio ProviderReasonCodes::ProviderCredentialInvalid, ProviderReasonCodes::ProviderAuthFailed, ProviderReasonCodes::ProviderConsentMissing => [ + [ + 'label' => 'Grant admin consent', + 'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant), + ], [ 'label' => $connection instanceof ProviderConnection ? 'Update Credentials' : 'Manage Provider Connections', 'url' => $connection instanceof ProviderConnection diff --git a/app/Support/Verification/StaleQueuedVerificationReportFactory.php b/app/Support/Verification/StaleQueuedVerificationReportFactory.php new file mode 100644 index 0000000..93fde61 --- /dev/null +++ b/app/Support/Verification/StaleQueuedVerificationReportFactory.php @@ -0,0 +1,90 @@ +> + */ + public static function checks(OperationRun $run): array + { + $context = is_array($run->context ?? null) ? $run->context : []; + + return [[ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => 'unknown_error', + 'message' => 'Run was queued but never started. A queue worker may not be running.', + 'evidence' => self::evidence($run, $context), + 'next_steps' => [], + ]]; + } + + /** + * @return array + */ + public static function identity(OperationRun $run): array + { + $context = is_array($run->context ?? null) ? $run->context : []; + + $identity = []; + + $providerConnectionId = $context['provider_connection_id'] ?? null; + if (is_numeric($providerConnectionId)) { + $identity['provider_connection_id'] = (int) $providerConnectionId; + } + + $targetScope = $context['target_scope'] ?? []; + $targetScope = is_array($targetScope) ? $targetScope : []; + + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $identity['entra_tenant_id'] = trim($entraTenantId); + } + + return $identity; + } + + /** + * @param array $context + * @return array + */ + private static function evidence(OperationRun $run, array $context): array + { + $evidence = []; + + $providerConnectionId = $context['provider_connection_id'] ?? null; + if (is_numeric($providerConnectionId)) { + $evidence[] = [ + 'kind' => 'provider_connection_id', + 'value' => (int) $providerConnectionId, + ]; + } + + $targetScope = $context['target_scope'] ?? []; + $targetScope = is_array($targetScope) ? $targetScope : []; + + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $evidence[] = [ + 'kind' => 'entra_tenant_id', + 'value' => trim($entraTenantId), + ]; + } + + $evidence[] = [ + 'kind' => 'operation_run_id', + 'value' => (int) $run->getKey(), + ]; + + return $evidence; + } +} diff --git a/config/graph_contracts.php b/config/graph_contracts.php index f698b72..2544b04 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -28,6 +28,11 @@ 'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'], 'allowed_expand' => [], ], + 'directoryRoleDefinitions' => [ + 'resource' => 'deviceManagement/roleDefinitions', + 'allowed_select' => ['id', 'displayName', 'isBuiltIn'], + 'allowed_expand' => [], + ], 'managedDevices' => [ 'resource' => 'deviceManagement/managedDevices', 'allowed_select' => ['id', 'complianceState'], diff --git a/database/factories/EntraRoleDefinitionFactory.php b/database/factories/EntraRoleDefinitionFactory.php new file mode 100644 index 0000000..9117c6f --- /dev/null +++ b/database/factories/EntraRoleDefinitionFactory.php @@ -0,0 +1,31 @@ + + */ +class EntraRoleDefinitionFactory extends Factory +{ + protected $model = EntraRoleDefinition::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'entra_id' => fake()->uuid(), + 'display_name' => fake()->jobTitle(), + 'is_built_in' => false, + 'last_seen_at' => now('UTC'), + ]; + } +} diff --git a/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php b/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php new file mode 100644 index 0000000..99ca489 --- /dev/null +++ b/database/migrations/2026_02_10_004939_add_unique_index_for_backup_schedule_scheduled_operation_runs.php @@ -0,0 +1,48 @@ +getDriverName(); + + if (! in_array($driver, ['pgsql', 'sqlite'], true)) { + return; + } + + DB::statement(<<<'SQL' + CREATE UNIQUE INDEX IF NOT EXISTS operation_runs_backup_schedule_scheduled_unique + ON operation_runs (tenant_id, run_identity_hash) + WHERE type = 'backup_schedule.scheduled' + SQL); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('operation_runs')) { + return; + } + + $driver = Schema::getConnection()->getDriverName(); + + if (! in_array($driver, ['pgsql', 'sqlite'], true)) { + return; + } + + DB::statement('DROP INDEX IF EXISTS operation_runs_backup_schedule_scheduled_unique'); + } +}; diff --git a/database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php b/database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php new file mode 100644 index 0000000..eba504a --- /dev/null +++ b/database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php @@ -0,0 +1,34 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_sync_runs', function (Blueprint $table) { + $table->dropForeign(['operation_run_id']); + $table->dropColumn('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php b/database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php new file mode 100644 index 0000000..0959b62 --- /dev/null +++ b/database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php @@ -0,0 +1,34 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entra_group_sync_runs', function (Blueprint $table) { + $table->dropForeign(['operation_run_id']); + $table->dropColumn('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php b/database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php new file mode 100644 index 0000000..217490b --- /dev/null +++ b/database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php @@ -0,0 +1,34 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backup_schedule_runs', function (Blueprint $table) { + $table->dropForeign(['operation_run_id']); + $table->dropColumn('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php b/database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php new file mode 100644 index 0000000..c916c4d --- /dev/null +++ b/database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php @@ -0,0 +1,34 @@ +foreignId('last_seen_operation_run_id') + ->nullable() + ->after('last_seen_run_id') + ->constrained('operation_runs') + ->nullOnDelete(); + + $table->index('last_seen_operation_run_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_items', function (Blueprint $table) { + $table->dropConstrainedForeignId('last_seen_operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php b/database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php new file mode 100644 index 0000000..271e784 --- /dev/null +++ b/database/migrations/2026_02_10_115908_add_operation_run_id_to_restore_runs_table.php @@ -0,0 +1,33 @@ +foreignId('operation_run_id') + ->nullable() + ->constrained('operation_runs') + ->nullOnDelete() + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('restore_runs', function (Blueprint $table) { + $table->dropConstrainedForeignId('operation_run_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php b/database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php new file mode 100644 index 0000000..7b87e2f --- /dev/null +++ b/database/migrations/2026_02_10_133238_create_entra_role_definitions_table.php @@ -0,0 +1,37 @@ +id(); + + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->uuid('entra_id'); + $table->string('display_name'); + $table->boolean('is_built_in')->default(false); + + $table->timestampTz('last_seen_at')->nullable(); + + $table->timestamps(); + + $table->unique(['tenant_id', 'entra_id']); + $table->index(['tenant_id', 'display_name']); + $table->index(['tenant_id', 'last_seen_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entra_role_definitions'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index fb74b6b..dd69fbd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,7 @@ - + diff --git a/resources/views/filament/pages/monitoring/alerts.blade.php b/resources/views/filament/pages/monitoring/alerts.blade.php index 306c863..5838c48 100644 --- a/resources/views/filament/pages/monitoring/alerts.blade.php +++ b/resources/views/filament/pages/monitoring/alerts.blade.php @@ -1,6 +1,7 @@ -
-
- Alerts is reserved for future work. + +
+
+ Alerts is reserved for future work. +
-
- + diff --git a/resources/views/filament/pages/monitoring/audit-log.blade.php b/resources/views/filament/pages/monitoring/audit-log.blade.php index 0ab0b00..0026d99 100644 --- a/resources/views/filament/pages/monitoring/audit-log.blade.php +++ b/resources/views/filament/pages/monitoring/audit-log.blade.php @@ -1,6 +1,7 @@ -
-
- Audit Log is reserved for future work. + +
+
+ Audit Log is reserved for future work. +
-
- + diff --git a/resources/views/filament/partials/context-bar.blade.php b/resources/views/filament/partials/context-bar.blade.php index 0ac2c3f..8e92051 100644 --- a/resources/views/filament/partials/context-bar.blade.php +++ b/resources/views/filament/partials/context-bar.blade.php @@ -5,6 +5,7 @@ use App\Models\WorkspaceMembership; use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; + use App\Support\OperateHub\OperateHubShell; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -40,9 +41,12 @@ } } - $currentTenant = Filament::getTenant(); + $operateHubShell = app(OperateHubShell::class); + $currentTenant = $operateHubShell->activeEntitledTenant(request()); $currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null; + $hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant; + $path = '/'.ltrim(request()->path(), '/'); $route = request()->route(); $routeName = (string) ($route?->getName() ?? ''); @@ -65,7 +69,7 @@ || ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.')); $lastTenantId = $workspaceContext->lastTenantId(request()); - $canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null; + $canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null; @endphp
@@ -83,7 +87,7 @@ Switch workspace @@ -174,7 +178,7 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
@csrf - + Clear tenant context diff --git a/routes/web.php b/routes/web.php index 2ab4541..71a2d1e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,7 +27,7 @@ Route::get('/admin/consent/start', TenantOnboardingController::class) ->name('admin.consent.start'); -// Panel root override: keep the app's workspace-first flow. + // Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows. // when no default tenant can be resolved. Route::middleware([ @@ -143,6 +143,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class) ->name('admin.operations.index'); @@ -155,6 +156,20 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/t/{tenant:external_id}/operations', fn () => redirect()->route('admin.operations.index')) + ->name('admin.operations.legacy-tenant-index'); + +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class) ->name('admin.monitoring.alerts'); @@ -167,6 +182,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class) ->name('admin.monitoring.audit-log'); @@ -179,6 +195,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class) ->name('admin.operations.view'); diff --git a/specs/085-tenant-operate-hub/checklists/requirements.md b/specs/085-tenant-operate-hub/checklists/requirements.md new file mode 100644 index 0000000..82766bb --- /dev/null +++ b/specs/085-tenant-operate-hub/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Tenant Operate Hub / Tenant Overview IA + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-09 +**Feature**: [specs/085-tenant-operate-hub/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 + +- Dependencies/assumptions used: canonical monitoring surfaces exist at `/admin/operations`, `/admin/operations/{run}`, `/admin/alerts`, `/admin/audit-log`; tenant plane exists at `/admin/t/{tenant}`; tenant context can be active or absent; authorization semantics remain consistent (deny-as-not-found vs forbidden); Monitoring views are view-only render surfaces that must not initiate outbound calls on render. \ No newline at end of file diff --git a/specs/085-tenant-operate-hub/contracts/openapi.yaml b/specs/085-tenant-operate-hub/contracts/openapi.yaml new file mode 100644 index 0000000..2e4edb0 --- /dev/null +++ b/specs/085-tenant-operate-hub/contracts/openapi.yaml @@ -0,0 +1,76 @@ +openapi: 3.0.3 +info: + title: Tenant Operate Hub / Central Monitoring (UI Route Contracts) + version: 0.1.0 + description: | + Internal documentation of canonical central Monitoring surfaces. + + These are Filament page routes (not a public API). The contract is used to + pin down URL shapes and security semantics (404 vs 403) for acceptance. + +servers: + - url: / + +paths: + /admin/operations: + get: + summary: Central Monitoring - Operations index + description: | + Canonical operations list. Must render without outbound calls. + + Scope semantics: + - If tenant context is active AND entitled: page is tenant-filtered by default and shows tenant-scoped header/CTAs. + - If tenant context is absent: page is workspace-wide. + - If tenant context is active but not entitled: page behaves workspace-wide and must not reveal tenant identity. + responses: + '200': + description: OK + '302': + description: Redirect to choose workspace if none selected + '403': + description: Authenticated but forbidden (capability denial after membership) + '404': + description: Deny-as-not-found when not entitled to workspace scope + /admin/clear-tenant-context: + post: + summary: Exit tenant context (Monitoring) + description: | + Clears the active tenant context for the current session. + Used by “Show all tenants” on central Monitoring pages. + responses: + '302': + description: Redirect back to a canonical Monitoring page + '404': + description: Deny-as-not-found when not entitled to workspace scope + /admin/operations/{run}: + get: + summary: Central Monitoring - Run detail + parameters: + - in: path + name: run + required: true + schema: + type: integer + responses: + '200': + description: OK + '403': + description: Authenticated but forbidden (policy denies view) + '404': + description: Deny-as-not-found when run is outside entitled scope + /admin/alerts: + get: + summary: Central Monitoring - Alerts + responses: + '200': + description: OK + '404': + description: Deny-as-not-found when not entitled to workspace scope + /admin/audit-log: + get: + summary: Central Monitoring - Audit log + responses: + '200': + description: OK + '404': + description: Deny-as-not-found when not entitled to workspace scope diff --git a/specs/085-tenant-operate-hub/data-model.md b/specs/085-tenant-operate-hub/data-model.md new file mode 100644 index 0000000..84e1dc7 --- /dev/null +++ b/specs/085-tenant-operate-hub/data-model.md @@ -0,0 +1,63 @@ +# Data Model: Tenant Operate Hub / Tenant Overview IA + +**Date**: 2026-02-09 +**Branch**: 085-tenant-operate-hub + +This feature is primarily UI/IA + navigation behavior. It introduces **no new database tables**. + +## Entities (existing) + +### Workspace +- Purpose: primary isolation boundary and monitoring scope. +- Source of truth: `workspaces` + membership. + +### Tenant +- Purpose: managed environment; tenant-plane routes live under `/admin/t/{tenant}`. +- Access: entitlement-based. + +### OperationRun +- Purpose: canonical run tracking for all operational workflows. +- Surface: + - Index: `/admin/operations` + - Detail: `/admin/operations/{run}` + +### Alert (placeholder) +- Purpose: future operator signals. +- Surface: `/admin/alerts`. + +### Audit Event / Audit Log (placeholder) +- Purpose: immutable record of sensitive actions. +- Surface: `/admin/audit-log`. + +## Session / Context State (existing) + +### Workspace context +- Key: `WorkspaceContext::SESSION_KEY` (`current_workspace_id`) +- Meaning: selected workspace id for the current session. + +### Last tenant per workspace (session-based) +- Key: `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` (`workspace_last_tenant_ids`) +- Shape: + - Map keyed by workspace id string → tenant id int + - Example: + - `{"12": 345}` +- APIs: + - `WorkspaceContext::rememberLastTenantId(int $workspaceId, int $tenantId, Request $request)` + - `WorkspaceContext::lastTenantId(Request $request): ?int` + - `WorkspaceContext::clearLastTenantId(Request $request)` + +### Filament tenant context +- Source: `Filament::getTenant()` (may persist across panels depending on Filament tenancy configuration). +- Used to determine “active tenant context” for Monitoring UX. + +**Spec 085 scope note**: Monitoring may use session-based last-tenant memory as a tenant-context signal when Filament tenant context is absent (e.g., when navigating from the tenant panel into central Monitoring). It must not be inferred from arbitrary deep links. + +### Stale tenant context behavior (no entitlement) + +- If tenant context is active but the user is not entitled, Monitoring pages behave as workspace-wide views and must not display tenant identity. + +## Validation / Rules + +- Tenant context MUST NOT be implicitly mutated by canonical monitoring pages. +- Deny-as-not-found (404) applies when the actor is not entitled to tenant/workspace scope. +- Forbidden (403) applies only after membership is established but capability is missing. diff --git a/specs/085-tenant-operate-hub/plan.md b/specs/085-tenant-operate-hub/plan.md new file mode 100644 index 0000000..e846e6a --- /dev/null +++ b/specs/085-tenant-operate-hub/plan.md @@ -0,0 +1,123 @@ +# Implementation Plan: Spec 085 — Tenant Operate Hub / Tenant Overview IA + +**Branch**: `085-tenant-operate-hub` | **Date**: 2026-02-09 | **Spec**: specs/085-tenant-operate-hub/spec.md +**Input**: specs/085-tenant-operate-hub/spec.md + +## Summary + +Make central Monitoring pages feel context-aware when entered from the tenant panel, without introducing tenant-scoped monitoring routes and without implicit tenant switching. + +Key outcomes: +- Tenant panel sidebar replaces “Operations” with a “Monitoring” group of shortcuts (Runs/Alerts/Audit Log) that open central Monitoring surfaces. +- `/admin/operations` becomes context-aware when tenant context is active: scope label shows tenant, table defaults to tenant filter, and header includes `Back to ` + `Show all tenants` (clears tenant context). +- `/admin/operations/{run}` adds deterministic “back” affordances: tenant back link when tenant context is active + entitled, plus secondary `Show all operations`; otherwise `Back to Operations`. +- Monitoring page render remains DB-only: no outbound calls and no background work triggered by view-only GET. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 +**Storage**: PostgreSQL (Sail) +**Testing**: Pest v4 (`vendor/bin/sail artisan test`) +**Target Platform**: Web (enterprise SaaS admin UI) +**Project Type**: Laravel monolith (Filament panels + Livewire) +**Performance Goals**: Monitoring page renders are DB-only, low-latency, and avoid N+1 regressions +**Constraints**: +- Canonical monitoring URLs must not change (`/admin/operations`, `/admin/operations/{run}`) +- No new tenant-scoped monitoring routes +- No implicit tenant switching (tenant selection remains explicit POST) +- Deny-as-not-found (404) for non-members/non-entitled; 403 only after membership established +- No outbound calls on render; no render-time side effects (jobs/notifications) +**Scale/Scope**: Small-to-medium UX change touching tenant navigation + 2 monitoring pages + Pest tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshots: Not applicable (read-only monitoring UX). +- Read/write separation: PASS (changes are navigation + view-only rendering; the only mutation is explicit “clear tenant context” action). +- Graph contract path: PASS (no new Graph calls). +- Deterministic capabilities: PASS (uses existing membership/entitlement checks; no new capability strings). +- Workspace isolation: PASS (non-member workspace access remains 404). +- Tenant isolation: PASS (no tenant identity leaks when not entitled; tenant pages remain 404). +- Run observability: PASS (view-only pages do not start operations; Monitoring stays DB-only). +- RBAC-UX destructive confirmation: PASS (no destructive actions added). +- Filament UI Action Surface Contract: PASS (we’re modifying Pages; we will provide explicit header actions and table/default filter behavior; no new list resources are added). + +## Project Structure + +### Documentation (this feature) + +```text +specs/085-tenant-operate-hub/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ ├── Resources/ +│ └── ... +├── Http/ +│ ├── Controllers/ +│ └── Middleware/ +├── Providers/ +└── Support/ + +resources/views/ +tests/Feature/ +routes/web.php +``` + +**Structure Decision**: Laravel monolith with Filament panels. Changes will be localized to existing panel providers, page classes, shared helpers (if present), and feature tests. + +## Phase Plan + +### Phase 0 — Research (complete) + +Outputs: +- specs/085-tenant-operate-hub/research.md (decisions + alternatives) + +### Phase 1 — Design & Contracts (complete) + +Outputs: +- specs/085-tenant-operate-hub/data-model.md (no schema changes; context rules) +- specs/085-tenant-operate-hub/contracts/openapi.yaml (canonical routes + clear-tenant-context POST) +- specs/085-tenant-operate-hub/quickstart.md (manual verification) + +### Phase 2 — Implementation Planning (next) + +Implementation will be executed as small, test-driven slices: + +1) Tenant panel navigation IA + - Replace tenant-panel “Operations” entry with “Monitoring” group. + - Add 3 shortcut items (Runs/Alerts/Audit Log). + - Verify no new tenant-scoped monitoring routes are introduced. + +2) Operations index context-aware header + default scope + - If tenant context active + entitled: show scope `Tenant — `, default table filter = tenant, CTAs `Back to ` and `Show all tenants`. + - If no tenant context: show scope `Workspace — all tenants`. + - If tenant context active but not entitled: behave workspace-wide (no tenant name, no back-to-tenant). + +3) Run detail deterministic back affordances + - If tenant context active + entitled: `← Back to ` plus secondary `Show all operations`. + - Else: `Back to Operations`. + +4) Pest tests (security + UX) + - OperationsIndexScopeTest (tenant vs workspace scope labels + CTAs) + - RunDetailBackToTenantTest (tenant-context vs no-context actions) + - Deny-as-not-found coverage for non-entitled tenant pages + - “No outbound calls on render” guard for `/admin/operations` and `/admin/operations/{run}` + +## Complexity Tracking + +No constitution violations expected. diff --git a/specs/085-tenant-operate-hub/quickstart.md b/specs/085-tenant-operate-hub/quickstart.md new file mode 100644 index 0000000..91829a7 --- /dev/null +++ b/specs/085-tenant-operate-hub/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: Tenant Operate Hub / Tenant Overview IA + +**Date**: 2026-02-09 +**Branch**: 085-tenant-operate-hub + +## Local setup + +- Start containers: `vendor/bin/sail up -d` +- Install deps (if needed): `vendor/bin/sail composer install` + +## Manual verification steps (happy path) + +1. Sign in. +2. Select a workspace (via the context bar). +3. Enter a tenant context (e.g., go to `/admin/t/{tenant}` via the tenant panel). +4. In the tenant panel sidebar, open the **Monitoring** group and click: + - Runs → lands on `/admin/operations` +5. Verify `/admin/operations` shows: + - Header scope: `Scope: Tenant — ` + - CTAs: `Back to ` and `Show all tenants` + - The table default scope is tenant-filtered to the active tenant. +6. Click `Show all tenants`. +7. Verify you stay on `/admin/operations` and scope becomes `Scope: Workspace — all tenants`. +8. Open an operation run detail at `/admin/operations/{run}`. +9. Verify the header shows: + - `← Back to ` + - secondary `Show all operations` → `/admin/operations` +10. Click `← Back to ` and verify it lands on the tenant dashboard (`/admin/t/{tenant}`). + +## Negative verification (security) + +- With tenant context active, revoke tenant entitlement for the test user. +- Reload `/admin/operations`. +- Verify scope is workspace-wide and no tenant name / “Back to tenant” affordance appears. +- Request the tenant dashboard URL directly (`/admin/t/{tenant}`) and verify deny-as-not-found. + +## Test commands (to be added in Phase 2) + +- Targeted suite: `vendor/bin/sail artisan test --compact --filter=OperationsIndexScopeTest` diff --git a/specs/085-tenant-operate-hub/research.md b/specs/085-tenant-operate-hub/research.md new file mode 100644 index 0000000..155ef18 --- /dev/null +++ b/specs/085-tenant-operate-hub/research.md @@ -0,0 +1,70 @@ +# Research: Tenant Operate Hub / Tenant Overview IA (Spec 085) + +**Date**: 2026-02-09 +**Branch**: 085-tenant-operate-hub +**Spec**: specs/085-tenant-operate-hub/spec.md + +This research consolidates repo evidence + the final clarification decisions, so the implementation plan and tests can be deterministic. + +## Repository Evidence (high-signal) + +- Canonical monitoring pages already exist in the Admin panel: + - Operations index: app/Filament/Pages/Monitoring/Operations.php + - Run detail (tenantless viewer): app/Filament/Pages/Operations/TenantlessOperationRunViewer.php + - Alerts: app/Filament/Pages/Monitoring/Alerts.php + - Audit log: app/Filament/Pages/Monitoring/AuditLog.php +- Tenant selection + clear tenant context already exist (UI + route/controller): + - Context bar partial: resources/views/filament/partials/context-bar.blade.php + - Tenant select controller: app/Http/Controllers/SelectTenantController.php + +## Decisions (resolved) + +### Decision: Tenant context may use last-tenant memory for cross-panel flows +- Decision: “Tenant context is active” on Monitoring pages is resolved from the active Filament tenant when present, otherwise from the remembered last-tenant id for the current workspace. +- Rationale: Central Monitoring routes are canonical and tenantless, but users enter them from the tenant panel and expect consistent scoping. +- Alternatives considered: + - Query param `?tenant=`: rejected (would introduce an implicit switching vector). + +### Decision: “Show all tenants” explicitly exits tenant context +- Decision: The CTA “Show all tenants” clears tenant context (single meaning) and returns the user to workspace-wide Monitoring. +- Rationale: Prevents the confusing state where tenant context is still active but filters are reset. +- Alternatives considered: + - Only reset table filter: rejected (context remains active and confuses scope semantics). + +### Decision: Stale tenant context is handled without leaks +- Decision: If tenant context is active but the user is no longer entitled to that tenant, Monitoring pages behave as workspace-wide: + - Scope shows `Workspace — all tenants` + - No tenant name is shown + - No “Back to tenant” is rendered + - Tenant pages remain deny-as-not-found +- Rationale: Preserves deny-as-not-found and avoids tenant existence hints. +- Alternatives considered: + - 404 the Monitoring page: rejected (feels like being “thrown out”). + - Auto-clear tenant context implicitly: rejected (implicit context mutation). + +### Decision: Run detail shows an explicit tenant return + secondary escape hatch +- Decision: When tenant context is active and entitled, run detail shows: + - `← Back to ` (tenant dashboard) + - secondary `Show all operations` → `/admin/operations` +- Rationale: “Back to tenant” is deterministic; the secondary link provides a canonical escape hatch. +- Alternatives considered: + - Only show `Back to tenant`: rejected by clarification (secondary escape hatch approved). + +### Decision: Tenant panel navigation uses a “Monitoring” group with central shortcuts +- Decision: Replace the tenant-panel “Operations” item with group “Monitoring” containing shortcuts: + - Runs → `/admin/operations` + - Alerts → `/admin/alerts` + - Audit Log → `/admin/audit-log` +- Rationale: Keep tenant sidebar labels minimal while still providing correct central Monitoring entry points, while preserving canonical URLs and avoiding tenant-scoped monitoring routes. +- Alternatives considered: + - New tenant-scoped monitoring routes: rejected (explicitly forbidden). + +### Decision: “No outbound calls on render” is enforced by tests +- Decision: Monitoring GET renders must not trigger outbound network calls or start background work as a side effect. +- Rationale: Aligns with constitution (“Monitoring pages MUST be DB-only at render time”). +- Alternatives considered: + - Rely on convention: rejected; this needs regression protection. + +## Open Questions + +None remaining for Phase 0. The spec clarifications cover all scope-affecting ambiguities. diff --git a/specs/085-tenant-operate-hub/spec.md b/specs/085-tenant-operate-hub/spec.md new file mode 100644 index 0000000..a661d4f --- /dev/null +++ b/specs/085-tenant-operate-hub/spec.md @@ -0,0 +1,194 @@ +# Feature Specification: Tenant Operate Hub / Tenant Overview IA + +**Feature Branch**: `085-tenant-operate-hub` +**Created**: 2026-02-09 +**Status**: Draft +**Input**: User description: "Make central Monitoring surfaces feel context-aware when entered from a tenant, without changing canonical URLs, and without weakening deny-as-not-found security boundaries." + +## Clarifications + +### Session 2026-02-09 (work order alignment) + +- Q: What is the source of truth for “Back to tenant”? → A: The active entitled tenant context (Filament tenant if present, otherwise the remembered last-tenant id for the current workspace). +- Q: Should “Back to last tenant” be implemented as a separate feature? → A: No; remembered tenant context is used only to preserve context when navigating from a tenant into central Monitoring. +- Q: What does “Show all tenants” do? → A: It explicitly exits tenant context to return to workspace-wide monitoring (no mixed behavior with filter resets). +- Q: How is Monitoring reached from tenant context? → A: Tenant navigation offers a “Monitoring” group with shortcuts that open central Monitoring surfaces. +- Q: How should stale tenant context (tenant context active but user no longer entitled) behave on Monitoring pages? → A: Monitoring renders workspace-wide (no tenant name, no “Back to tenant”), preserving deny-as-not-found for tenant pages. +- Q: Should run detail offer a secondary escape hatch when tenant context is active? → A: Yes — show a secondary “Show all operations” link to `/admin/operations`. +- Q: How should tenant Monitoring shortcuts indicate “opens central monitoring”? → A: Keep labels minimal (no “↗ Central” suffix). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Monitoring feels context-aware (Priority: P1) + +As an operator, when I open central Monitoring from within a tenant, I immediately understand: +1) whether the Monitoring view is scoped to the current tenant or to all tenants, and +2) how to get back to the tenant I came from. + +**Why this priority**: This is the core usability and safety problem: monitoring and tenant work should not feel like different apps, but they must not blur security boundaries. + +**Independent Test**: With a tenant context active, a test user can open the Operations index and a run detail, see an explicit scope indicator and a deterministic “Back to tenant”, and exit to workspace-wide monitoring intentionally. + +**Acceptance Scenarios**: + +1. **Given** a user is a member of a workspace and has access to at least one tenant, **When** they open central Monitoring (Operations), **Then** the page clearly shows whether tenant context is active. +2. **Given** a tenant context is active, **When** the user navigates to a canonical monitoring detail page, **Then** the UI provides a single, clear “Back to tenant” affordance that returns to that tenant dashboard. +3. **Given** a user is not entitled to the current tenant, **When** they try to access tenant-scoped pages via a direct link, **Then** they receive a not-found experience (deny-as-not-found), without any tenant existence hints. + +--- + +### User Story 2 - Canonical URLs with explicit scope (Priority: P2) + +As an operator, I can use canonical Monitoring URLs at all times. When tenant context is active, Monitoring views can be tenant-filtered by default, but they must not implicitly change tenant selection. + +**Why this priority**: Avoids mistakes and misinterpretation of data by preventing silent scoping changes. + +**Independent Test**: With a tenant selected, open monitoring index and detail views and verify the scope is consistent and clearly communicated. + +**Acceptance Scenarios**: + +1. **Given** a tenant context is active, **When** the user opens the monitoring index, **Then** the default view is tenant-scoped (or clearly offers a one-click tenant scope), and the UI visibly indicates the scope. +2. **Given** no tenant context is active, **When** the user opens monitoring, **Then** the view is workspace-wide and does not imply a tenant is selected. + +--- + +### User Story 3 - Deep links are safe and recoverable (Priority: P3) + +As an operator working inside a tenant, when I land on a canonical run detail via a deep link, I can safely return to the tenant if tenant context is still active and I am still entitled. + +**Why this priority**: These workflows are frequent. Deep links are where users most often “lose” tenant context. + +**Independent Test**: With a tenant context active, open a canonical run detail and verify the “Back to tenant” affordance is present and correct. + +**Acceptance Scenarios**: + +1. **Given** a tenant context is active and the user is still entitled, **When** they open a canonical run detail, **Then** they see a “Back to tenant” affordance. +2. **Given** tenant context is not active, **When** the user opens a canonical run detail, **Then** they see only a “Back to Operations” affordance. + +### Edge Cases + +- User has no workspace selected: Monitoring must not show cross-workspace data; user must select a workspace first. +- User has workspace access but zero tenant access: Monitoring must still work in workspace-wide mode, without tenant selection. +- User’s tenant access is revoked while they have a deep link open: subsequent tenant-scoped navigation must be deny-as-not-found. +- User opens a bookmarked canonical run detail directly: the UI must provide a deterministic “Back” behavior without inventing tenant context. +- Tenant context is active, but entitlement was revoked: Monitoring must not leak tenant identity; tenant return affordance must not appear (or must be safe). +- Monitoring views must remain view-only render surfaces: rendering must not trigger outbound calls. + +## Requirements *(mandatory)* + +### Target State (hard decision) + +This spec adopts a single, deterministic interpretation: + +- Monitoring URLs are canonical and do not change with tenant context. +- Tenant context makes Monitoring *feel* scoped (scope indicators, default filters, and deterministic exits) without implicit tenant switching. + +- Operations index: `/admin/operations` +- Operations run detail: `/admin/operations/{run}` +- Alerts: `/admin/alerts` +- Audit log: `/admin/audit-log` + +Tenant plane remains under `/admin/t/{tenant}` for tenant dashboards and workflows. Monitoring views are central, but when tenant context is active they become tenant-filtered by default and provide a deterministic “Back to tenant” affordance. + +**Constitution alignment (required):** This feature is information architecture + navigation behavior. It MUST NOT introduce new outbound calls for monitoring pages. If it introduces or changes any write/change behavior (e.g., starting workflows), it MUST maintain existing safety gates (preview/confirmation/audit), tenant isolation, run observability, and tests. + +**Constitution alignment (RBAC-UX):** This feature changes how users reach surfaces; it MUST preserve and test authorization semantics: +- Non-member / not entitled to workspace scope OR tenant scope → deny-as-not-found (404 semantics) +- Member but missing capability → forbidden (403 semantics) + +**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound communication on auth endpoints. This MUST NOT be used for Monitoring pages. + +**Constitution alignment (BADGE-001):** If any status/severity/outcome badges are added or changed on hub pages, their meaning MUST be centralized and covered by tests. + +**Constitution alignment (UI Action Surfaces):** If this feature adds/modifies any admin or tenant UI surfaces, the “UI Action Matrix” MUST be updated and action gating MUST remain consistent (confirmation for destructive-like actions; server-side authorization for mutations). + +### Functional Requirements + +- **FR-085-001**: Tenant navigation MUST offer a “Monitoring” group with shortcuts to central Monitoring surfaces: + - Runs (Operations) → `/admin/operations` + - Alerts → `/admin/alerts` + - Audit Log → `/admin/audit-log` + These shortcuts MUST NOT introduce new tenant-scoped monitoring URLs. + +- **FR-085-002**: The Operations index (`/admin/operations`) MUST show a clear scope indicator in the page header: + - `Scope: Workspace — all tenants` when no tenant context is active + - `Scope: Tenant — ` when tenant context is active +- **FR-085-003**: Canonical Monitoring URLs MUST NOT implicitly change tenant context. Tenant context MAY influence default filters on Monitoring views. +- **FR-085-004**: When tenant context is active on the Operations index, the default tenant filter MUST be set to the current tenant, and the UI MUST make this tenant scoping obvious. +- **FR-085-005**: The Operations index MUST provide two explicit CTAs when tenant context is active: + - `Show all tenants` (explicitly exits tenant context and returns to workspace-wide monitoring) + - `Back to ` (navigates to tenant dashboard) +- **FR-085-006**: The run detail (`/admin/operations/{run}`) MUST provide a deterministic “Back” affordance: + - If tenant context is active AND the user is still entitled: `← Back to ` (tenant dashboard) AND a secondary `Show all operations` (to `/admin/operations`) + - Else: `Back to Operations` (Operations index) +- **FR-085-007**: “Back to tenant” MUST be based only on active entitled tenant context (Filament tenant, or remembered tenant for the current workspace). It MUST NOT be inferred from arbitrary deep-link parameters. +- **FR-085-008**: Deny-as-not-found MUST remain: users not entitled to workspace or tenant scope MUST receive a not-found experience (404 semantics), with no tenant existence hints. +- **FR-085-009**: Monitoring views (`/admin/operations` and `/admin/operations/{run}`) MUST remain view-only render surfaces and MUST NOT trigger outbound calls during render. +- **FR-085-010**: If tenant context is active but the user is not entitled to that tenant, Monitoring pages MUST behave as workspace-wide views: + - Scope indicator MUST show `Scope: Workspace — all tenants` + - No tenant name MUST be displayed + - No “Back to ” affordance MUST be rendered + - Direct access to tenant pages MUST continue to be deny-as-not-found + +## UI Action Matrix *(mandatory when UI surfaces are 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Central Operations (index) | `/admin/operations` | Scope indicator; `Show all tenants` (when tenant context active); deterministic back affordance | Linked run rows to open run detail | N/A | N/A | N/A | N/A | N/A | No | Must not implicitly change tenant context; default tenant filter when tenant context active | +| Central Operations (run detail) | `/admin/operations/{run}` | `← Back to ` (when tenant context active + entitled) OR `Back to Operations`; secondary `Show all operations` allowed when tenant context active + entitled | N/A | N/A | N/A | N/A | N/A | N/A | No | Must not reveal tenant identity when user is not entitled | +| Tenant navigation shortcuts | Tenant sidebar | N/A | N/A | N/A | N/A | N/A | N/A | N/A | No | “Monitoring” group with central shortcuts | + +### Key Entities *(include if feature involves data)* + +- **Workspace**: A security and organizational boundary for operations and monitoring. +- **Tenant**: A managed environment within a workspace; access is entitlement-based. +- **Monitoring (Operations)**: Central monitoring views that can be workspace-wide or tenant-scoped when tenant context is active. +- **Operation Run**: A tracked execution of an operational workflow; viewable via canonical run detail. +- **Alert**: An operator-facing signal about an issue or state requiring attention. +- **Audit Event**: An immutable record of important user-triggered actions and sensitive operations. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-085-001**: In a usability walkthrough, 90% of operators can correctly identify whether Operations is scoped to a tenant or to all tenants within 10 seconds of opening `/admin/operations`. +- **SC-085-002**: With tenant context active, operators can return to the tenant dashboard from `/admin/operations` and `/admin/operations/{run}` in ≤ 1 click. +- **SC-085-003**: Support tickets tagged “lost tenant context / where am I?” decrease by 30% within 30 days after rollout. +- **SC-085-004**: Authorization regression checks show zero cases where a non-entitled user can infer existence of a tenant or view tenant-scoped monitoring data. + +### Engineering Acceptance Outcomes + +- **SC-085-005**: When tenant context is active, `/admin/operations` and `/admin/operations/{run}` clearly show tenant scope and a “Back to ” affordance. +- **SC-085-006**: When tenant context is not active, `/admin/operations/{run}` shows “Back to Operations” and no “Back to tenant”. +- **SC-085-007**: Viewing Monitoring pages does not initiate outbound network requests or start background work as a side effect of rendering. + +## Test Plan *(mandatory)* + +1. **Operations index scope label + CTAs (tenant context)** + - With tenant context active and user entitled, request `/admin/operations`. + - Assert the page indicates `Scope: Tenant — `. + - Assert `Show all tenants` and `Back to ` are available. + +2. **Operations index scope label (no tenant context)** + - With no tenant context active, request `/admin/operations`. + - Assert the page indicates `Scope: Workspace — all tenants`. + +3. **Run detail back affordance (tenant context)** + - With tenant context active and user entitled, request `/admin/operations/{run}`. + - Assert `← Back to ` is available. + - Assert secondary `Show all operations` is available and links to `/admin/operations`. + +4. **Run detail back affordance (no tenant context)** + - With no tenant context active, request `/admin/operations/{run}`. + - Assert only `Back to Operations` is available. + +5. **Deny-as-not-found regression** + - As a user without tenant entitlement, request `/admin/t/{tenant}`. + - Assert deny-as-not-found behavior (404 semantics) and that no tenant identity hints are revealed via Monitoring CTAs. + +6. **Stale tenant context behaves workspace-wide** + - With tenant context active but user not entitled, request `/admin/operations`. + - Assert scope indicates workspace-wide and no tenant name or “Back to tenant” is present. + +7. **No outbound calls on render** + - Assert rendering `/admin/operations` and `/admin/operations/{run}` does not initiate outbound network calls and does not start background work from a view-only GET. diff --git a/specs/085-tenant-operate-hub/tasks.md b/specs/085-tenant-operate-hub/tasks.md new file mode 100644 index 0000000..14613d5 --- /dev/null +++ b/specs/085-tenant-operate-hub/tasks.md @@ -0,0 +1,192 @@ + +--- +description: "Task list for Spec 085 — Tenant Operate Hub / Tenant Overview IA" +--- + +# Tasks: Spec 085 — Tenant Operate Hub / Tenant Overview IA + +**Input**: Design documents from `/specs/085-tenant-operate-hub/` + +**Required**: +- `specs/085-tenant-operate-hub/plan.md` +- `specs/085-tenant-operate-hub/spec.md` + +**Additional docs present**: +- `specs/085-tenant-operate-hub/research.md` +- `specs/085-tenant-operate-hub/data-model.md` +- `specs/085-tenant-operate-hub/contracts/openapi.yaml` +- `specs/085-tenant-operate-hub/quickstart.md` + +**Tests**: REQUIRED (runtime UX + security semantics; Pest) + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the existing code touchpoints and test harness for Spec 085. + +- [X] T001 Confirm canonical Monitoring routes + existing clear-context endpoint in routes/web.php and app/Http/Controllers/ClearTenantContextController.php +- [X] T002 Confirm the Monitoring pages exist and are canonical: app/Filament/Pages/Monitoring/Operations.php, app/Filament/Pages/Operations/TenantlessOperationRunViewer.php, app/Filament/Pages/Monitoring/Alerts.php, app/Filament/Pages/Monitoring/AuditLog.php +- [X] T003 Confirm Tenant panel provider is the entry point for tenant sidebar Monitoring shortcuts in app/Providers/Filament/TenantPanelProvider.php +- [X] T004 Confirm Laravel 11+/12 panel provider registration is in bootstrap/providers.php (not bootstrap/app.php) +- [X] T005 [P] Identify existing monitoring/tenant scoping tests to extend (tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/Operations/TenantlessOperationRunViewerTest.php) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared helper behavior must match Spec 085 semantics before story work. + +- [X] T006 Update scope-label copy and semantics in app/Support/OperateHub/OperateHubShell.php (MUST match FR-085-002 exactly: "Scope: Workspace — all tenants" / "Scope: Tenant — ") +- [X] T007 Ensure OperateHubShell resolves active entitled tenant context safely (Filament tenant when present, otherwise remembered last-tenant id for the current workspace) +- [X] T008 Update OperateHubShell return affordance label to include tenant name ("Back to ") in app/Support/OperateHub/OperateHubShell.php +- [X] T009 Add a helper method to resolve “active tenant AND still entitled” in app/Support/OperateHub/OperateHubShell.php (used by Operations index + run detail to implement stale-tenant-context behavior) +- [X] T010 Ensure Monitoring renders remain DB-only (no outbound calls / no side effects) by standardizing test guards with Http::preventStrayRequests() in tests/Feature/Spec085/*.php and existing coverage tests/Feature/Monitoring/OperationsTenantScopeTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php + +**Checkpoint**: Shared semantics locked; user story work can begin. + +--- + +## Phase 3: User Story 1 — Monitoring feels context-aware (Priority: P1) 🎯 MVP + +**Goal**: When tenant context is active, Monitoring clearly shows tenant scope + deterministic “Back to tenant” and offers explicit “Show all tenants” to exit. + +**Independent Test**: With tenant context active + entitled, GET `/admin/operations` shows `Scope: Tenant — ` and buttons `Back to ` and `Show all tenants`; clicking “Show all tenants” clears tenant context and returns to workspace-wide operations. + +### Tests for User Story 1 (write first) + +- [X] T011 [P] [US1] Add Spec 085 operations header tests in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant scope label + both CTAs) +- [X] T012 [P] [US1] Add stale-tenant-context test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant context set but user not entitled → workspace scope + no tenant name + no back-to-tenant) +- [X] T013 [P] [US1] Add explicit-exit behavior test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (POST /admin/clear-tenant-context clears Filament tenant + last tenant id) +- [X] T014 [P] [US1] Add tenant navigation shortcuts test in tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php (tenant sidebar shows “Monitoring” group with Runs/Alerts/Audit Log) +- [X] T015 [P] [US1] Add “deny-as-not-found” regression tests for canonical Monitoring access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-workspace-member → 404 for /admin/operations and /admin/operations/{run}) +- [X] T016 [P] [US1] Add “deny-as-not-found” regression test for tenant dashboard direct access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-entitled to tenant → 404 for /admin/t/{tenant}) + +### Implementation for User Story 1 + +- [X] T017 [US1] Replace Tenant sidebar "Operations" item with "Monitoring" group shortcuts in app/Providers/Filament/TenantPanelProvider.php (Runs→/admin/operations, Alerts→/admin/alerts, Audit Log→/admin/audit-log) +- [X] T018 [US1] Implement Operations index scope indicator per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (workspace vs tenant; stale context treated as workspace) +- [X] T019 [US1] Implement Operations index CTAs per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (Back to using App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); Show all tenants exits tenant context) +- [X] T020 [US1] Ensure “Show all tenants” uses an explicit server-side action (no implicit GET mutation) in app/Filament/Pages/Monitoring/Operations.php (perform the same mutations as app/Http/Controllers/ClearTenantContextController.php: Filament::setTenant(null, true) + WorkspaceContext::clearLastTenantId(); then redirect to /admin/operations) + +**Checkpoint**: US1 fully testable and meets FR-085-001/002/005/007/010. + +--- + +## Phase 4: User Story 2 — Canonical URLs with explicit scope (Priority: P2) + +**Goal**: Canonical Monitoring URLs never implicitly change tenant context; tenant context may only affect default filtering and must be obvious. + +**Independent Test**: With tenant context active, GET `/admin/operations` does not change tenant context and defaults the list to the active tenant (or otherwise clearly shows it’s tenant-scoped by default). + +### Tests for User Story 2 + +- [X] T021 [P] [US2] Add non-mutation test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (GET /admin/operations does not set/clear tenant context) +- [X] T022 [P] [US2] Add scope label test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (no tenant context → "Scope: Workspace — all tenants") +- [X] T023 [P] [US2] Add default-tenant-filter test in tests/Feature/Monitoring/OperationsTenantScopeTest.php (tenant context active → list defaults to active tenant) + +### Implementation for User Story 2 + +- [X] T024 [US2] Ensure Operations index query applies workspace scoping and (when tenant context is active + entitled) tenant scoping without mutating tenant context in app/Filament/Pages/Monitoring/Operations.php +- [X] T025 [US2] Ensure any default tenant filter is applied as a query/filter default only (no calls to Filament::setTenant() during GET) in app/Filament/Pages/Monitoring/Operations.php + +**Checkpoint**: US2 meets FR-085-003/004/009. + +--- + +## Phase 5: User Story 3 — Deep links are safe and recoverable (Priority: P3) + +**Goal**: On `/admin/operations/{run}`, tenant-context users get a deterministic “Back to ” plus a secondary “Show all operations”; otherwise only “Back to Operations”. + +**Independent Test**: With tenant context active + entitled, GET `/admin/operations/{run}` shows `← Back to ` and `Show all operations`; without tenant context it shows `Back to Operations` only. + +### Tests for User Story 3 + +- [X] T026 [P] [US3] Add run detail header-action tests in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context vs no context) +- [X] T027 [P] [US3] Add stale-tenant-context run detail test in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context set but not entitled → no tenant name, no back-to-tenant) + +### Implementation for User Story 3 + +- [X] T028 [US3] Implement deterministic back affordances for run detail in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (tenant-context+entitled → “← Back to ” + “Show all operations”; else “Back to Operations”) +- [X] T029 [US3] Ensure run detail never reveals tenant identity when the viewer is not entitled (stale tenant context treated as workspace-wide) in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php + +**Checkpoint**: US3 meets FR-085-006/008/010. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T030 [P] Confirm Spec 085 UI Action Matrix matches implemented header actions in specs/085-tenant-operate-hub/spec.md +- [X] T031 [P] Validate manual verification steps in specs/085-tenant-operate-hub/quickstart.md against actual behavior (update doc only if it drifted) +- [X] T037 Ensure “Show all tenants” clears Operations table tenant filter state (prevents stale Livewire table filter state from keeping the list scoped) +- [X] T032 Run formatting on changed files under app/ and tests/ using vendor/bin/sail bin pint --dirty +- [X] T033 Run focused test suite: vendor/bin/sail artisan test --compact tests/Feature/Spec085 tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php +- [X] T034 Fix Filament auth-pattern guard compliance by removing Gate:: usage in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (use $this->authorize(...)) +- [X] T035 Ensure canonical Operate Hub routes sanitize stale/non-entitled tenant context by applying ensure-filament-tenant-selected middleware to /admin/operations, /admin/alerts, /admin/audit-log, and /admin/operations/{run} +- [X] T036 Harden Spec 085-related tests to match final copy/semantics and avoid brittle Livewire DOM assertions (tests/Feature/OpsUx/OperateHubShellTest.php, tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php) + +--- + +## Dependencies & Execution Order + +### Dependency Graph + +```mermaid +graph TD + P1[Phase 1: Setup] --> P2[Phase 2: Foundational] + P2 --> US1[US1 (P1): Context-aware Monitoring entry] + US1 --> US2[US2 (P2): Canonical URLs + explicit scope] + US1 --> US3[US3 (P3): Deep-link back affordances] + US2 --> P6[Phase 6: Polish] + US3 --> P6 +``` + +### User Story Dependencies + +- US1 is the MVP. +- US2 and US3 depend on the shared foundational semantics (scope labels + entitled active tenant resolution). + +--- + +## Parallel Execution Examples + +### US1 + +```text +In parallel: +- T011 (tests) in tests/Feature/Spec085/OperationsIndexHeaderTest.php +- T017 (tenant nav shortcuts) in app/Providers/Filament/TenantPanelProvider.php +Then: +- T018–T020 in app/Filament/Pages/Monitoring/Operations.php +``` + +### US2 + +```text +In parallel: +- T021–T022 (non-mutation + scope label tests) +- T023 (default tenant filter test) in tests/Feature/Monitoring/OperationsTenantScopeTest.php +Then: +- T024–T025 in app/Filament/Pages/Monitoring/Operations.php +``` + +### US3 + +```text +In parallel: +- T026–T027 (run detail tests) in tests/Feature/Spec085/RunDetailBackAffordanceTest.php +Then: +- T028–T029 in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +``` + +--- + +## Implementation Strategy + +### MVP Scope + +- Implement US1 only (T011–T020), run T033, then manually validate via specs/085-tenant-operate-hub/quickstart.md. + +### Incremental Delivery + +- US1 → US2 → US3, keeping each story independently testable. diff --git a/specs/086-retire-legacy-runs-into-operation-runs/checklists/requirements.md b/specs/086-retire-legacy-runs-into-operation-runs/checklists/requirements.md new file mode 100644 index 0000000..6e4ec74 --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Retire Legacy Runs Into Operation Runs + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-09 +**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 + +- The spec uses product-specific terms (e.g., 404 vs 403 semantics) to make authorization behavior testable, but avoids naming specific frameworks or code-level implementation choices. diff --git a/specs/086-retire-legacy-runs-into-operation-runs/contracts/README.md b/specs/086-retire-legacy-runs-into-operation-runs/contracts/README.md new file mode 100644 index 0000000..e5f4ee2 --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/contracts/README.md @@ -0,0 +1,41 @@ +# Contracts (Spec 086) + +This spec does not introduce a new public HTTP API surface. + +## Canonical OperationRun contract (internal) + +Spec 086 tightens and standardizes the internal contract for how operations are created, identified, and displayed. + +### Run creation contract + +- Start surfaces must create the `operation_runs` row **before** dispatching asynchronous work. +- Jobs must receive the `OperationRun` (or its id) and must **not** attempt a fallback-create. + +### Identity / idempotency contract + +Operation run identity is enforced by a partial unique index for active states. + +Planned identity rules by type: +- `inventory.sync` and `directory_groups.sync`: deterministic identity (while-active dedupe) +- `backup_schedule.run_now` and `backup_schedule.retry`: unique-per-click identity (nonce) +- `backup_schedule.scheduled`: deterministic identity by `(backup_schedule_id, scheduled_for)` (strict) + +### Context contract (selected keys) + +The `operation_runs.context` JSON is used for: +- “Target” display (via `target_scope`) +- “Related” deep links (via `OperationRunLinks::related`) +- provenance (trigger source, schedule id, initiating user) + +Keys referenced in existing UI code: +- `provider_connection_id` +- `backup_schedule_id` +- `backup_schedule_run_id` +- `restore_run_id` +- `target_scope` + +## Graph Contract Registry + +All Microsoft Graph calls remain required to go through `GraphClientInterface` and be modeled in `config/graph_contracts.php`. + +Spec 086 removes Graph calls from Filament render/search/label callbacks (DB-only rendering), and moves those lookups behind cached tables + asynchronous sync operations. diff --git a/specs/086-retire-legacy-runs-into-operation-runs/data-model.md b/specs/086-retire-legacy-runs-into-operation-runs/data-model.md new file mode 100644 index 0000000..9084d5f --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/data-model.md @@ -0,0 +1,131 @@ +# Data Model (Spec 086) + +This feature consolidates execution tracking into `operation_runs` while keeping legacy run tables as read-only history. + +## Entities + +### 1) OperationRun (canonical) + +**Table:** `operation_runs` + +**Purpose:** Single source of truth for execution tracking: status/progress, results (counts), failures, provenance/context. + +**Fields (current):** +- `id` +- `workspace_id` (FK, required) +- `tenant_id` (FK, nullable) +- `user_id` (FK, nullable) +- `initiator_name` (string) +- `type` (string; see OperationRunType registry) +- `status` (string; queued|running|completed) +- `outcome` (string; pending|succeeded|partially_succeeded|failed|blocked…) +- `run_identity_hash` (string; deterministic hash for idempotency) +- `summary_counts` (json/array; normalized counts + key metadata) +- `failure_summary` (json/array; structured failures, sanitized) +- `context` (json/array; provenance + inputs + target scope) +- `started_at`, `completed_at`, `created_at`, `updated_at` + +**Indexes / constraints (current):** +- `(workspace_id, type, created_at)` and `(workspace_id, created_at)` +- `(tenant_id, type, created_at)` and `(tenant_id, created_at)` +- Partial unique indexes for active runs: + - tenant-scoped: unique `(tenant_id, run_identity_hash)` where `tenant_id IS NOT NULL` and `status IN ('queued','running')` + - workspace-scoped: unique `(workspace_id, run_identity_hash)` where `tenant_id IS NULL` and `status IN ('queued','running')` + +**Context contract (current patterns):** +The `context` JSON is used for “related links” and display. Existing keys include (non-exhaustive): +- `provider_connection_id` +- `backup_schedule_id` +- `backup_schedule_run_id` +- `backup_set_id` +- `policy_id` +- `restore_run_id` +- `target_scope` (nested object) +- `selection` and `idempotency` objects for bulk operations + +**Required additions for Spec 086 (planned):** +- New `type` values: + - `backup_schedule.scheduled` + - `directory_role_definitions.sync` +- Scheduled backup context keys: + - `backup_schedule_id` + - `scheduled_for` (UTC timestamp/minute) + - Optional `backup_schedule_run_id` if the legacy table remains for history during transition + +### 2) InventorySyncRun (legacy) + +**Table:** `inventory_sync_runs` + +**Purpose:** Historical record (read-only) for pre-cutover tracking. + +**Key fields:** +- `tenant_id` +- `selection_hash` +- `selection_payload` (nullable) +- status + timestamps + counters + +**Planned optional mapping:** +- Add nullable `operation_run_id` FK to enable deterministic redirect to canonical viewer when present. No backfill required. + +### 3) EntraGroupSyncRun (legacy) + +**Table:** `entra_group_sync_runs` + +**Purpose:** Historical record (read-only) for pre-cutover group sync tracking. + +**Key fields:** +- `tenant_id` +- `selection_key`, `slot_key` +- status + error fields + counters + +**Planned optional mapping:** +- Add nullable `operation_run_id` FK to enable deterministic redirect when present. + +### 4) BackupScheduleRun (legacy) + +**Table:** `backup_schedule_runs` + +**Purpose:** Historical record of backup schedule executions. + +**Planned behavior change:** +- Distinguish scheduled fires vs manual/retry at the OperationRun level by introducing `backup_schedule.scheduled` type. + +**Planned optional mapping:** +- Add nullable `operation_run_id` FK to enable deterministic redirect when present. + +### 5) RestoreRun (domain) + +**Table:** `restore_runs` + +**Purpose:** Domain workflow record (requested items, dry-run, preview/results). Execution tracking and “View run” uses `operation_runs`. + +**Current linkage approach:** +- Canonical runs store `restore_run_id` in `operation_runs.context` (used by `OperationRunLinks::related`). + +## Enumerations / Registries + +### OperationRunType + +**Location:** `app/Support/OperationRunType.php` + +**Planned additions:** +- `BackupScheduleScheduled = 'backup_schedule.scheduled'` +- `DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'` + +### OperationCatalog + +**Location:** `app/Support/OperationCatalog.php` + +**Planned additions:** +- Human label for `backup_schedule.scheduled` +- Human label for `directory_role_definitions.sync` +- Optional expected durations (if known) + +## State transitions + +### OperationRun + +- `queued` → `running` → `completed` +- `outcome` starts as `pending`, transitions to one of: `succeeded`, `partially_succeeded`, `failed`, `blocked`. + +The canonical update surface is `OperationRunService` (`dispatchOrFail`, `updateRun`, `appendFailures`, `incrementSummaryCounts`, etc.). diff --git a/specs/086-retire-legacy-runs-into-operation-runs/plan.md b/specs/086-retire-legacy-runs-into-operation-runs/plan.md new file mode 100644 index 0000000..2730ac7 --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/plan.md @@ -0,0 +1,109 @@ +# Implementation Plan: Retire Legacy Runs Into Operation Runs + +**Branch**: `086-retire-legacy-runs-into-operation-runs` | **Date**: 2026-02-10 | **Spec**: `specs/086-retire-legacy-runs-into-operation-runs/spec.md` +**Input**: Feature specification from `specs/086-retire-legacy-runs-into-operation-runs/spec.md` + +## Summary + +Retire legacy “run tracking” tables as the primary execution tracker for in-scope operations (inventory sync, directory groups sync, backup schedule runs, restore execution, and directory role definitions sync) and make `operation_runs` the canonical source of truth. + +Key implementation approach: +- Use the existing tenantless canonical viewer `/admin/operations/{run}` (Filament page `TenantlessOperationRunViewer`) and ensure it remains DB-only at render time. +- Enforce the clarified 404/403 semantics for run viewing: non-members 404, members missing capability 403, where the view capability equals the start capability. +- Enforce dispatch-time OperationRun creation for every start surface; jobs never fallback-create. +- Apply explicit run identity rules per operation type (dedupe vs unique-per-click vs strict schedule dedupe), including strict scheduled backup idempotency: at most one canonical run ever per (schedule_id, intended fire-time). +- Remove Graph calls from UI render/search/label callbacks by using cached directory data (groups + role definitions) and “Sync now” operations. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4 +**Storage**: PostgreSQL (via Sail) +**Testing**: Pest v4 (PHPUnit v12 runner) +**Target Platform**: Web application (Laravel + Filament admin panel) +**Project Type**: web +**Performance Goals**: Operations viewer + Monitoring pages render from DB state only; canonical viewer loads in ~2s under normal conditions +**Constraints**: No outbound HTTP in Monitoring/Operations rendering/search/label callbacks (OPS-EX-AUTH-001); dispatch-time OperationRun creation; jobs must never fallback-create; strict 404/403 isolation semantics +**Scale/Scope**: TenantPilot admin workflows; multiple operation families; staged cutover with legacy history preserved + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: No change to the meaning of inventory vs snapshots/backups; this spec only changes execution tracking. +- Read/write separation: Start surfaces remain enqueue-only; destructive-like actions are not added here; audit logging remains required for mutations. +- Graph contract path: UI render/search/label callbacks must become DB-only; any remaining Graph calls stay behind `GraphClientInterface` + `config/graph_contracts.php`. +- Deterministic capabilities: Run viewing must be capability-gated using the existing capability registry (no raw strings). +- RBAC-UX: Enforce clarified semantics for run viewing: non-members 404, members missing capability 403; authorization enforced server-side via Policy/Gate. +- Workspace isolation: Canonical tenantless `/admin/operations/{run}` continues to enforce workspace membership (deny-as-not-found). +- Global search: `OperationRunResource` stays non-globally-searchable; no new global-search surfaces introduced. +- Run observability: All in-scope long-running/scheduled/remote operations are tracked via `OperationRun`; Monitoring pages remain DB-only. +- Automation: Scheduled backup run creation uses strict idempotency per schedule + intended fire-time. +- Badge semantics (BADGE-001): Run status/outcome badges already use `BadgeRenderer`; do not introduce ad-hoc mappings. +- Filament UI Action Surface Contract: Legacy resources remain read-only; canonical operations pages already define inspection affordances. + +## Project Structure + +### Documentation (this feature) + +```text +specs/086-retire-legacy-runs-into-operation-runs/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── README.md +└── tasks.md # To be created by /speckit.tasks +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ │ ├── Monitoring/ +│ │ └── Operations/ +│ └── Resources/ +├── Http/ +│ └── Middleware/ +├── Jobs/ +├── Models/ +├── Policies/ +├── Services/ +└── Support/ + +config/ +├── graph.php +└── graph_contracts.php + +database/ +└── migrations/ + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Laravel web application (monolith) with Filament admin panel. + +## Complexity Tracking + +No constitution violations are required for this feature. + +## Phase Plan + +Phase 0/1 deliverables are already captured in: +- `specs/086-retire-legacy-runs-into-operation-runs/research.md` +- `specs/086-retire-legacy-runs-into-operation-runs/data-model.md` +- `specs/086-retire-legacy-runs-into-operation-runs/contracts/README.md` +- `specs/086-retire-legacy-runs-into-operation-runs/quickstart.md` + +Phase 2 (tasks) will be produced via `/speckit.tasks` and should slice work by operation family: +1) Authorization: capability-gate canonical run viewing (404 vs 403 semantics). +2) Backup schedules: add `backup_schedule.scheduled` + strict idempotency; make manual runs unique-per-click. +3) Directory groups: stop writing legacy rows; keep legacy pages read-only; ensure dispatch-time OperationRun creation. +4) Inventory sync: stop writing legacy rows; ensure dispatch-time OperationRun creation and no UI Graph calls. +5) Tenant configuration: remove Graph calls from render/search/labels; add role definitions cache + “Sync now” operation. +6) Restore: ensure execution tracking uses OperationRun only; legacy restore domain records remain as domain entities. diff --git a/specs/086-retire-legacy-runs-into-operation-runs/quickstart.md b/specs/086-retire-legacy-runs-into-operation-runs/quickstart.md new file mode 100644 index 0000000..89b6e92 --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/quickstart.md @@ -0,0 +1,42 @@ +# Quickstart (Spec 086) + +This quickstart is for validating Spec 086 changes locally using Sail. + +## Prereqs + +- `vendor/bin/sail up -d` + +## Run formatting + +- `vendor/bin/sail bin pint --dirty` + +## Run targeted tests + +Use the minimal test subset relevant to the PR slice you are working on: + +- `vendor/bin/sail artisan test --compact --filter=OperationRun` +- `vendor/bin/sail artisan test --compact tests/Feature` (narrow further to the new/changed files) + +## Manual verification checklist + +### Canonical viewer + +- Trigger an operation that creates an `OperationRun`. +- Open the canonical URL (from notification action): `/admin/operations/{runId}`. +- Confirm the viewer renders from persisted DB state only. + +### Authorization semantics + +- As a non-workspace-member user, opening `/admin/operations/{runId}` returns 404. +- As a workspace member without the required capability for that run type, opening the viewer returns 403. + +### Dedupe semantics + +- Inventory sync / directory group sync: attempting to start while active reuses the existing active run and links to it. +- Manual backup schedule run now/retry: each click produces a distinct `OperationRun`. +- Scheduled backup: double-fire for the same schedule + intended minute produces at most one `OperationRun`. + +### DB-only forms + +- Tenant configuration selectors (directory groups, role definitions) render and search without outbound HTTP calls. +- “Sync now” actions enqueue operations and provide “View run” link. diff --git a/specs/086-retire-legacy-runs-into-operation-runs/research.md b/specs/086-retire-legacy-runs-into-operation-runs/research.md new file mode 100644 index 0000000..9473199 --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/research.md @@ -0,0 +1,87 @@ +# Research (Spec 086) + +This document resolves the unknowns needed to write an implementation plan for “Retire Legacy Runs Into Operation Runs”. It is based on repository inspection (no new external dependencies). + +## Decisions + +### 1) Canonical run viewer is already implemented; keep the route shape + +- **Decision:** Use the existing tenantless canonical viewer route `admin.operations.view` (path pattern `/admin/operations/{run}`) implemented by the Filament page `TenantlessOperationRunViewer`. +- **Rationale:** This already enforces “tenantless deep link” while still doing workspace / tenant entitlement checks server-side through `Gate::authorize('view', $run)`. +- **Alternatives considered:** Create a second viewer page or route. Rejected because it would introduce duplicate UX and increase the chance of policy drift. + +Repository anchors: +- Canonical viewer page: `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- Link helper: `app/Support/OperationRunLinks.php` +- Workspace selection middleware explicitly treats `/admin/operations/{id}` as workspace-optional: `app/Http/Middleware/EnsureWorkspaceSelected.php` + +### 2) OperationRun is persisted and DB-rendered; schema supports workspace-tenant and workspace-only runs + +- **Decision:** Treat `operation_runs` as the canonical persistence format for status/progress/results. +- **Rationale:** The schema already includes `workspace_id` (required) and `tenant_id` (nullable), enabling both tenant-plane and workspace-plane operations. +- **Alternatives considered:** Separate tables per operation family. Rejected because it breaks the Monitoring → Operations single source of truth principle. + +Repository anchors: +- Migrations: `database/migrations/2026_01_16_180642_create_operation_runs_table.php`, `database/migrations/2026_02_04_090030_add_workspace_id_to_operation_runs_table.php` +- Model: `app/Models/OperationRun.php` + +### 3) View authorization must be capability-gated per operation type (in addition to membership) + +- **Decision:** Extend run viewing authorization to require the same capability used to start the operation type. +- **Rationale:** Spec 086 clarifications require: non-members get 404; members without capability get 403; and the “view” capability equals the “start” capability. +- **Implementation approach (planned):** Update `OperationRunPolicy::view()` to: + 1) Keep existing workspace membership and tenant entitlement checks (deny-as-not-found). + 2) Resolve required capability from `OperationRun->type` using a centralized mapping helper. + 3) If capability is known and tenant-scoped, enforce `403` when the member lacks it. + +Repository anchors: +- Current policy (membership + tenant entitlement only): `app/Policies/OperationRunPolicy.php` +- Existing capability enforcement in start surfaces (examples): + - Inventory sync start: `Capabilities::TENANT_INVENTORY_SYNC_RUN` in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` + - Directory groups sync start: `Capabilities::TENANT_SYNC` in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` + - Backup schedule run/retry: `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` in `app/Filament/Resources/BackupScheduleResource.php` + +### 4) Run identity / dedupe strategy varies by operation type + +- **Decision:** Use existing `OperationRunService` helpers but apply type-specific identity rules: + - `inventory.sync` and `directory_groups.sync`: **while-active dedupe** based on deterministic inputs (continue using `ensureRun(...)`-style identity). + - `backup_schedule.run_now` and `backup_schedule.retry`: **unique per click** (no dedupe). Create a new run each time by including a nonce in identity inputs (e.g., UUID). + - `backup_schedule.scheduled`: **strict dedupe** per `(backup_schedule_id, scheduled_for)`; create a new operation type `backup_schedule.scheduled` and use `ensureRunWithIdentity(...)` keyed by schedule + intended fire-time. +- **Rationale:** Matches explicit spec clarifications and protects against scheduler double-fire. +- **Alternatives considered:** + - Keep using `ensureRun(...)` for manual runs → rejected (dedupes while active). + - Use legacy table unique constraints as idempotency → rejected (spec requires OperationRun is canonical). + +Repository anchors: +- `ensureRun(...)` and `ensureRunWithIdentity(...)`: `app/Services/OperationRunService.php` +- Existing partial unique index for active runs: `operation_runs_active_unique_*` in the migrations above. + +### 5) Legacy run tables are real and currently written to; deterministic redirect requires an explicit mapping field + +- **Decision:** Legacy tables remain viewable and read-only, but should not be relied on for current execution tracking. +- **Rationale:** Spec requires “no new legacy rows” for in-scope operations. Today, some start surfaces still create legacy rows (e.g., inventory/group sync, backup schedule runs). +- **Planned design:** + - Stop creating new legacy rows as part of the cutover PRs. + - Implement legacy “view” redirect behavior only when a record has a canonical mapping. + - To make redirects deterministic without a backfill, add an optional `operation_run_id` FK column to legacy tables that we intend to redirect (only populated for rows created after the migration; older rows remain legacy-only view). +- **Alternatives considered:** Derive mapping by recomputing hashes and searching by time window. Rejected as non-deterministic and likely to pick the wrong run when identities collide historically. + +Repository anchors (legacy tables): +- Inventory sync runs: `database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php` +- Directory group sync runs: `database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php` +- Backup schedule runs: `database/migrations/**create_backup_schedule_runs**` (used in `BackupScheduleResource`) +- Restore runs (domain): `database/migrations/2025_12_10_000150_create_restore_runs_table.php` + +### 6) DB-only rendering constraint is already enforced in Monitoring pages, but Tenant configuration forms still call Graph + +- **Decision:** Remove outbound Graph calls from configuration-form search/labels by introducing cached directory role definitions and using cached directory groups. +- **Rationale:** Constitution OPS-EX-AUTH-001 + Spec 086 FR-006/FR-015 require render/search/label resolution to be DB-only. +- **Repository finding:** `TenantResource` currently queries Graph for role definitions in selector callbacks. + +Repository anchors: +- Graph call sites inside UI callbacks: `app/Filament/Resources/TenantResource.php` (roleDefinitions search/label methods) + +## Open items (resolved enough for planning) + +- Exact schema for the new role definition cache tables and the sync job contract will be specified in `data-model.md` and implemented in Phase PR(s). +- The capability mapping for run viewing will be implemented via a centralized helper; the plan will enumerate required capabilities per in-scope operation type. diff --git a/specs/086-retire-legacy-runs-into-operation-runs/spec.md b/specs/086-retire-legacy-runs-into-operation-runs/spec.md new file mode 100644 index 0000000..95ec441 --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/spec.md @@ -0,0 +1,160 @@ +# Feature Specification: Retire Legacy Runs Into Operation Runs + +**Feature Branch**: `086-retire-legacy-runs-into-operation-runs` +**Created**: 2026-02-09 +**Status**: Draft +**Input**: User description: "Retire legacy run tracking into canonical operation runs, with DB-only rendering and dispatch-time run creation. Legacy run tables remain read-only history." + +## Clarifications + +### Session 2026-02-10 + +- Q: For manual backup schedule runs (`backup_schedule.run_now`) and retries (`backup_schedule.retry`), should the system dedupe while a run is active, or always create a new run per click? → A: Always create a new run per click (no dedupe). +- Q: Who may view the canonical run detail page (“View run”)? → A: Workspace members may view runs only if they also have the required capability for that operation type; non-members get 404, members without capability get 403. +- Q: Which capability should be required to view a run (“View run”)? → A: Use the same capability as starting that operation type. +- Q: For `backup_schedule.scheduled`, how should dedupe work? → A: Strict dedupe per schedule and intended fire-time (at most one run). +- Q: For the role definitions cache “Sync now” operation, should it use a new dedicated operation type or reuse an existing one? → A: Use a new dedicated operation type. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Start an operation with an immediate canonical run link (Priority: P1) + +As a workspace member, I can start long-running operations (inventory sync, directory groups sync, scheduled backups, restore execution, directory role definitions sync) and immediately receive a stable “View run” link that I can open and share. + +**Why this priority**: This removes the “run link appears later / changes” ambiguity, improves auditability, and prevents duplicate tracking paths. + +**Independent Test**: Trigger each supported operation start surface and verify a canonical run record exists before work begins, and that the canonical viewer loads from persisted state. + +**Acceptance Scenarios**: + +1. **Given** a workspace member with the required capability, **When** they start an inventory sync, **Then** a canonical run exists immediately and the UI shows a stable “View run” link. +2. **Given** a scheduled backup fire event, **When** the scheduler dispatches work, **Then** a canonical run exists immediately and the same fire event cannot create duplicates. +3. **Given** a workspace member without the required capability, **When** they attempt to start the operation, **Then** the request is rejected with a capability error (403) and no run is created. + +--- + +### User Story 2 - Monitor executions from a single canonical viewer (Priority: P2) + +As a workspace member, I can open an operations viewer link for any run and see status, progress, results, and errors without the page triggering outbound calls. + +Legacy “run history” pages remain available for older historical rows but cannot start or retry anything. + +**Why this priority**: A single viewer reduces support load, enables consistent deep linking, and avoids UI latency and rate-limiting from outbound calls. + +**Independent Test**: Load the canonical viewer and legacy history pages using outbound client fakes/mocks and assert no outbound calls occur during rendering/search. + +**Acceptance Scenarios**: + +1. **Given** a run exists, **When** a user opens its canonical operations link, **Then** the page renders only from persisted state and performs no outbound calls. +2. **Given** a legacy run history record that has a known canonical mapping, **When** a user opens the legacy “view” page, **Then** they are redirected to the canonical operations viewer. +3. **Given** a legacy run history record without a canonical mapping, **When** a user opens the legacy “view” page, **Then** they see a read-only historical record and no new canonical run is created. + +--- + +### User Story 3 - Use cached directory data in forms without blocking calls (Priority: P3) + +As a workspace member configuring tenant-related settings, I can search/select directory groups and role definitions using cached data. If cached data is missing or stale, I can trigger an asynchronous sync (“Sync now”) without the form making outbound calls. + +**Why this priority**: Prevents slow, flaky UI and rate-limits from inline lookups, while keeping the configuration flow usable. + +**Independent Test**: Render the configuration form and exercise search/label rendering while asserting outbound clients are not called. + +**Acceptance Scenarios**: + +1. **Given** cached directory groups exist, **When** the user searches for groups, **Then** results and labels come from cached data. +2. **Given** cached role definitions are missing, **When** the user opens the role definition selector, **Then** the UI indicates “data not available yet” and offers a non-destructive “Sync now” action. +3. **Given** the user triggers “Sync now”, **When** the sync starts, **Then** a canonical run is created immediately and the user can open its canonical “View run” link. + +### Edge Cases + +- A scheduler fires the same scheduled backup more than once for the same intended time. +- A user triggers the same sync while an identical sync is still active (dedupe/while-active semantics). +- A job fails before writing progress; the canonical run still exists and shows a clear failure state. +- A legacy history row exists but has no canonical mapping; it must remain viewable without creating new canonical runs. +- A non-member attempts to access a canonical operations link; response must be deny-as-not-found (404). +- A member lacks capability: start surfaces must reject (403) and the UI must reflect disabled affordances. +- Cached directory data is empty or stale; UI must not block on outbound calls and must provide a safe way to sync. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature includes long-running/queued/scheduled work. The spec MUST describe tenant isolation, run observability (type/identity/visibility), and tests. + +**Constitution alignment (RBAC-UX):** This feature changes authorization behavior and navigation paths. It MUST define 404 vs 403 semantics and ensure server-side enforcement for operation-start flows. + +**Constitution alignment (OPS-EX-AUTH-001):** Outbound HTTP without a canonical run is not allowed on Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** Any new/changed status presentation for runs MUST remain centralized and covered by tests. + +**Constitution alignment (Admin UI Action Surfaces):** This feature changes multiple admin UI surfaces and MUST satisfy the UI Action Surface Contract (see matrix below). + +### Functional Requirements + +- **FR-001 (Canonical tracking)**: The system MUST treat the canonical run record as the single source of truth for execution tracking (status, progress, results, errors) for the in-scope operations. +- **FR-002 (Dispatch-time creation)**: Every start surface (UI action, console command, scheduler, internal service) MUST create the canonical run record before dispatching any asynchronous work. +- **FR-003 (No job fallback-create)**: Background workers MUST NOT create canonical run records as a fallback; missing run identifiers are treated as a fatal contract violation. +- **FR-004 (Canonical deep-link)**: The system MUST support exactly one canonical deep-link format for viewing runs which is tenantless and stable. + +- **FR-005 (Membership + capability rules)**: Access to operation runs MUST follow these rules: + - Non-members of the workspace scope MUST receive deny-as-not-found (404). + - Workspace members who lack the required capability for the operation type MUST receive 403. +- **FR-005a (View capability mapping)**: “View run” MUST require the same capability as “Start” for the corresponding operation type. +- **FR-006 (DB-only rendering)**: Operations/monitoring and run viewer pages MUST render solely from persisted data and MUST NOT perform outbound calls during rendering/search/label resolution. + +- **FR-007 (Legacy history read-only)**: Legacy run history records MUST remain viewable as historical data, but MUST be strictly read-only (no start/retry/execute actions). +- **FR-008 (Legacy redirects)**: If a legacy history record includes a canonical mapping, the legacy “view” page MUST redirect deterministically to the canonical viewer; otherwise it MUST display legacy-only history. +- **FR-009 (No new legacy rows)**: For the in-scope operations, the system MUST stop writing new legacy run history rows. Existing legacy history remains unchanged. + +- **FR-010 (Scheduled backup classification)**: Scheduled backup executions MUST be represented with a distinct operation type (not conflated with manual runs). +- **FR-011 (Run identity & dedupe)**: The system MUST compute deterministic run identities for dedupe and scheduler double-fire protection, and MUST define whether each type dedupes “while active” or is strictly unique. +- **FR-011b (Scheduled backups are strict)**: Scheduled backup executions MUST use strict dedupe per schedule and intended fire-time (at most one canonical run ever per schedule per intended fire-time). +- **FR-011a (Backup manual runs are unique)**: Manual backup schedule runs (“run now”) and retries MUST be unique per user action (no while-active dedupe). +- **FR-012 (Inputs & provenance)**: The system MUST store operation inputs and provenance (target tenant/schedule, trigger source, optional initiating user) on the canonical run record. + +- **FR-013 (Structured results)**: The system MUST store a standard, structured summary of results (counts) and failures (structured error entries) on the canonical run record. +- **FR-014 (Restore domain vs execution)**: Restore workflow domain records may remain as domain entities, but execution tracking and “View run” affordances MUST use the canonical run record exclusively. + +- **FR-015 (Cached directory data)**: The system MUST provide cached directory group data and cached role definition data to support search and label rendering in configuration forms without outbound calls. +- **FR-015a (Role definitions sync type)**: The role definitions cache sync MUST use a dedicated operation type (e.g., `directory_role_definitions.sync`) to keep identities, results, and auditability distinct from other sync operations. +- **FR-016 (Safe “Sync now”)**: When cached directory data is missing, the UI MUST provide a non-destructive “Sync now” action that starts an asynchronous sync and immediately exposes the canonical run link. + +#### Assumptions + +- A canonical run model/viewer already exists and is suitable for monitoring long-running operations. +- Outbound calls to external services are permitted only in asynchronous execution paths and are observable via the canonical run record. + +#### Out of Scope + +- Backfilling legacy history into canonical runs. +- Dropping/removing legacy run history tables. +- Introducing new cross-workspace analytics. + +## UI Action Matrix *(mandatory when admin UI 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Operations viewer | Canonical run viewer route | None | Open by canonical link | None | None | None | None | N/A | Yes (canonical run record metadata) | Must be DB-only rendering; non-member is 404 | +| Inventory sync start | Inventory admin UI | Start sync | View run link appears after start | View run | None | None | N/A | N/A | Yes | Capability-gated; creates canonical run before dispatch | +| Directory groups sync start | Directory groups admin UI & console | Sync now | View run link appears after start | View run | None | Sync now (when cache empty) | N/A | N/A | Yes | Single dispatcher entry; legacy start actions removed | +| Backup schedule runs list | Backup schedule detail | None | List links open canonical viewer | View run | None | None | N/A | N/A | Yes | Includes scheduled/manual/retry runs; scheduled has distinct type | +| Tenant configuration selectors | Tenant settings forms | Sync now (when cache empty) | Search from cached data | None | None | Sync now | N/A | Save/Cancel | Yes | No outbound calls in search/label resolution | +| Legacy run history pages | Archive/history areas | None | View (read-only) | View only | None | None | None | N/A | Yes (historical) | No Start/Retry; redirect only if canonical mapping exists | + +### Key Entities *(include if feature involves data)* + +- **Canonical Run**: A single, shareable execution record containing type, identity, provenance, status, progress, results, and errors. +- **Legacy Run History Record**: A historical record for prior run-tracking paths; viewable but not mutable. +- **Managed Tenant**: The tenant context targeted by operations. +- **Backup Schedule**: A schedule configuration that can trigger executions automatically. +- **Restore Run (Domain Record)**: The domain workflow record for restore; links to canonical execution runs. +- **Directory Group Cache**: Cached group metadata used for searching/label rendering in forms. +- **Role Definition Cache**: Cached role definition metadata used for searching/label rendering in forms. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of newly started in-scope operations create a canonical run record before any asynchronous work is dispatched. +- **SC-002**: Over a 30-day staging observation window, 0 new legacy run history rows are created for in-scope operations. +- **SC-003**: Operations viewer and monitoring pages perform 0 outbound calls during rendering/search/label resolution (verified by automated tests). +- **SC-004**: For scheduled backups, duplicate scheduler fires for the same schedule and intended fire-time result in at most 1 canonical run. +- **SC-005**: Users can open a canonical “View run” link and see status/progress within 2 seconds in typical conditions. diff --git a/specs/086-retire-legacy-runs-into-operation-runs/tasks.md b/specs/086-retire-legacy-runs-into-operation-runs/tasks.md new file mode 100644 index 0000000..d5843e2 --- /dev/null +++ b/specs/086-retire-legacy-runs-into-operation-runs/tasks.md @@ -0,0 +1,143 @@ +--- + +description: "Task list for Spec 086 implementation" +--- + +# Tasks: Retire Legacy Runs Into Operation Runs (086) + +**Input**: Design documents from `specs/086-retire-legacy-runs-into-operation-runs/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md + +**Tests**: REQUIRED (Pest) — runtime behavior changes must be covered. + +## Phase 1: Setup (Shared Infrastructure) + + - [x] T001 Confirm baseline green test subset via `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` +- [x] T002 Confirm Filament v5 + Livewire v4 constraints are respected for any touched pages/resources in `app/Filament/**` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives required by all stories. + +- [x] T003 Add centralized “run type → required capability” resolver in `app/Support/Operations/OperationRunCapabilityResolver.php` +- [x] T004 Update `app/Policies/OperationRunPolicy.php` to enforce clarified 404/403 semantics (non-member 404; member missing capability 403) using T003 +- [x] T005 [P] Add/extend operation type registry for new types in `app/Support/OperationRunType.php` +- [x] T006 [P] Add/extend operation labels/catalog entries in `app/Support/OperationCatalog.php` +- [x] T007 Add tests covering view authorization semantics in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` (404 vs 403 + capability-gated view) + +**Checkpoint**: Canonical viewer authorization matches spec; new run types exist in registries. + +--- + +## Phase 3: User Story 1 — Start an operation with an immediate canonical run link (Priority: P1) + +**Goal**: All start surfaces create an `operation_runs` record at dispatch time; no job fallback-create; “View run” link is stable. + +**Independent Test**: Start each in-scope operation and assert the `operation_runs` row exists before work begins, with correct type/identity/context and a stable tenantless view URL. + +### Tests (US1) + +- [x] T008 [P] [US1] Add/extend tests for OperationRun dispatch-time creation in `tests/Feature/OperationRunServiceTest.php` +- [X] T009 [P] [US1] Add/extend tests for start-surface authorization (403 prevents run creation) in `tests/Feature/RunStartAuthorizationTest.php` + +### Implementation (US1) + +- [X] T010 [US1] Ensure inventory sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` +- [X] T011 [US1] Ensure directory groups sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` +- [X] T012 [US1] Ensure backup schedule manual run-now creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php` +- [X] T013 [US1] Ensure backup schedule retry creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php` +- [x] T014 [US1] Ensure scheduled backup dispatcher creates OperationRun before dispatch with strict identity by (schedule_id, scheduled_for) and type `backup_schedule.scheduled` in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` +- [x] T014a [US1] Enforce strict scheduled backup idempotency (at most one canonical run ever per schedule_id + intended fire-time), using an explicit DB constraint and/or lock strategy aligned with `OperationRunService` identities +- [x] T015 [US1] Enforce “no job fallback-create” by validating required OperationRun context is present; fail fast if missing in `app/Jobs/RunInventorySyncJob.php`, `app/Jobs/EntraGroupSyncJob.php`, and `app/Jobs/RunBackupScheduleJob.php` + +### Restore (US1) + +- [X] T015a [P] [US1] Add/extend tests that starting a restore execution creates an OperationRun at dispatch time (target existing restore execution tests under `tests/Feature/RestoreRunWizardExecuteTest.php` and/or `tests/Feature/ExecuteRestoreRunJobTest.php`) +- [X] T015b [US1] Ensure the restore execution start surface creates OperationRun before dispatch and surfaces the stable canonical “View run” link (adjust the Filament restore execution action/page used in the wizard flow) +- [x] T015c [US2] Ensure restore domain records link to canonical OperationRuns for observability (align with FR-014; no legacy fallback-create) + +**Checkpoint**: Starting operations always yields a stable `/admin/operations/{run}` link immediately. + +--- + +## Phase 4: User Story 2 — Monitor executions from a single canonical viewer (Priority: P2) + +**Goal**: Canonical viewer and Monitoring pages remain DB-only; legacy run history pages are read-only and redirect only when a deterministic mapping exists. + +**Independent Test**: Load canonical viewer and legacy view pages while asserting no outbound Graph calls occur during render/search/label callbacks. + +### Tests (US2) + +- [X] T016 [P] [US2] Add tests asserting Monitoring pages render DB-only (no Graph calls) in `tests/Feature/Monitoring/MonitoringOperationsTest.php` +- [X] T017 [P] [US2] Add tests for legacy-to-canonical redirect when mapping exists and no redirect when mapping absent in `tests/Feature/Operations/` (new file: `tests/Feature/Operations/LegacyRunRedirectTest.php`) + +### Implementation (US2) + +- [X] T018 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `inventory_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_inventory_sync_runs_table.php`) +- [X] T019 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `entra_group_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_entra_group_sync_runs_table.php`) +- [X] T020 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `backup_schedule_runs` (new migration in `database/migrations/**_add_operation_run_id_to_backup_schedule_runs_table.php`) +- [x] T021 [US2] Stop writing NEW legacy run rows for inventory sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Inventory/InventorySyncService.php` and any start surfaces) +- [x] T022 [US2] Stop writing NEW legacy run rows for directory group sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Directory/EntraGroupSyncService.php` and any start surfaces) +- [x] T023 [US2] Stop writing NEW legacy run rows for backup schedule executions and use `operation_runs` only for execution tracking; keep legacy table strictly read-only history for existing rows (adjust dispatcher and UI surfaces in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` and `app/Filament/Resources/BackupScheduleResource.php`) +- [x] T023a [US2] Update Backup Schedule UI to show new executions from `operation_runs` (query by type + context like schedule_id) and link to canonical viewer; legacy runs list remains history-only +- [X] T024 [US2] Implement deterministic redirect on legacy “view” pages when `operation_run_id` exists in `app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php` and `app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php` +- [x] T025 [US2] Ensure legacy run history pages remain strictly read-only (remove/disable start/retry actions) in `app/Filament/Resources/InventorySyncRunResource.php`, `app/Filament/Resources/EntraGroupSyncRunResource.php`, and `app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php` + +**Checkpoint**: Canonical viewer is the only execution-tracker UI; legacy is view-only and redirects only when mapped. + +--- + +## Phase 5: User Story 3 — Use cached directory data in forms without blocking calls (Priority: P3) + +**Goal**: Tenant configuration selectors use cached directory groups + cached role definitions; “Sync now” triggers async sync with an immediate canonical run link; no outbound calls during render/search/label callbacks. + +**Independent Test**: Render Tenant configuration forms and exercise search/label callbacks while asserting Graph client is not called. + +### Tests (US3) + +- [x] T026 [P] [US3] Add tests that TenantResource role definition selectors render/search DB-only (no Graph calls) in `tests/Feature/Filament/` (new file: `tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php`) +- [x] T027 [P] [US3] Add tests that “Sync now” creates an OperationRun and returns a canonical view link in `tests/Feature/DirectoryGroups/` or `tests/Feature/TenantRBAC/` (choose closest existing folder) +- [x] T027a [P] [US3] Add tests that directory group selectors render/search DB-only (no Graph calls) and use cached DB tables (new file under `tests/Feature/DirectoryGroups/` or `tests/Feature/Filament/`) + +### Implementation (US3) + +- [x] T028 [US3] Create cached role definitions table + model + factory (new migration in `database/migrations/**_create_entra_role_definitions_table.php`, model in `app/Models/EntraRoleDefinition.php`, factory in `database/factories/EntraRoleDefinitionFactory.php`) +- [x] T029 [US3] Add “role definitions sync” operation type `directory_role_definitions.sync` to `app/Support/OperationRunType.php` and label in `app/Support/OperationCatalog.php` (if not already completed in T005/T006) +- [x] T030 [US3] Implement role definitions sync service + job that updates the cache and records progress/failures on the OperationRun (service in `app/Services/Directory/RoleDefinitionsSyncService.php`, job in `app/Jobs/SyncRoleDefinitionsJob.php`) +- [x] T030a [US3] Register/verify Graph contract entries required for role definitions sync in `config/graph_contracts.php` and ensure the sync uses `GraphClientInterface` only (no ad-hoc endpoints) +- [x] T031 [US3] Update `app/Filament/Resources/TenantResource.php` roleDefinitions search/label callbacks to query cached DB tables only (remove Graph calls from callbacks) +- [x] T032 [US3] Add a non-destructive “Sync now” Filament action that dispatches `directory_role_definitions.sync` and provides a canonical “View run” link (in `app/Filament/Resources/TenantResource.php`) + +**Checkpoint**: Tenant configuration selectors are DB-only; cache sync is async and observable via canonical run. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [x] T033 Ensure new/modified destructive-like actions (if any) use `Action::make(...)->action(...)->requiresConfirmation()` and are authorized server-side (audit existing touched Filament actions under `app/Filament/**`) +- [x] T034 Run Pint on changed files via `vendor/bin/sail bin pint --dirty` +- [x] T035 Run targeted test subset per quickstart: `vendor/bin/sail artisan test --compact --filter=OperationRun` and the new/changed test files +- [x] T036 Allow re-running onboarding verification while status is `in_progress` (prevents dead-end when a prior run is stuck and the current connection would immediately block with next steps) in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T037 Auto-fail stale queued provider operation runs to allow rerun (prevents permanent dedupe when a worker isn’t running) in `app/Services/Providers/ProviderOperationStartGate.php` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → Polish (Phase 6) + +### User Story Dependencies + +- US1 is the MVP: it enables stable canonical run creation + links. +- US2 depends on Foundational + US1 (viewer/auth semantics), but can be implemented in parallel once viewer auth is stable. +- US3 depends on Foundational + cache primitives, but can proceed after Foundational even if US2 is in progress. + +### Parallel Execution Examples + +- US1 parallelizable: T008 + T009 (tests) can be written in parallel; start-surface patches T010–T014 can be split across different files. +- US2 parallelizable: migrations T018–T020 can be done in parallel; legacy resource updates T024–T025 can be split by resource. +- US3 parallelizable: schema/model/factory T028 can be done while tests T026–T027 are being drafted. diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php index 8df6cd6..c2bb534 100644 --- a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -2,8 +2,9 @@ use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; +use App\Models\OperationRun; use App\Services\BackupScheduling\BackupScheduleDispatcher; +use App\Services\OperationRunService; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Bus; @@ -34,12 +35,24 @@ $dispatcher->dispatchDue([$tenant->external_id]); $dispatcher->dispatchDue([$tenant->external_id]); - expect(BackupScheduleRun::query()->count())->toBe(1); + expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'backup_schedule.scheduled') + ->count())->toBe(1); Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); + + Bus::assertDispatched(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($tenant): bool { + return $job->backupScheduleId !== null + && $job->backupScheduleRunId === 0 + && $job->operationRun?->tenant_id === $tenant->getKey() + && $job->operationRun?->type === 'backup_schedule.scheduled'; + }); }); -it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () { +it('treats an existing canonical run as already-dispatched and advances next_run_at', function () { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -59,22 +72,36 @@ 'next_run_at' => null, ]); - BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + + $operationRunService->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + identityInputs: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + ], + context: [ + 'backup_schedule_id' => (int) $schedule->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + 'trigger' => 'scheduled', + ], + ); Bus::fake(); $dispatcher = app(BackupScheduleDispatcher::class); $dispatcher->dispatchDue([$tenant->external_id]); - expect(BackupScheduleRun::query()->count())->toBe(1); + expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0); Bus::assertNotDispatched(RunBackupScheduleJob::class); + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'backup_schedule.scheduled') + ->count())->toBe(1); + $schedule->refresh(); expect($schedule->next_run_at)->not->toBeNull(); expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00'); diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php index f0b43de..cfbdbd3 100644 --- a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -9,6 +9,7 @@ use App\Services\OperationRunService; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Cache; +use Illuminate\Contracts\Queue\Job; it('creates a backup set and marks the run successful', function () { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); @@ -160,7 +161,7 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ]); }); -it('updates the operation run based on the backup schedule run id when not passed into the job', function () { +it('fails fast when operation run context is not passed into the job', function () { CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -187,50 +188,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, 'status' => BackupScheduleRun::STATUS_RUNNING, ]); - /** @var OperationRunService $operationRunService */ - $operationRunService = app(OperationRunService::class); - $operationRun = $operationRunService->ensureRun( - tenant: $tenant, - type: 'backup_schedule.run_now', - inputs: ['backup_schedule_id' => (int) $schedule->id], - initiator: $user, - ); + $queueJob = \Mockery::mock(Job::class); + $queueJob->shouldReceive('fail')->once(); - $operationRun->update([ - 'context' => array_merge($operationRun->context ?? [], [ - 'backup_schedule_run_id' => (int) $run->getKey(), - ]), - ]); + $job = new RunBackupScheduleJob($run->id); + $job->setJob($queueJob); - app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService - { - public function __construct() {} - - public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array - { - return ['synced' => [], 'failures' => []]; - } - }); - - $backupSet = BackupSet::factory()->create([ - 'tenant_id' => $tenant->id, - 'status' => 'completed', - 'item_count' => 0, - ]); - - app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService - { - public function __construct(private readonly BackupSet $backupSet) {} - - public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet - { - return $this->backupSet; - } - }); - - Cache::flush(); - - (new RunBackupScheduleJob($run->id))->handle( + $job->handle( app(PolicySyncService::class), app(BackupService::class), app(\App\Services\BackupScheduling\PolicyTypeResolver::class), @@ -239,14 +203,6 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, app(\App\Services\BackupScheduling\RunErrorMapper::class), ); - $operationRun->refresh(); - expect($operationRun->status)->toBe('completed'); - expect($operationRun->outcome)->toBe('succeeded'); - expect($operationRun->context)->toMatchArray([ - 'backup_schedule_run_id' => (int) $run->id, - 'backup_set_id' => (int) $backupSet->id, - ]); - expect($operationRun->summary_counts)->toMatchArray([ - 'created' => 1, - ]); + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_RUNNING); }); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index d5c8d5c..1f18b97 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -3,11 +3,11 @@ use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; -use App\Models\BackupScheduleRun; use App\Models\OperationRun; use App\Models\User; use App\Notifications\OperationRunQueued; use App\Services\Graph\GraphClientInterface; +use App\Services\OperationRunService; use App\Support\OperationRunLinks; use Carbon\CarbonImmutable; use Filament\Facades\Filament; @@ -49,12 +49,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('runNow', $schedule); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(1); - - $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); - expect($run)->not->toBeNull(); - expect($run->user_id)->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) @@ -62,13 +58,15 @@ ->first(); expect($operationRun)->not->toBeNull(); + expect($operationRun->user_id)->toBe($user->id); expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, - 'backup_schedule_run_id' => (int) $run->id, + 'trigger' => 'run_now', ]); - Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { - return $job->backupScheduleRunId === (int) $run->id + Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($schedule, $operationRun): bool { + return $job->backupScheduleRunId === 0 + && $job->backupScheduleId === (int) $schedule->getKey() && $job->operationRun instanceof OperationRun && $job->operationRun->is($operationRun); }); @@ -88,6 +86,49 @@ ->toBe(OperationRunLinks::view($operationRun, $tenant)); }); +test('run now is unique per click (no dedupe)', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + '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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); + + $runs = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.run_now') + ->pluck('id') + ->all(); + + expect($runs)->toHaveCount(2); + expect($runs[0])->not->toBe($runs[1]); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 2); +}); + test('operator can retry and it persists a database notification', function () { Queue::fake([RunBackupScheduleJob::class]); @@ -112,12 +153,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableAction('retry', $schedule); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) - ->toBe(1); - - $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); - expect($run)->not->toBeNull(); - expect($run->user_id)->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) @@ -125,13 +162,15 @@ ->first(); expect($operationRun)->not->toBeNull(); + expect($operationRun->user_id)->toBe($user->id); expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, - 'backup_schedule_run_id' => (int) $run->id, + 'trigger' => 'retry', ]); - Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { - return $job->backupScheduleRunId === (int) $run->id + Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($schedule, $operationRun): bool { + return $job->backupScheduleRunId === 0 + && $job->backupScheduleId === (int) $schedule->getKey() && $job->operationRun instanceof OperationRun && $job->operationRun->is($operationRun); }); @@ -150,6 +189,49 @@ ->toBe(OperationRunLinks::view($operationRun, $tenant)); }); +test('retry is unique per click (no dedupe)', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + '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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); + + $runs = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.retry') + ->pluck('id') + ->all(); + + expect($runs)->toHaveCount(2); + expect($runs[0])->not->toBe($runs[1]); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 2); +}); + test('readonly cannot dispatch run now or retry', function () { [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -183,7 +265,7 @@ // Action should be hidden/blocked for readonly users. } - expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) ->toBe(0); expect(OperationRun::query() @@ -230,11 +312,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB])); - expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) - ->toBe(2); - - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(0); expect(OperationRun::query() ->where('tenant_id', $tenant->id) @@ -242,6 +321,15 @@ ->count()) ->toBe(2); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.run_now') + ->pluck('user_id') + ->unique() + ->values() + ->all()) + ->toBe([$user->id]); + Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ @@ -293,11 +381,8 @@ Livewire::test(ListBackupSchedules::class) ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); - expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) - ->toBe(2); - - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(0); expect(OperationRun::query() ->where('tenant_id', $tenant->id) @@ -305,6 +390,15 @@ ->count()) ->toBe(2); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.retry') + ->pluck('user_id') + ->unique() + ->values() + ->all()) + ->toBe([$user->id]); + Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ @@ -319,66 +413,75 @@ ->toBe(OperationRunLinks::index($tenant)); }); -test('operator can bulk retry even if a run already exists for this minute', function () { +test('operator can bulk retry even if a previous canonical run exists', function () { Queue::fake([RunBackupScheduleJob::class]); - [$user, $tenant] = createUserWithTenant(role: 'operator'); + $frozenNow = CarbonImmutable::parse('2026-02-10 01:04:06', 'UTC'); + CarbonImmutable::setTestNow($frozenNow); - $scheduleA = BackupSchedule::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Nightly A', - '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, - ]); + try { + [$user, $tenant] = createUserWithTenant(role: 'operator'); - $scheduleB = BackupSchedule::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Nightly B', - 'is_enabled' => true, - 'timezone' => 'UTC', - 'frequency' => 'daily', - 'time_of_day' => '02:00:00', - 'days_of_week' => null, - 'policy_types' => ['deviceConfiguration'], - 'include_foundations' => true, - 'retention_keep_last' => 30, - ]); + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + '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, + ]); - $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); - BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $scheduleA->id, - 'tenant_id' => $tenant->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); - $this->actingAs($user); - Filament::setTenant($tenant, true); + /** @var OperationRunService $operationRunService */ + $operationRunService = app(OperationRunService::class); + $existing = $operationRunService->ensureRunWithIdentity( + tenant: $tenant, + type: 'backup_schedule.retry', + identityInputs: [ + 'backup_schedule_id' => (int) $scheduleA->getKey(), + 'nonce' => 'existing', + ], + context: [ + 'backup_schedule_id' => (int) $scheduleA->getKey(), + 'trigger' => 'retry', + ], + initiator: $user, + ); + $operationRunService->updateRun($existing, status: 'completed', outcome: 'succeeded'); - Livewire::test(ListBackupSchedules::class) - ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); + $this->actingAs($user); + Filament::setTenant($tenant, true); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count()) - ->toBe(2); + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); - $newRunA = BackupScheduleRun::query() - ->where('backup_schedule_id', $scheduleA->id) - ->orderByDesc('id') - ->first(); + expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(0); - expect($newRunA)->not->toBeNull(); - expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString()) - ->toBe($scheduledFor->addMinute()->toDateTimeString()); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_schedule.retry') + ->count()) + ->toBe(3); - expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count()) - ->toBe(1); - - Queue::assertPushed(RunBackupScheduleJob::class, 2); + Queue::assertPushed(RunBackupScheduleJob::class, 2); + } finally { + CarbonImmutable::setTestNow(); + } }); diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php index 74b990b..9caa638 100644 --- a/tests/Feature/BulkSyncPoliciesTest.php +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -16,6 +16,8 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); + $policies = Policy::factory() ->count(3) ->create([ diff --git a/tests/Feature/DependencyExtractionFeatureTest.php b/tests/Feature/DependencyExtractionFeatureTest.php index d1d5236..be514ea 100644 --- a/tests/Feature/DependencyExtractionFeatureTest.php +++ b/tests/Feature/DependencyExtractionFeatureTest.php @@ -53,6 +53,7 @@ public function request(string $method, string $path, array $options = []): Grap it('extracts edges during inventory sync and marks missing appropriately', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps); $svc = app(InventorySyncService::class); @@ -73,6 +74,7 @@ public function request(string $method, string $path, array $options = []): Grap it('respects 50-edge limit for outbound extraction', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); // Fake client returning 60 group assignments $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface @@ -132,6 +134,7 @@ public function request(string $method, string $path, array $options = []): Grap it('persists unsupported reference warnings on the sync run record', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface @@ -276,6 +279,7 @@ public function request(string $method, string $path, array $options = []): Grap it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () { $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $this->app->bind(GraphClientInterface::class, function () { return new class implements GraphClientInterface diff --git a/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php index c24b180..b364493 100644 --- a/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php +++ b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php @@ -97,6 +97,8 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-1', ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::factory()->create([ 'tenant_id' => $tenant->id, 'external_id' => 'dcs-1', diff --git a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php index 82141b2..80994fc 100644 --- a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php +++ b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php @@ -48,7 +48,7 @@ $this->actingAs($this->user); - $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + $response = $this->get(route('filament.tenant.resources.policy-versions.view', array_merge( filamentTenantRouteParams($this->tenant), ['record' => $version], ))); diff --git a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php index 7517372..150c6e4 100644 --- a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php +++ b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php @@ -1,7 +1,7 @@ where('tenant_id', $tenant->getKey()) + ->count(); + Artisan::call('tenantpilot:directory-groups:dispatch', [ '--tenant' => [$tenant->tenant_id], ]); $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; - $run = EntraGroupSyncRun::query() + $legacyCountAfter = \App\Models\EntraGroupSyncRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('selection_key', 'groups-v1:all') - ->where('slot_key', $slotKey) + ->count(); + + expect($legacyCountAfter)->toBe($legacyCountBefore); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'directory_groups.sync') + ->where('context->slot_key', $slotKey) ->first(); - expect($run)->not->toBeNull() - ->and($run->initiator_user_id)->toBeNull(); + expect($opRun)->not->toBeNull(); + expect($opRun?->user_id)->toBeNull(); - Queue::assertPushed(EntraGroupSyncJob::class); + Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($opRun): bool { + return (int) ($job->operationRun?->getKey() ?? 0) === (int) $opRun->getKey(); + }); CarbonImmutable::setTestNow(); }); diff --git a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php index aff8996..fd14735 100644 --- a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php +++ b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php @@ -2,7 +2,6 @@ use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups; use App\Jobs\EntraGroupSyncJob; -use App\Models\EntraGroupSyncRun; use App\Models\OperationRun; use App\Services\Graph\GraphClientInterface; use Filament\Facades\Filament; @@ -27,17 +26,18 @@ $tenant->makeCurrent(); Filament::setTenant($tenant, true); + $legacyCountBefore = \App\Models\EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->count(); + Livewire::test(ListEntraGroups::class) ->callAction('sync_groups'); - $run = EntraGroupSyncRun::query() + $legacyCountAfter = \App\Models\EntraGroupSyncRun::query() ->where('tenant_id', $tenant->getKey()) - ->latest('id') - ->first(); + ->count(); - expect($run)->not->toBeNull(); - expect($run?->status)->toBe(EntraGroupSyncRun::STATUS_PENDING); - expect($run?->selection_key)->toBe('groups-v1:all'); + expect($legacyCountAfter)->toBe($legacyCountBefore); $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) @@ -47,10 +47,12 @@ expect($opRun)->not->toBeNull(); expect($opRun?->status)->toBe('queued'); + expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all'); - Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $run, $opRun): bool { + Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool { return $job->tenantId === (int) $tenant->getKey() - && $job->runId === (int) $run?->getKey() + && $job->selectionKey === 'groups-v1:all' + && $job->runId === null && $job->operationRun instanceof OperationRun && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); }); diff --git a/tests/Feature/DirectoryGroups/StartSyncTest.php b/tests/Feature/DirectoryGroups/StartSyncTest.php index 982ea0a..ca20948 100644 --- a/tests/Feature/DirectoryGroups/StartSyncTest.php +++ b/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -1,7 +1,7 @@ startManualSync($tenant, $user); - expect($run)->toBeInstanceOf(EntraGroupSyncRun::class) + expect($run)->toBeInstanceOf(OperationRun::class) ->and($run->tenant_id)->toBe($tenant->getKey()) - ->and($run->initiator_user_id)->toBe($user->getKey()) - ->and($run->selection_key)->toBe('groups-v1:all') - ->and($run->status)->toBe(EntraGroupSyncRun::STATUS_PENDING); + ->and($run->user_id)->toBe($user->getKey()) + ->and($run->type)->toBe('directory_groups.sync') + ->and($run->status)->toBe('queued') + ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all'); Queue::assertPushed(EntraGroupSyncJob::class); }); diff --git a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php index df3f1d8..589c644 100644 --- a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php +++ b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php @@ -2,23 +2,16 @@ use App\Jobs\EntraGroupSyncJob; use App\Models\EntraGroup; -use App\Models\EntraGroupSyncRun; use App\Services\Directory\EntraGroupSyncService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; +use App\Services\OperationRunService; it('sync job upserts groups and updates run counters', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); - $run = EntraGroupSyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - EntraGroup::factory()->create([ 'tenant_id' => $tenant->getKey(), 'entra_id' => '11111111-1111-1111-1111-111111111111', @@ -57,23 +50,32 @@ app()->instance(GraphClientInterface::class, $mock); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'directory_groups.sync', + inputs: ['selection_key' => 'groups-v1:all'], + initiator: $user, + ); + $job = new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: 'groups-v1:all', slotKey: null, - runId: (int) $run->getKey(), + runId: null, + operationRun: $opRun, ); $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); - $run->refresh(); + $opRun->refresh(); - expect($run->status)->toBe(EntraGroupSyncRun::STATUS_SUCCEEDED) - ->and($run->pages_fetched)->toBe(2) - ->and($run->items_observed_count)->toBe(2) - ->and($run->items_upserted_count)->toBe(2) - ->and($run->error_count)->toBe(0) - ->and($run->finished_at)->not->toBeNull(); + expect($opRun->status)->toBe('completed') + ->and($opRun->outcome)->toBe('succeeded') + ->and($opRun->summary_counts['processed'] ?? null)->toBe(2) + ->and($opRun->summary_counts['updated'] ?? null)->toBe(2) + ->and($opRun->summary_counts['failed'] ?? null)->toBe(0); expect(EntraGroup::query()->where('tenant_id', $tenant->getKey())->count())->toBe(2); diff --git a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php index da21325..d1b243b 100644 --- a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php +++ b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php @@ -2,11 +2,11 @@ use App\Jobs\EntraGroupSyncJob; use App\Models\EntraGroup; -use App\Models\EntraGroupSyncRun; use App\Services\Directory\EntraGroupSyncService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; +use App\Services\OperationRunService; use Illuminate\Support\Facades\Config; it('purges cached groups older than the retention window', function () { @@ -24,24 +24,27 @@ 'last_seen_at' => now('UTC')->subDays(10), ]); - $run = EntraGroupSyncRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'selection_key' => 'groups-v1:all', - 'status' => EntraGroupSyncRun::STATUS_PENDING, - 'initiator_user_id' => $user->getKey(), - ]); - $mock = \Mockery::mock(GraphClientInterface::class); $mock->shouldReceive('request') ->once() ->andReturn(new GraphResponse(success: true, data: ['value' => []], status: 200)); app()->instance(GraphClientInterface::class, $mock); + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'directory_groups.sync', + inputs: ['selection_key' => 'groups-v1:all'], + initiator: $user, + ); + $job = new EntraGroupSyncJob( tenantId: (int) $tenant->getKey(), selectionKey: 'groups-v1:all', slotKey: null, - runId: (int) $run->getKey(), + runId: null, + operationRun: $opRun, ); $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); diff --git a/tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php b/tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php new file mode 100644 index 0000000..3369d3b --- /dev/null +++ b/tests/Feature/DirectoryGroups/TenantGroupSelectorsDbOnlyTest.php @@ -0,0 +1,48 @@ +create(); + + EntraGroup::factory() + ->for($tenant) + ->create([ + 'entra_id' => '33333333-3333-3333-3333-333333333333', + 'display_name' => 'TenantPilot Operators', + ]); + + $options = assertNoOutboundHttp(fn () => TenantResource::groupSearchOptions($tenant, 'Ten')); + + expect($options)->toMatchArray([ + '33333333-3333-3333-3333-333333333333' => 'TenantPilot Operators (…33333333)', + ]); +}); + +it('resolves a directory group label from cached data without Graph calls', function (): void { + bindFailHardGraphClient(); + + /** @var Tenant $tenant */ + $tenant = Tenant::factory()->create(); + + EntraGroup::factory() + ->for($tenant) + ->create([ + 'entra_id' => '44444444-4444-4444-4444-444444444444', + 'display_name' => 'TenantPilot Admins', + ]); + + $label = assertNoOutboundHttp(fn () => TenantResource::groupLabelFromCache($tenant, '44444444-4444-4444-4444-444444444444')); + + expect($label)->toBe('TenantPilot Admins (…44444444)'); +}); diff --git a/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php index c19edbc..b5e32bd 100644 --- a/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php +++ b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php @@ -58,6 +58,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/EndpointSecurityPolicyRestore023Test.php b/tests/Feature/EndpointSecurityPolicyRestore023Test.php index 1a4821e..056976d 100644 --- a/tests/Feature/EndpointSecurityPolicyRestore023Test.php +++ b/tests/Feature/EndpointSecurityPolicyRestore023Test.php @@ -82,6 +82,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, @@ -163,6 +165,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php index c6dc634..b6b2392 100644 --- a/tests/Feature/ExecuteRestoreRunJobTest.php +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; +use App\Services\OperationRunService; use App\Support\RestoreRunStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery\MockInterface; @@ -15,7 +16,7 @@ uses(RefreshDatabase::class); test('execute restore run job moves queued to running and calls the executor', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-1', 'name' => 'Tenant One', 'metadata' => [], @@ -60,7 +61,18 @@ }); }); - $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => false, + ], + initiator: null, + ); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun); $job->handle($restoreService, app(AuditLogger::class)); $restoreRun->refresh(); @@ -70,7 +82,7 @@ }); test('execute restore run job persists per-item outcomes keyed by backup_item_id', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-results', 'name' => 'Tenant Results', 'metadata' => [], @@ -116,8 +128,43 @@ 'metadata' => [], ]); - $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); - $job->handle(app(RestoreService::class), app(AuditLogger::class)); + $restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($backupItem): void { + $mock->shouldReceive('executeForRun') + ->once() + ->andReturnUsing(function (...$args) use ($backupItem): RestoreRun { + /** @var RestoreRun $run */ + $run = $args[0]; + + $run->update([ + 'status' => RestoreRunStatus::Completed->value, + 'completed_at' => now(), + 'results' => [ + 'items' => [ + (string) $backupItem->id => [ + 'backup_item_id' => $backupItem->id, + 'status' => 'skipped', + ], + ], + ], + ]); + + return $run->refresh(); + }); + }); + + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => false, + ], + initiator: null, + ); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun); + $job->handle($restoreService, app(AuditLogger::class)); $restoreRun->refresh(); diff --git a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php index 1294a72..836b8f7 100644 --- a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php +++ b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php @@ -57,6 +57,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'ca-policy-1', diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php index 64703da..4099ec3 100644 --- a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -57,6 +57,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'enrollment-restriction-1', @@ -159,6 +161,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'enrollment-limit-1', diff --git a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php index 341f1c1..6c13c71 100644 --- a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php +++ b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php @@ -2,18 +2,14 @@ use App\Filament\Resources\EntraGroupSyncRunResource; use App\Filament\Resources\EntraGroupSyncRunResource\Pages\ListEntraGroupSyncRuns; -use App\Jobs\EntraGroupSyncJob; use App\Models\EntraGroupSyncRun; use App\Models\Tenant; use App\Models\User; -use App\Notifications\RunStatusChangedNotification; -use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; -use Symfony\Component\HttpKernel\Exception\HttpException; uses(RefreshDatabase::class); @@ -69,7 +65,7 @@ ->assertForbidden(); }); -test('sync groups action enqueues job and writes database notification', function () { +test('legacy sync runs list is read-only (no sync action)', function () { Queue::fake(); [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -79,76 +75,10 @@ $tenant->makeCurrent(); Filament::setTenant($tenant, true); - Livewire::test(ListEntraGroupSyncRuns::class) - ->callAction('sync_groups'); - - Queue::assertPushed(EntraGroupSyncJob::class); - - $run = EntraGroupSyncRun::query()->where('tenant_id', $tenant->getKey())->latest('id')->first(); - expect($run)->not->toBeNull(); - - $this->assertDatabaseHas('notifications', [ - 'notifiable_id' => $user->getKey(), - 'notifiable_type' => $user->getMorphClass(), - 'type' => RunStatusChangedNotification::class, - ]); - - $notification = $user->notifications()->latest('id')->first(); - expect($notification)->not->toBeNull(); - expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); -}); - -test('sync groups action is forbidden for readonly members when disabled check is bypassed', function () { - Queue::fake(); - - [$user, $tenant] = createUserWithTenant(role: 'readonly'); - - $this->actingAs($user); - - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - $component = Livewire::test(ListEntraGroupSyncRuns::class)->instance(); $action = $component->getAction([['name' => 'sync_groups']]); - expect($action)->not->toBeNull(); - $thrown = null; - - try { - $action->callBefore(); - $action->call(); - } catch (HttpException $exception) { - $thrown = $exception; - } - - expect($thrown)->not->toBeNull(); - expect($thrown?->getStatusCode())->toBe(403); - - Queue::assertNothingPushed(); - - $runCount = EntraGroupSyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - - expect($runCount)->toBe(0); -}); - -test('sync groups action is disabled for readonly users with standard tooltip', function () { - Queue::fake(); - - [$user, $tenant] = createUserWithTenant(role: 'readonly'); - - $this->actingAs($user); - - $tenant->makeCurrent(); - Filament::setTenant($tenant, true); - - Livewire::actingAs($user) - ->test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups') - ->assertActionDisabled('sync_groups') - ->assertActionExists('sync_groups', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); + expect($action)->toBeNull(); Queue::assertNothingPushed(); }); diff --git a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php index dce8d9c..869b9f9 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php @@ -95,6 +95,8 @@ public function request(string $method, string $path, array $options = []): Grap 'status' => 'active', ]); + ensureDefaultProviderConnection($tenant); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; diff --git a/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php index c46565d..d19a3e2 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php @@ -103,6 +103,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'gpo-1', diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 0f9b627..6a3ed26 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -54,6 +54,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'status' => 'active', ]); + ensureDefaultProviderConnection($tenant); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $tenant->makeCurrent(); diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index 236a37d..6f91f4d 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -36,7 +36,7 @@ ]); $this->actingAs($user) - ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))) + ->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($tenant))) ->assertOk() ->assertSee('Policy A') ->assertDontSee('Policy B'); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php index 010e84c..b074307 100644 --- a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -44,7 +44,7 @@ [$user, $tenant] = createUserWithTenant(tenant: $tenant, user: $user, role: 'owner'); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); + ->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings&tenant='.(string) $tenant->external_id); $response->assertOk(); $response->assertSee('Settings'); diff --git a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php index 875f1dd..e0aacb8 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php @@ -71,6 +71,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'gpo-versioned-1', diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php index 915d41d..0cd73b8 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -1,7 +1,6 @@ callTableAction('restore_via_wizard', $version) - ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false, tenant: $tenant)); + ->assertRedirectContains('/admin/restore-runs/create') + ->assertRedirectContains('tenant='.(string) $tenant->external_id); $backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first(); expect($backupSet)->not->toBeNull(); diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index f8cff84..2b1c11e 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -126,7 +126,7 @@ ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings'); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id); $response->assertOk(); $response->assertSee('Enrollment notifications'); diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index af6bf77..2304e38 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -56,6 +56,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant One', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -148,6 +149,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ]); $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create(); $backupItem = BackupItem::factory() ->for($tenant) @@ -251,6 +253,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant Three', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -365,6 +368,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'status' => 'active', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -481,6 +485,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant One', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', @@ -586,6 +591,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'name' => 'Tenant Four', 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', diff --git a/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php b/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php index 5fa4058..70acfa0 100644 --- a/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php +++ b/tests/Feature/Filament/RestoreWizardGraphSafetyTest.php @@ -26,7 +26,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName bindFailHardGraphClient(); $this->actingAs($user) - ->get(RestoreRunResource::getUrl('create', tenant: $tenant)) + ->get(RestoreRunResource::getUrl('create').'?tenant='.(string) $tenant->external_id) ->assertOk() ->assertSee('Create restore run') ->assertSee('Select Backup Set'); @@ -54,7 +54,7 @@ function makeAssignment(string $odataType, string $groupId, ?string $displayName bindFailHardGraphClient(); - $url = RestoreRunResource::getUrl('create', tenant: $tenant).'?backup_set_id='.$backupSet->getKey(); + $url = RestoreRunResource::getUrl('create').'?backup_set_id='.$backupSet->getKey().'&tenant='.(string) $tenant->external_id; $this->actingAs($user) ->get($url) diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index f6db31f..5485b52 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -50,10 +50,10 @@ ], ]); - $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index', tenant: $tenant)) + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index').'?tenant='.(string) $tenant->external_id) ->assertSuccessful(); - $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings') + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id) ->assertSuccessful(); $originalEnv !== false @@ -116,9 +116,9 @@ ], ]); - $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]).'?tenant='.(string) $tenant->external_id; - $this->get($url.'?tab=diff') + $this->get($url.'&tab=diff') ->assertSuccessful() ->assertSeeText('Fullscreen') ->assertSeeText("- Write-Host 'one'") @@ -181,9 +181,9 @@ ], ]); - $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]).'?tenant='.(string) $tenant->external_id; - $this->get($url.'?tab=diff') + $this->get($url.'&tab=diff') ->assertSuccessful() ->assertSeeText('Fullscreen') ->assertSeeText("- Write-Host 'one'") diff --git a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php index e4ad978..f32ce88 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php @@ -73,6 +73,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; @@ -126,6 +128,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; diff --git a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index 67d75e6..f447cef 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -2,7 +2,10 @@ use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; +use App\Models\Workspace; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\PolicySyncService; @@ -82,6 +85,27 @@ public function request(string $method, string $path, array $options = []): Grap $tenant->makeCurrent(); expect(Tenant::current()->id)->toBe($tenant->id); + $workspace = Workspace::factory()->create(); + $tenant->forceFill(['workspace_id' => (int) $workspace->getKey()])->save(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenant->tenant_id, + 'is_default' => true, + 'status' => 'connected', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + ], + ]); + app(PolicySyncService::class)->syncPolicies($tenant); $settingsPolicy = Policy::where('policy_type', 'settingsCatalogPolicy')->first(); @@ -113,7 +137,7 @@ public function request(string $method, string $path, array $options = []): Grap $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))); + ->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($tenant))); $response->assertOk(); $response->assertSee('Settings Catalog Policy'); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php index 5037867..efa63cd 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php @@ -103,6 +103,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-3', diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index df8b4dd..371003b 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -3,6 +3,8 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\Policy; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GraphClientInterface; @@ -12,6 +14,30 @@ uses(RefreshDatabase::class); +if (! function_exists('makeTenantWithDefaultProviderConnection')) { + function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant + { + $tenant = Tenant::create(array_merge([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ], $attributes)); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->id, + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->id, + ]); + + return $tenant; + } +} + class SettingsCatalogRestoreGraphClient implements GraphClientInterface { /** @@ -105,11 +131,7 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -204,7 +226,7 @@ public function request(string $method, string $path, array $options = []): Grap ->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance'); $response = $this - ->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); + ->get(route('filament.tenant.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); $response->assertOk(); $response->assertSee('settings are read-only'); @@ -225,10 +247,9 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ + $tenant = makeTenantWithDefaultProviderConnection([ 'tenant_id' => 'tenant-2', 'name' => 'Tenant Two', - 'metadata' => [], ]); $policy = Policy::create([ @@ -346,10 +367,9 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ + $tenant = makeTenantWithDefaultProviderConnection([ 'tenant_id' => 'tenant-4', 'name' => 'Tenant Four', - 'metadata' => [], ]); $policy = Policy::create([ @@ -464,10 +484,9 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ + $tenant = makeTenantWithDefaultProviderConnection([ 'tenant_id' => 'tenant-5', 'name' => 'Tenant Five', - 'metadata' => [], ]); $policy = Policy::create([ diff --git a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php index 034ea0f..6a02b5c 100644 --- a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php +++ b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php @@ -56,8 +56,10 @@ [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + $tenant->makeCurrent(); + $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); + ->get(PolicyResource::getUrl('view', ['record' => $policy], panel: 'admin').'?tab=settings&tenant='.(string) $tenant->external_id); $policyResponse->assertOk(); $policyResponse->assertSee('fi-width-full'); @@ -68,7 +70,7 @@ $policyResponse->assertSee('fi-ta-table'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], panel: 'admin').'?tenant='.(string) $tenant->external_id); $versionResponse->assertOk(); $versionResponse->assertSee('fi-width-full'); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index 29ba4df..f5b5029 100644 --- a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -22,7 +22,7 @@ $unauthorizedTenant = Tenant::factory()->create(); $this->actingAs($user) - ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant))) + ->get(route('filament.tenant.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant))) ->assertNotFound(); }); diff --git a/tests/Feature/Filament/TenantRbacWizardTest.php b/tests/Feature/Filament/TenantRbacWizardTest.php index ce26f41..479184e 100644 --- a/tests/Feature/Filament/TenantRbacWizardTest.php +++ b/tests/Feature/Filament/TenantRbacWizardTest.php @@ -2,6 +2,8 @@ use App\Filament\Resources\TenantResource\Pages\ViewTenant; use App\Http\Controllers\RbacDelegatedAuthController; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; @@ -19,13 +21,30 @@ function tenantWithApp(): Tenant { - return Tenant::create([ + $tenant = Tenant::create([ 'tenant_id' => 'tenant-guid', 'name' => 'Tenant One', 'app_client_id' => 'client-123', 'app_client_secret' => 'secret', 'status' => 'active', ]); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-123', + 'client_secret' => 'secret', + ], + ]); + + return $tenant; } test('rbac action prompts login when no delegated token', function () { diff --git a/tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php b/tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php new file mode 100644 index 0000000..0bec2e2 --- /dev/null +++ b/tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php @@ -0,0 +1,48 @@ +create(); + + EntraRoleDefinition::factory() + ->for($tenant) + ->create([ + 'entra_id' => '11111111-1111-1111-1111-111111111111', + 'display_name' => 'Policy and Profile Manager', + ]); + + $options = assertNoOutboundHttp(fn () => TenantResource::roleSearchOptions($tenant, 'Pol')); + + expect($options)->toMatchArray([ + '11111111-1111-1111-1111-111111111111' => 'Policy and Profile Manager (11111111)', + ]); +}); + +it('resolves a role definition label from cached data without Graph calls', function (): void { + bindFailHardGraphClient(); + + /** @var Tenant $tenant */ + $tenant = Tenant::factory()->create(); + + EntraRoleDefinition::factory() + ->for($tenant) + ->create([ + 'entra_id' => '22222222-2222-2222-2222-222222222222', + 'display_name' => 'Read Only Operator', + ]); + + $label = assertNoOutboundHttp(fn () => TenantResource::roleLabelFromCache($tenant, '22222222-2222-2222-2222-222222222222')); + + expect($label)->toBe('Read Only Operator (22222222)'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php index 058c034..ed5d69e 100644 --- a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -4,6 +4,8 @@ use App\Models\BackupSet; use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GraphClientInterface; @@ -13,6 +15,30 @@ uses(RefreshDatabase::class); +if (! function_exists('makeTenantWithDefaultProviderConnection')) { + function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant + { + $tenant = Tenant::create(array_merge([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ], $attributes)); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->id, + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->id, + ]); + + return $tenant; + } +} + class WindowsUpdateProfilesRestoreGraphClient implements GraphClientInterface { /** @@ -62,11 +88,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon $client = new WindowsUpdateProfilesRestoreGraphClient; app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -141,11 +163,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon $client = new WindowsUpdateProfilesRestoreGraphClient; app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -220,11 +238,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon $client = new WindowsUpdateProfilesRestoreGraphClient; app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php index a69832f..5b75d6b 100644 --- a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -4,6 +4,8 @@ use App\Models\BackupSet; use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GraphClientInterface; @@ -13,6 +15,30 @@ uses(RefreshDatabase::class); +if (! function_exists('makeTenantWithDefaultProviderConnection')) { + function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant + { + $tenant = Tenant::create(array_merge([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ], $attributes)); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->id, + 'provider' => 'microsoft', + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->id, + ]); + + return $tenant; + } +} + test('restore execution applies windows update ring and records audit log', function () { $client = new class implements GraphClientInterface { @@ -66,11 +92,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon app()->instance(GraphClientInterface::class, $client); - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-1', - 'name' => 'Tenant One', - 'metadata' => [], - ]); + $tenant = makeTenantWithDefaultProviderConnection(); $policy = Policy::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index 841c9ab..03cc3ed 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Inventory\InventorySyncService; +use App\Services\OperationRunService; use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; @@ -30,10 +31,7 @@ Queue::assertPushed(RunInventorySyncJob::class); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect($run->user_id)->toBe($user->id); - expect($run->status)->toBe(InventorySyncRun::STATUS_PENDING); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) @@ -43,6 +41,10 @@ ->first(); expect($opRun)->not->toBeNull(); + expect($opRun->status)->toBe('queued'); + + $context = is_array($opRun->context) ? $opRun->context : []; + expect($context['selection_hash'] ?? null)->not->toBeNull(); }); it('dispatches inventory sync for selected policy types', function () { @@ -67,9 +69,17 @@ Queue::assertPushed(RunInventorySyncJob::class); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect($run->selection_payload['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect($context['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes); }); it('persists include dependencies toggle into the run selection payload', function () { @@ -92,9 +102,17 @@ ]) ->assertHasNoActionErrors(); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect((bool) ($run->selection_payload['include_dependencies'] ?? true))->toBeFalse(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect((bool) ($context['include_dependencies'] ?? true))->toBeFalse(); }); it('defaults include foundations toggle to true and persists it into the run selection payload', function () { @@ -117,9 +135,17 @@ ->callMountedAction() ->assertHasNoActionErrors(); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect((bool) ($run->selection_payload['include_foundations'] ?? false))->toBeTrue(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect((bool) ($context['include_foundations'] ?? false))->toBeTrue(); }); it('persists include foundations toggle into the run selection payload', function () { @@ -142,9 +168,17 @@ ]) ->assertHasNoActionErrors(); - $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); - expect($run)->not->toBeNull(); - expect((bool) ($run->selection_payload['include_foundations'] ?? true))->toBeFalse(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'inventory.sync') + ->latest('id') + ->first(); + + expect($opRun)->not->toBeNull(); + $context = is_array($opRun->context) ? $opRun->context : []; + expect((bool) ($context['include_foundations'] ?? true))->toBeFalse(); }); it('rejects cross-tenant initiation attempts (403) with no side effects', function () { @@ -180,27 +214,29 @@ $selectionPayload = $sync->defaultSelectionPayload(); $computed = $sync->normalizeAndHashSelection($selectionPayload); - InventorySyncRun::query()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => $user->getKey(), - 'selection_hash' => $computed['selection_hash'], - 'selection_payload' => $computed['selection'], - 'status' => InventorySyncRun::STATUS_RUNNING, - 'had_errors' => false, - 'error_codes' => [], - 'error_context' => null, + $opService = app(OperationRunService::class); + $existing = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'inventory.sync', + identityInputs: [ + 'selection_hash' => $computed['selection_hash'], + ], + context: array_merge($computed['selection'], [ + 'selection_hash' => $computed['selection_hash'], + ]), + initiator: $user, + ); + + $existing->forceFill([ + 'status' => 'running', 'started_at' => now(), - 'finished_at' => null, - 'items_observed_count' => 0, - 'items_upserted_count' => 0, - 'errors_count' => 0, - ]); + ])->save(); Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]); Queue::assertNothingPushed(); - expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(0); expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1); }); diff --git a/tests/Feature/Inventory/InventorySyncServiceTest.php b/tests/Feature/Inventory/InventorySyncServiceTest.php index 2f89312..4eb621b 100644 --- a/tests/Feature/Inventory/InventorySyncServiceTest.php +++ b/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -5,12 +5,17 @@ use App\Models\BackupScheduleRun; use App\Models\BackupSet; use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\ProviderCredential; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Inventory\InventoryMetaSanitizer; use App\Services\Inventory\InventoryMissingService; use App\Services\Inventory\InventorySyncService; +use App\Services\OperationRunService; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; @@ -66,6 +71,84 @@ public function request(string $method, string $path, array $options = []): Grap }; } +/** + * Executes an inventory sync against a canonical OperationRun. + * + * @param array $selection + * @return array{opRun: \App\Models\OperationRun, result: array, selection: array, selection_hash: string} + */ +function executeInventorySyncNow(Tenant $tenant, array $selection): array +{ + $service = app(InventorySyncService::class); + $opService = app(OperationRunService::class); + + $defaultConnection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->where('is_default', true) + ->first(); + + if (! $defaultConnection instanceof ProviderConnection) { + $defaultConnection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenant->tenant_id, + 'is_default' => true, + 'status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $defaultConnection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + ], + ]); + } + + $computed = $service->normalizeAndHashSelection($selection); + $context = array_merge($computed['selection'], [ + 'selection_hash' => $computed['selection_hash'], + ]); + + $opRun = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'inventory.sync', + identityInputs: [ + 'selection_hash' => $computed['selection_hash'], + ], + context: $context, + initiator: null, + ); + + $result = $service->executeSelection($opRun, $tenant, $context); + $status = (string) ($result['status'] ?? 'failed'); + + $outcome = match ($status) { + 'success' => OperationRunOutcome::Succeeded->value, + 'partial' => OperationRunOutcome::PartiallySucceeded->value, + default => OperationRunOutcome::Failed->value, + }; + + $opService->updateRun( + $opRun, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: [ + 'total' => count($computed['selection']['policy_types'] ?? []), + ], + ); + + return [ + 'opRun' => $opRun->refresh(), + 'result' => $result, + 'selection' => $computed['selection'], + 'selection_hash' => $computed['selection_hash'], + ]; +} + test('inventory sync upserts and updates last_seen fields without duplicates', function () { $tenant = Tenant::factory()->create(); @@ -75,8 +158,6 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - $selection = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], @@ -84,21 +165,21 @@ public function request(string $method, string $path, array $options = []): Grap 'include_dependencies' => false, ]; - $runA = $service->syncNow($tenant, $selection); - expect($runA->status)->toBe('success'); + $runA = executeInventorySyncNow($tenant, $selection); + expect($runA['result']['status'] ?? null)->toBe('success'); $item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first(); expect($item)->not->toBeNull(); expect($item->external_id)->toBe('cfg-1'); - expect($item->last_seen_run_id)->toBe($runA->id); + expect($item->last_seen_operation_run_id)->toBe($runA['opRun']->id); - $runB = $service->syncNow($tenant, $selection); + $runB = executeInventorySyncNow($tenant, $selection); $items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get(); expect($items)->toHaveCount(1); $items->first()->refresh(); - expect($items->first()->last_seen_run_id)->toBe($runB->id); + expect($items->first()->last_seen_operation_run_id)->toBe($runB['opRun']->id); }); test('inventory sync includes foundation types when include_foundations is true', function () { @@ -117,16 +198,14 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); - expect($run->status)->toBe('success'); + expect($run['result']['status'] ?? null)->toBe('success'); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -159,16 +238,14 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['roleScopeTag'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => false, ]); - expect($run->status)->toBe('success'); + expect($run['result']['status'] ?? null)->toBe('success'); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -192,16 +269,14 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); - expect($run->status)->toBe('success'); + expect($run['result']['status'] ?? null)->toBe('success'); $foundationItem = \App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -247,29 +322,27 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - - $runA = $service->syncNow($tenantA, [ + $runA = executeInventorySyncNow($tenantA, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => true, 'include_dependencies' => false, ]); - expect($runA->status)->toBe('success'); - expect($runA->items_observed_count)->toBe(4); - expect($runA->items_upserted_count)->toBe(4); + expect($runA['result']['status'] ?? null)->toBe('success'); + expect((int) ($runA['result']['items_observed_count'] ?? 0))->toBe(4); + expect((int) ($runA['result']['items_upserted_count'] ?? 0))->toBe(4); - $runB = $service->syncNow($tenantB, [ + $runB = executeInventorySyncNow($tenantB, [ 'policy_types' => ['deviceConfiguration'], 'categories' => [], 'include_foundations' => false, 'include_dependencies' => false, ]); - expect($runB->status)->toBe('success'); - expect($runB->items_observed_count)->toBe(1); - expect($runB->items_upserted_count)->toBe(1); + expect($runB['result']['status'] ?? null)->toBe('success'); + expect((int) ($runB['result']['items_observed_count'] ?? 0))->toBe(1); + expect((int) ($runB['result']['items_upserted_count'] ?? 0))->toBe(1); }); test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () { @@ -306,7 +379,7 @@ public function request(string $method, string $path, array $options = []): Grap 'include_dependencies' => false, ]; - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); expect(\App\Models\InventoryItem::query() ->where('tenant_id', $tenant->id) @@ -375,13 +448,13 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); app()->instance(GraphClientInterface::class, fakeGraphClient([ 'deviceConfiguration' => [], ])); - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); $missingService = app(InventoryMissingService::class); $result = $missingService->missingForSelection($tenant, $selection); @@ -393,7 +466,7 @@ public function request(string $method, string $path, array $options = []): Grap 'deviceConfiguration' => [], ], failedTypes: ['deviceConfiguration'])); - app(InventorySyncService::class)->syncNow($tenant, $selection); + executeInventorySyncNow($tenant, $selection); $result2 = $missingService->missingForSelection($tenant, $selection); expect($result2['missing'])->toHaveCount(1); @@ -426,10 +499,8 @@ public function request(string $method, string $path, array $options = []): Grap ], ])); - $service = app(InventorySyncService::class); - $service->syncNow($tenant, $selectionX); - - $service->syncNow($tenant, $selectionY); + executeInventorySyncNow($tenant, $selectionX); + executeInventorySyncNow($tenant, $selectionY); $missingService = app(InventoryMissingService::class); $resultX = $missingService->missingForSelection($tenant, $selectionX); @@ -444,8 +515,6 @@ public function request(string $method, string $path, array $options = []): Grap 'deviceConfiguration' => [], ])); - $service = app(InventorySyncService::class); - $selection = [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], @@ -457,10 +526,11 @@ public function request(string $method, string $path, array $options = []): Grap $lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900); expect($lock->get())->toBeTrue(); - $run = $service->syncNow($tenant, $selection); + $run = executeInventorySyncNow($tenant, $selection); - expect($run->status)->toBe('skipped'); - expect($run->error_codes)->toContain('lock_contended'); + expect($run['result']['status'] ?? null)->toBe('skipped'); + $codes = is_array($run['result']['error_codes'] ?? null) ? $run['result']['error_codes'] : []; + expect($codes)->toContain('lock_contended'); $lock->release(); }); @@ -480,9 +550,7 @@ public function request(string $method, string $path, array $options = []): Grap 'deviceConfiguration' => [], ])); - $service = app(InventorySyncService::class); - - $service->syncNow($tenant, [ + executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, @@ -503,18 +571,16 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable)); - $service = app(InventorySyncService::class); - - $run = $service->syncNow($tenant, [ + $run = executeInventorySyncNow($tenant, [ 'policy_types' => ['deviceConfiguration'], 'categories' => ['Configuration'], 'include_foundations' => false, 'include_dependencies' => false, ]); - expect($run->status)->toBe('failed'); + expect($run['result']['status'] ?? null)->toBe('failed'); - $context = is_array($run->error_context) ? $run->error_context : []; + $context = is_array($run['result']['error_context'] ?? null) ? $run['result']['error_context'] : []; $message = (string) ($context['message'] ?? ''); expect($message)->not->toContain('abc.def.ghi'); diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php index d014d24..a117992 100644 --- a/tests/Feature/Inventory/RunInventorySyncJobTest.php +++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php @@ -1,10 +1,8 @@ mock(GraphClientInterface::class, function (MockInterface $mock) { - $mock->shouldReceive('listPolicies') - ->atLeast() - ->once() - ->andReturn(new GraphResponse(true, [], 200)); + $mock->shouldReceive('listPolicies')->never(); }); $sync = app(InventorySyncService::class); $selectionPayload = $sync->defaultSelectionPayload(); $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; - $run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']); + + $mockSync = \Mockery::mock(InventorySyncService::class); + $mockSync + ->shouldReceive('executeSelection') + ->once() + ->andReturn([ + 'status' => 'success', + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => [], + 'errors_count' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'skipped_policy_types' => [], + 'processed_policy_types' => $computed['selection']['policy_types'], + 'failed_policy_types' => [], + 'selection_hash' => $computed['selection_hash'], + ]); /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -38,20 +50,13 @@ $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->getKey(), operationRun: $opRun, ); - $job->handle($sync, app(AuditLogger::class), $opService); + $job->handle($mockSync, app(AuditLogger::class), $opService); - $run->refresh(); $opRun->refresh(); - expect($run->user_id)->toBe($user->id); - expect($run->status)->toBe(InventorySyncRun::STATUS_SUCCESS); - expect($run->started_at)->not->toBeNull(); - expect($run->finished_at)->not->toBeNull(); - expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('succeeded'); @@ -75,13 +80,10 @@ $sync = app(InventorySyncService::class); $selectionPayload = $sync->defaultSelectionPayload(); - $run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload); $computed = $sync->normalizeAndHashSelection($selectionPayload); $policyTypes = $computed['selection']['policy_types']; - $run->update(['selection_payload' => $computed['selection']]); - /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( @@ -93,34 +95,32 @@ $mockSync = \Mockery::mock(InventorySyncService::class); $mockSync - ->shouldReceive('executePendingRun') + ->shouldReceive('executeSelection') ->once() - ->andReturnUsing(function (InventorySyncRun $inventorySyncRun) { - $inventorySyncRun->forceFill([ - 'status' => InventorySyncRun::STATUS_SKIPPED, - 'error_codes' => ['locked'], - 'selection_payload' => $inventorySyncRun->selection_payload ?? [], - 'started_at' => now(), - 'finished_at' => now(), - ])->save(); - - return $inventorySyncRun; - }); + ->andReturn([ + 'status' => 'skipped', + 'had_errors' => true, + 'error_codes' => ['locked'], + 'error_context' => [], + 'errors_count' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'skipped_policy_types' => $computed['selection']['policy_types'], + 'processed_policy_types' => [], + 'failed_policy_types' => [], + 'selection_hash' => $computed['selection_hash'], + ]); $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->getKey(), operationRun: $opRun, ); $job->handle($mockSync, app(AuditLogger::class), $opService); - $run->refresh(); $opRun->refresh(); - expect($run->status)->toBe(InventorySyncRun::STATUS_SKIPPED); - expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('failed'); diff --git a/tests/Feature/InventoryItemDependenciesTest.php b/tests/Feature/InventoryItemDependenciesTest.php index 8ba5d22..e010de2 100644 --- a/tests/Feature/InventoryItemDependenciesTest.php +++ b/tests/Feature/InventoryItemDependenciesTest.php @@ -18,7 +18,7 @@ ]); // Zero state - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url)->assertOk()->assertSee('No dependencies found'); // Create a missing edge and assert badge appears @@ -71,10 +71,10 @@ 'relationship_type' => 'depends_on', ]); - $urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=outbound'; + $urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; $this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found'); - $urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound'; + $urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound'; $this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found'); }); @@ -109,8 +109,8 @@ 'metadata' => ['last_known_name' => 'Scoped Target'], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant) - .'?direction=outbound&relationship_type=scoped_by'; + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin') + .'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by'; $this->get($url) ->assertOk() @@ -141,7 +141,7 @@ 'metadata' => ['last_known_name' => 'Other Tenant Edge'], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertDontSee('Other Tenant Edge'); @@ -170,7 +170,7 @@ ], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertSee('Group (external): 123456…'); @@ -239,7 +239,7 @@ ], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertSee('Scope Tag: Finance (6…)') @@ -286,7 +286,7 @@ ], ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url) ->assertOk() ->assertSee('Scope Tag: Finance'); @@ -301,6 +301,6 @@ 'external_id' => (string) Str::uuid(), ]); - $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $this->get($url)->assertRedirect(); }); diff --git a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php index fb3dcb1..974302f 100644 --- a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php +++ b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php @@ -55,6 +55,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'config-1', diff --git a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php index 4ae8c1f..b21c9c7 100644 --- a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php +++ b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php @@ -55,6 +55,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + // Create an ignored policy $policy = Policy::create([ 'tenant_id' => $tenant->id, @@ -100,6 +102,8 @@ public function request(string $method, string $path, array $options = []): Grap 'is_current' => true, ]); + ensureDefaultProviderConnection($tenant); + // Create multiple ignored policies Policy::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/ManagedTenantOnboardingWizardTest.php b/tests/Feature/ManagedTenantOnboardingWizardTest.php index 2d595c5..b5ee7f7 100644 --- a/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -746,6 +746,74 @@ Bus::assertNotDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class); }); +it('fails a stale queued verification run and allows starting a new verification run', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = '99999999-9999-9999-9999-999999999999'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $staleRun = OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => sha1('stale-queued-verify-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + $component->set('selectedProviderConnectionId', (int) $connection->getKey()); + $component->call('startVerification'); + + $staleRun->refresh(); + expect($staleRun->status)->toBe('completed'); + expect($staleRun->outcome)->toBe('failed'); + expect($staleRun->context)->toBeArray(); + expect($staleRun->context['verification_report'] ?? null)->toBeArray(); + + $report = $staleRun->context['verification_report'] ?? null; + expect($report['checks'] ?? null)->toBeArray(); + expect($report['checks'][0]['message'] ?? null)->toBe('Run was queued but never started. A queue worker may not be running.'); + + $newRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->firstOrFail(); + + expect((int) $newRun->getKey())->not->toBe((int) $staleRun->getKey()); + + Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class); +}); + it('registers the onboarding capability in the canonical registry', function (): void { expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue(); }); diff --git a/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php b/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php index 0e4fd5d..e41e5d4 100644 --- a/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php +++ b/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Resources\TenantResource; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; @@ -12,7 +13,7 @@ [$user] = createUserWithTenant($tenant, role: 'readonly'); $this->actingAs($user) - ->get("/admin/t/{$tenant->external_id}/tenants/{$tenant->id}/edit") + ->get(TenantResource::getUrl('edit', ['record' => $tenant])) ->assertForbidden(); }); diff --git a/tests/Feature/Monitoring/HeaderContextBarTest.php b/tests/Feature/Monitoring/HeaderContextBarTest.php index 30c4d9e..99c1624 100644 --- a/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -25,7 +25,6 @@ ->get('/admin/operations') ->assertOk() ->assertSee($workspaceName ?? 'Select workspace') - ->assertSee('Select tenant') ->assertSee('Search tenants…') ->assertSee('Switch workspace') ->assertSee('admin/select-tenant') diff --git a/tests/Feature/Monitoring/MonitoringOperationsTest.php b/tests/Feature/Monitoring/MonitoringOperationsTest.php new file mode 100644 index 0000000..4162fd8 --- /dev/null +++ b/tests/Feature/Monitoring/MonitoringOperationsTest.php @@ -0,0 +1,50 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + Bus::fake(); + + Filament::setTenant(null, true); + + assertNoOutboundHttp(function () use ($tenant, $run) { + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk(); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk(); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/alerts') + ->assertOk(); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/audit-log') + ->assertOk(); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php index 894c25a..7435970 100644 --- a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php +++ b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php @@ -118,12 +118,21 @@ $component = Livewire::actingAs($user) ->test(Operations::class) - ->assertCanSeeTableRecords([$runA]) - ->assertCanNotSeeTableRecords([$runB]); + ->assertSee('TenantA') + ->assertDontSee('TenantB') + ->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey()); $component - ->filterTable('tenant_id', null) - ->assertCanSeeTableRecords([$runA, $runB]); + ->callAction('operate_hub_show_all_tenants') + ->assertSet('tableFilters.tenant_id.value', null) + ->assertRedirect('/admin/operations'); + + Filament::setTenant(null, true); + + Livewire::actingAs($user) + ->test(Operations::class) + ->assertSee('TenantA') + ->assertSee('TenantB'); }); it('does not register legacy operation resource routes', function (): void { diff --git a/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/tests/Feature/Monitoring/OperationsTenantScopeTest.php index e687b20..0c030a5 100644 --- a/tests/Feature/Monitoring/OperationsTenantScopeTest.php +++ b/tests/Feature/Monitoring/OperationsTenantScopeTest.php @@ -5,8 +5,13 @@ use App\Models\Tenant; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; +use Illuminate\Support\Facades\Http; use Livewire\Livewire; +beforeEach(function (): void { + Http::preventStrayRequests(); +}); + it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); @@ -47,6 +52,49 @@ ->assertDontSee('TenantB'); }); +it('defaults Monitoring → Operations list to the remembered tenant when Filament tenant is not available', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + $tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save(); + + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'owner'], + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'TenantA', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'TenantB', + ]); + + Filament::setTenant(null, true); + + $workspaceId = (int) $tenantA->workspace_id; + app(WorkspaceContext::class)->rememberLastTenantId($workspaceId, (int) $tenantA->getKey()); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Tenant — '.$tenantA->name) + ->assertSee('Policy sync') + ->assertSee('TenantA') + ->assertDontSee('Inventory sync'); +}); + it('scopes Monitoring → Operations tabs to the active tenant', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php index 8cc8b05..e5a5f84 100644 --- a/tests/Feature/OperationRunServiceTest.php +++ b/tests/Feature/OperationRunServiceTest.php @@ -127,6 +127,78 @@ expect(OperationRun::query()->count())->toBe(2); }); +it('reuses the same run even after completion when using strict identity', function () { + $tenant = Tenant::factory()->create(); + + $service = new OperationRunService; + + $runA = $service->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], + context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], + ); + + $runA->update(['status' => 'completed', 'outcome' => 'succeeded']); + + $runB = $service->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], + context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], + ); + + expect($runA->getKey())->toBe($runB->getKey()); + expect(OperationRun::query()->count())->toBe(1); +}); + +it('handles strict unique-index race collisions by returning the existing run', function () { + $tenant = Tenant::factory()->create(); + $service = new OperationRunService; + + $fired = false; + + $dispatcher = OperationRun::getEventDispatcher(); + + OperationRun::creating(function (OperationRun $model) use (&$fired, $tenant): void { + if ($fired) { + return; + } + + $fired = true; + + OperationRun::withoutEvents(function () use ($model, $tenant): void { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => $model->tenant_id, + 'user_id' => $model->user_id, + 'initiator_name' => $model->initiator_name, + 'type' => $model->type, + 'status' => $model->status, + 'outcome' => $model->outcome, + 'run_identity_hash' => $model->run_identity_hash, + 'context' => $model->context, + ]); + }); + }); + + try { + $run = $service->ensureRunWithIdentityStrict( + tenant: $tenant, + type: 'backup_schedule.scheduled', + identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], + context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'], + ); + } finally { + OperationRun::flushEventListeners(); + OperationRun::setEventDispatcher($dispatcher); + } + + expect($run)->toBeInstanceOf(OperationRun::class); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'backup_schedule.scheduled')->count()) + ->toBe(1); +}); + it('updates run lifecycle fields and summaries', function () { $tenant = Tenant::factory()->create(); diff --git a/tests/Feature/Operations/LegacyRunRedirectTest.php b/tests/Feature/Operations/LegacyRunRedirectTest.php new file mode 100644 index 0000000..ff8a6af --- /dev/null +++ b/tests/Feature/Operations/LegacyRunRedirectTest.php @@ -0,0 +1,88 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $legacyRun = InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'operation_run_id' => (int) $opRun->getKey(), + ]); + + $this->actingAs($user) + ->get(InventorySyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) + ->assertRedirect(OperationRunLinks::tenantlessView($opRun->getKey())); +}); + +it('does not redirect legacy inventory sync run view when not mapped', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $legacyRun = InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'operation_run_id' => null, + ]); + + $this->actingAs($user) + ->get(InventorySyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) + ->assertOk(); +}); + +it('redirects legacy directory group sync run view to canonical OperationRun when mapped', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $opRun = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'directory_groups.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $legacyRun = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + 'operation_run_id' => (int) $opRun->getKey(), + ]); + + $this->actingAs($user) + ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) + ->assertRedirect(OperationRunLinks::tenantlessView($opRun->getKey())); +}); + +it('does not redirect legacy directory group sync run view when not mapped', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $legacyRun = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + 'operation_run_id' => null, + ]); + + $this->actingAs($user) + ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant)) + ->assertOk(); +}); diff --git a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php index 6ad7063..95274f1 100644 --- a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +++ b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php @@ -3,12 +3,19 @@ declare(strict_types=1); use App\Models\OperationRun; +use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\TenantRole; use App\Support\Workspaces\WorkspaceContext; +use Illuminate\Support\Facades\Http; + +beforeEach(function (): void { + Http::preventStrayRequests(); +}); it('allows viewing an operation run without a selected workspace when the user is a member of the run workspace', function (): void { $workspace = Workspace::factory()->create(); @@ -56,6 +63,42 @@ ->assertNotFound(); }); +it('returns 403 for members missing the required capability for the operation type', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $tenant->users()->attach((int) $user->getKey(), [ + 'role' => TenantRole::Readonly->value, + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + session()->forget(WorkspaceContext::SESSION_KEY); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => 'inventory.sync', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + $this->actingAs($user) + ->get("/admin/operations/{$run->getKey()}") + ->assertForbidden(); +}); + it('renders stored target scope and failure details for a completed run', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); diff --git a/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php b/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php index 65d5c77..f949c29 100644 --- a/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php +++ b/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Models\OperationRun; +use App\Support\OperationRunLinks; use Illuminate\Support\Facades\File; it('routes all OperationRun view links through OperationRunLinks', function (): void { @@ -30,3 +32,12 @@ expect($violations)->toBeEmpty(); })->group('ops-ux'); + +it('resolves tenantless operation run links to the canonical admin.operations.view route', function (): void { + $run = OperationRun::factory()->create(); + + $expectedUrl = route('admin.operations.view', ['run' => (int) $run->getKey()]); + + expect(OperationRunLinks::tenantlessView($run))->toBe($expectedUrl); + expect(OperationRunLinks::tenantlessView((int) $run->getKey()))->toBe($expectedUrl); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/FailureSanitizationTest.php b/tests/Feature/OpsUx/FailureSanitizationTest.php index 010450d..cc6afce 100644 --- a/tests/Feature/OpsUx/FailureSanitizationTest.php +++ b/tests/Feature/OpsUx/FailureSanitizationTest.php @@ -38,7 +38,7 @@ expect($failureSummaryJson)->not->toContain($rawBearer); expect($failureSummaryJson)->not->toContain('test.user@example.com'); - expect($run->failure_summary[0]['reason_code'] ?? null)->toBe('permission_denied'); + expect($run->failure_summary[0]['reason_code'] ?? null)->toBe('provider_permission_denied'); $notification = DatabaseNotification::query() ->where('notifiable_id', $user->getKey()) diff --git a/tests/Feature/OpsUx/OperateHubShellTest.php b/tests/Feature/OpsUx/OperateHubShellTest.php new file mode 100644 index 0000000..229cb67 --- /dev/null +++ b/tests/Feature/OpsUx/OperateHubShellTest.php @@ -0,0 +1,264 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'blocked', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + Bus::fake(); + Filament::setTenant(null, true); + + $session = [ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]; + + assertNoOutboundHttp(function () use ($run, $session): void { + $this->withSession($session) + ->get(route('admin.operations.index')) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + $this->withSession($session) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + $this->withSession($session) + ->get(route('admin.monitoring.alerts')) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + $this->withSession($session) + ->get(route('admin.monitoring.audit-log')) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + }); + + Bus::assertNothingDispatched(); +})->group('ops-ux'); + +it('shows back to tenant on run detail when tenant context is active and entitled', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ])->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + + $response + ->assertOk() + ->assertSee('← Back to '.$tenant->name) + ->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false) + ->assertSee('Show all operations') + ->assertDontSee('Back to Operations'); + + expect(substr_count((string) $response->getContent(), '← Back to '.$tenant->name))->toBe(1); +})->group('ops-ux'); + +it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant(null, true); + + $workspaceId = (int) $tenant->workspace_id; + $lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()]; + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap, + ])->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + + $response + ->assertOk() + ->assertSee('← Back to '.$tenant->name) + ->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false) + ->assertSee('Show all operations') + ->assertDontSee('Back to Operations'); +})->group('ops-ux'); + +it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void { + [$user, $entitledTenant] = createUserWithTenant(role: 'owner'); + + $nonEntitledTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $entitledTenant->getKey(), + 'workspace_id' => (int) $entitledTenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant($nonEntitledTenant, true); + + $workspaceId = (int) $entitledTenant->workspace_id; + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [(string) $workspaceId => (int) $nonEntitledTenant->getKey()], + ])->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + + $response + ->assertOk() + ->assertSee('Back to Operations') + ->assertDontSee('← Back to '.$nonEntitledTenant->name) + ->assertDontSee('Show all operations'); +})->group('ops-ux'); + +it('returns 404 for non-member workspace access to /admin/operations', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.operations.index')) + ->assertNotFound(); +})->group('ops-ux'); + +it('returns 404 for non-entitled tenant dashboard direct access', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); +})->group('ops-ux'); + +it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(RestoreRunResource::getUrl('create', tenant: $tenant)) + ->assertForbidden(); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->exists())->toBeFalse(); +})->group('ops-ux'); + +it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $workspaceId = (int) $tenant->workspace_id; + $lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()]; + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap, + ])->get(route('admin.operations.index')); + + $response->assertOk(); + $response->assertSessionHas(WorkspaceContext::SESSION_KEY, $workspaceId); + $response->assertSessionHas(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $lastTenantMap); +})->group('ops-ux'); + +it('shows tenant scope label when tenant context is active', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $this->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ])->get(route('admin.operations.index')) + ->assertOk() + ->assertSee('Scope: Tenant — '.$tenant->name) + ->assertDontSee('Scope: Workspace — all tenants'); +})->group('ops-ux'); + +it('does not create audit entries when viewing operate hub pages', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant(null, true); + + $before = (int) AuditLog::query()->count(); + + $session = [ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]; + + $this->withSession($session) + ->get(route('admin.operations.index')) + ->assertOk(); + + $this->withSession($session) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk(); + + $this->withSession($session) + ->get(route('admin.monitoring.alerts')) + ->assertOk(); + + $this->withSession($session) + ->get(route('admin.monitoring.audit-log')) + ->assertOk(); + + expect((int) AuditLog::query()->count())->toBe($before); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php b/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php index 795a1ca..7e400ce 100644 --- a/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php +++ b/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php @@ -48,6 +48,8 @@ expect($opRun?->status)->toBe('completed'); expect($opRun?->outcome)->toBe('succeeded'); + expect((int) ($restoreRun->refresh()->operation_run_id ?? 0))->toBe((int) ($opRun?->getKey() ?? 0)); + expect($opRun?->summary_counts['total'] ?? null)->toBe(10); expect($opRun?->summary_counts['succeeded'] ?? null)->toBe(8); expect($opRun?->summary_counts['failed'] ?? null)->toBe(1); diff --git a/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php index 06ea62e..44f87aa 100644 --- a/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php +++ b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php @@ -7,6 +7,7 @@ use App\Models\RestoreRun; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; +use App\Services\OperationRunService; it('syncs restore execution into OperationRun even if restore status updates bypass model events', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -24,12 +25,17 @@ 'completed_at' => null, ]); - // Observer should create the adapter OperationRun row on create. - $operationRun = OperationRun::query() - ->where('tenant_id', $tenant->id) - ->where('type', 'restore.execute') - ->where('context->restore_run_id', $restoreRun->id) - ->first(); + // Canonical OperationRun must exist at dispatch time and be passed into the job. + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), + ], + initiator: $user, + ); expect($operationRun)->not->toBeNull(); expect($operationRun?->status)->toBe('queued'); @@ -48,7 +54,7 @@ }); }); - $job = new ExecuteRestoreRunJob($restoreRun->id); + $job = new ExecuteRestoreRunJob($restoreRun->id, null, null, $operationRun); $job->handle( app(RestoreService::class), app(AuditLogger::class), diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php index a21e3b3..ab5ca32 100644 --- a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -19,6 +19,7 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); // Simulate an older bug: ESP row was synced under enrollmentRestriction. $wrong = Policy::create([ @@ -81,6 +82,7 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); $this->mock(GraphClientInterface::class, function (MockInterface $mock) { $payload = [ @@ -172,6 +174,7 @@ ]); $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant); $this->mock(GraphClientInterface::class, function (MockInterface $mock) { $limitPayload = [ diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index 0e248d2..e34739c 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -212,8 +212,8 @@ $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( filamentTenantRouteParams($this->tenant), - ['record' => $version], - )).'?tab=normalized-settings'); + ['record' => $version, 'tab' => 'normalized-settings'], + ))); $response->assertOk(); $response->assertSee('Password & Access'); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php index ac274e9..899835c 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php @@ -7,6 +7,8 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\OperationRunService; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; it('updates connection health and marks the run succeeded on success', function (): void { app()->instance(GraphClientInterface::class, new class implements GraphClientInterface @@ -109,6 +111,102 @@ public function request(string $method, string $path, array $options = []): Grap ]); }); +it('finalizes the verification run as blocked when admin consent is missing', function (): void { + app()->instance(GraphClientInterface::class, new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getOrganization(array $options = []): GraphResponse + { + throw new RuntimeException('provider_consent_missing'); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true); + } + }); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => $connection->getKey(), + 'payload' => [ + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ], + ]); + + $job = new ProviderConnectionHealthCheckJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + operationRun: $run, + ); + + $job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class)); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Completed->value); + expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value); + + $context = is_array($run->context ?? null) ? $run->context : []; + expect($context['reason_code'] ?? null)->toBe('provider_consent_missing'); + + $nextSteps = $context['next_steps'] ?? null; + expect($nextSteps)->toBeArray(); + expect($nextSteps)->not->toBeEmpty(); + + $first = $nextSteps[0] ?? null; + expect($first)->toBeArray(); + expect($first['label'] ?? null)->toBe('Grant admin consent'); + expect($first['url'] ?? null)->toBeString()->not->toBeEmpty(); +}); + it('uses provider connection credentials when refreshing observed permissions', function (): void { $graph = new class implements GraphClientInterface { diff --git a/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php b/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php index a97a882..7a236d3 100644 --- a/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php +++ b/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php @@ -4,10 +4,12 @@ use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\ProviderConnection; use App\Models\ProviderCredential; use App\Models\Tenant; +use App\Models\Workspace; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Graph\ScopeTagResolver; @@ -16,6 +18,7 @@ use App\Services\Intune\RbacHealthService; use App\Services\Intune\RestoreService; use App\Services\Inventory\InventorySyncService; +use App\Support\OperationRunType; use App\Support\RbacReason; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -26,11 +29,14 @@ */ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ 'tenant_id' => $tenantId, 'status' => 'active', 'app_client_id' => null, 'app_client_secret' => null, + 'workspace_id' => (int) $workspace->getKey(), ]); $connection = ProviderConnection::factory()->create([ @@ -79,17 +85,23 @@ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array app()->instance(GraphClientInterface::class, $graph); - $run = app(InventorySyncService::class)->syncNow( - $setup['tenant'], - [ - 'policy_types' => ['deviceConfiguration'], - 'categories' => ['Configuration'], - 'include_foundations' => false, - 'include_dependencies' => false, - ], - ); + $service = app(InventorySyncService::class); + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; - expect($run->status)->toBe('success'); + $opRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $setup['tenant']->getKey(), + 'workspace_id' => (int) $setup['tenant']->workspace_id, + 'type' => OperationRunType::InventorySync->value, + ]); + + $result = $service->executeSelection($opRun, $setup['tenant'], $selection); + + expect($result['status'])->toBe('success'); }); it('Spec081 smoke: policy sync uses provider connection credentials with tenant secrets empty', function (): void { diff --git a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php index 94d7c7d..e66cb96 100644 --- a/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php +++ b/tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php @@ -1,7 +1,6 @@ makeCurrent(); Filament::setTenant($tenant, true); - $component = Livewire::test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups'); + $component = Livewire::test(ListEntraGroupSyncRuns::class); $user->tenants()->detach($tenant->getKey()); app(\App\Services\Auth\CapabilityResolver::class)->clearCache(); - $component->assertActionHidden('sync_groups'); + expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); Queue::assertNothingPushed(); }); - it('shows sync action as visible but disabled for readonly members', function () { + it('does not expose a sync action for readonly members', function () { [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user); $tenant->makeCurrent(); Filament::setTenant($tenant, true); - Livewire::test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups') - ->assertActionDisabled('sync_groups'); + $component = Livewire::test(ListEntraGroupSyncRuns::class); + expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); Queue::assertNothingPushed(); }); - it('allows owner members to execute sync action (dispatches job)', function () { + it('does not expose a sync action for owner members', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); $tenant->makeCurrent(); Filament::setTenant($tenant, true); - Livewire::test(ListEntraGroupSyncRuns::class) - ->assertActionVisible('sync_groups') - ->assertActionEnabled('sync_groups') - ->mountAction('sync_groups') - ->callMountedAction() - ->assertHasNoActionErrors(); + $component = Livewire::test(ListEntraGroupSyncRuns::class); + expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull(); - Queue::assertPushed(EntraGroupSyncJob::class); + Queue::assertNothingPushed(); }); }); diff --git a/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php b/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php index f0aa71b..4828fec 100644 --- a/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php +++ b/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; -use App\Jobs\ProviderConnectionHealthCheckJob; +use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\TenantOnboardingSession; @@ -101,6 +101,14 @@ ->test(ManagedTenantOnboardingWizard::class) ->call('startVerification'); - Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + + Queue::assertNothingPushed(); }); }); diff --git a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php index 9a1871f..f6e1690 100644 --- a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php +++ b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php @@ -1,6 +1,7 @@ actingAs($user); $tenant->makeCurrent(); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + expect(RegisterTenant::canView())->toBeFalse(); + + Filament::setCurrentPanel(null); }); it('is visible for owner members', function () { @@ -18,6 +23,10 @@ $this->actingAs($user); $tenant->makeCurrent(); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + expect(RegisterTenant::canView())->toBeTrue(); + + Filament::setCurrentPanel(null); }); }); diff --git a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php index 281fdbc..94bb9f1 100644 --- a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php +++ b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php @@ -2,6 +2,7 @@ use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Resources\TenantResource\Pages\CreateTenant; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -12,11 +13,15 @@ $this->actingAs($user); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + expect(RegisterTenant::canView())->toBeFalse(); Livewire::actingAs($user) ->test(RegisterTenant::class) ->assertStatus(404); + + Filament::setCurrentPanel(null); }); test('readonly users cannot create tenants', function () { diff --git a/tests/Feature/RestoreAssignmentApplicationTest.php b/tests/Feature/RestoreAssignmentApplicationTest.php index 501db21..8325536 100644 --- a/tests/Feature/RestoreAssignmentApplicationTest.php +++ b/tests/Feature/RestoreAssignmentApplicationTest.php @@ -81,6 +81,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-1', @@ -185,6 +187,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-1', @@ -271,6 +275,8 @@ public function request(string $method, string $path, array $options = []): Grap 'metadata' => [], ]); + ensureDefaultProviderConnection($tenant); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-1', diff --git a/tests/Feature/RestoreAuditLoggingTest.php b/tests/Feature/RestoreAuditLoggingTest.php index f87c535..bb000d8 100644 --- a/tests/Feature/RestoreAuditLoggingTest.php +++ b/tests/Feature/RestoreAuditLoggingTest.php @@ -5,6 +5,7 @@ use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\OperationRunService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; use App\Support\RestoreRunStatus; @@ -14,7 +15,7 @@ uses(RefreshDatabase::class); test('live restore execution emits an auditable event linked to the run', function () { - $tenant = Tenant::create([ + $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-audit', 'name' => 'Tenant Audit', 'metadata' => [], @@ -52,7 +53,18 @@ }); }); - $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $operationRun = app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'is_dry_run' => false, + ], + initiator: null, + ); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun); $job->handle($restoreService, app(AuditLogger::class)); $audit = AuditLog::query() diff --git a/tests/Feature/RestoreGraphErrorMetadataTest.php b/tests/Feature/RestoreGraphErrorMetadataTest.php index 62724de..57649b3 100644 --- a/tests/Feature/RestoreGraphErrorMetadataTest.php +++ b/tests/Feature/RestoreGraphErrorMetadataTest.php @@ -65,6 +65,8 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(); + + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php index 8528c5c..88f8ed4 100644 --- a/tests/Feature/RestoreRunWizardExecuteTest.php +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -4,6 +4,7 @@ use App\Jobs\ExecuteRestoreRunJob; use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\RestoreRun; use App\Models\Tenant; @@ -172,5 +173,20 @@ expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com'); expect($run->metadata['confirmed_at'] ?? null)->toBeString(); - Bus::assertDispatched(ExecuteRestoreRunJob::class); + $operationRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($operationRun)->not->toBeNull(); + expect($operationRun?->status)->toBe('queued'); + expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey()); + expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0)); + + Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool { + return $job->restoreRunId === (int) $run->getKey() + && $job->operationRun instanceof OperationRun + && $job->operationRun->getKey() === $operationRun?->getKey(); + }); }); diff --git a/tests/Feature/RestoreScopeTagMappingTest.php b/tests/Feature/RestoreScopeTagMappingTest.php index 9eab0e3..cfd95a4 100644 --- a/tests/Feature/RestoreScopeTagMappingTest.php +++ b/tests/Feature/RestoreScopeTagMappingTest.php @@ -63,6 +63,8 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); + $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 2, diff --git a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php index 43f6742..2445021 100644 --- a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php +++ b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php @@ -82,6 +82,7 @@ public function request(string $method, string $path, array $options = []): Grap config()->set('graph_contracts.types.securityBaselinePolicy', []); $tenant = Tenant::factory()->create(); + ensureDefaultProviderConnection($tenant); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', 'item_count' => 1, diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index ceb8d0b..8e56c88 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -1,7 +1,14 @@ where('tenant_id', $tenantB->id)->exists())->toBeFalse(); expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); }); + +it('prevents run creation when a readonly member tries to start inventory sync', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + + Livewire::test(ListInventoryItems::class) + ->assertActionVisible('run_inventory_sync') + ->assertActionDisabled('run_inventory_sync') + ->callAction('run_inventory_sync', data: ['tenant_id' => $tenant->getKey(), 'policy_types' => $allTypes]); + + Queue::assertNothingPushed(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); + +it('prevents run creation when a readonly member tries to start directory group sync', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroups::class) + ->assertActionVisible('sync_groups') + ->assertActionDisabled('sync_groups') + ->callAction('sync_groups'); + + Queue::assertNotPushed(EntraGroupSyncJob::class); + expect(EntraGroupSyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); + +it('prevents run creation when a readonly member tries to run a backup schedule now', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + '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, + ]); + + Livewire::test(ListBackupSchedules::class) + ->assertTableActionVisible('runNow', $schedule) + ->assertTableActionDisabled('runNow', $schedule) + ->callTableAction('runNow', $schedule); + + Queue::assertNothingPushed(); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); + +it('prevents run creation when a readonly member tries to retry a backup schedule', function () { + Queue::fake([RunBackupScheduleJob::class]); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + '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, + ]); + + Livewire::test(ListBackupSchedules::class) + ->assertTableActionVisible('retry', $schedule) + ->assertTableActionDisabled('retry', $schedule) + ->callTableAction('retry', $schedule); + + Queue::assertNothingPushed(); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php b/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php new file mode 100644 index 0000000..e9c96ea --- /dev/null +++ b/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php @@ -0,0 +1,44 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk(); + + expect(Filament::getTenant())->toBe($tenant); +}); + +it('renders workspace scope label when no tenant context is active', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant(null, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + expect(Filament::getTenant())->toBeNull(); +}); diff --git a/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php new file mode 100644 index 0000000..b8e2b61 --- /dev/null +++ b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php @@ -0,0 +1,63 @@ +create(); + + session()->forget(WorkspaceContext::SESSION_KEY); + + $this->actingAs($user) + ->get('/admin/operations') + ->assertNotFound(); +}); + +it('returns 404 for non-workspace-members on central operation run detail', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + session()->forget(WorkspaceContext::SESSION_KEY); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => null, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + $this->actingAs($user) + ->get("/admin/operations/{$run->getKey()}") + ->assertNotFound(); +}); + +it('returns 404 for non-entitled users on tenant dashboard direct access', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenantB)) + ->assertNotFound(); +}); diff --git a/tests/Feature/Spec085/OperationsIndexHeaderTest.php b/tests/Feature/Spec085/OperationsIndexHeaderTest.php new file mode 100644 index 0000000..0779a28 --- /dev/null +++ b/tests/Feature/Spec085/OperationsIndexHeaderTest.php @@ -0,0 +1,83 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Tenant — '.$tenant->name) + ->assertSee('Back to '.$tenant->name) + ->assertSee('Show all tenants'); +}); + +it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void { + $entitledTenant = Tenant::factory()->create(); + [$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly'); + + $staleTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + ]); + + Filament::setTenant($staleTenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Workspace — all tenants') + ->assertDontSee('Back to '.$staleTenant->name) + ->assertDontSee($staleTenant->name) + ->assertDontSee('Show all tenants'); +}); + +it('clears filament tenant context and last-tenant session state via clear-tenant-context endpoint', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $lastTenantIds = [ + (string) $workspaceId => (int) $tenant->getKey(), + ]; + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantIds, + ]) + ->from('/admin/alerts') + ->post('/admin/clear-tenant-context') + ->assertRedirect('/admin/alerts'); + + expect(Filament::getTenant())->toBeNull(); + expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [])) + ->not->toHaveKey((string) $workspaceId); + + $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + ]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Workspace — all tenants') + ->assertDontSee('Scope: Tenant — '.$tenant->name); +}); diff --git a/tests/Feature/Spec085/RunDetailBackAffordanceTest.php b/tests/Feature/Spec085/RunDetailBackAffordanceTest.php new file mode 100644 index 0000000..23f1bfa --- /dev/null +++ b/tests/Feature/Spec085/RunDetailBackAffordanceTest.php @@ -0,0 +1,93 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/operations/{$run->getKey()}") + ->assertOk() + ->assertSee('← Back to '.$tenant->name) + ->assertSee('Show all operations') + ->assertDontSee('Back to Operations'); +}); + +it('shows only back-to-operations when no tenant context is active', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + Filament::setTenant(null, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/operations/{$run->getKey()}") + ->assertOk() + ->assertSee('Back to Operations') + ->assertDontSee('← Back to ') + ->assertDontSee('Show all operations'); +}); + +it('treats stale tenant context as workspace-wide on run detail', function (): void { + $entitledTenant = Tenant::factory()->create(); + [$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly'); + + $staleTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + 'tenant_id' => (int) $entitledTenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + Filament::setTenant($staleTenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id]) + ->get("/admin/operations/{$run->getKey()}") + ->assertOk() + ->assertSee('Scope: Workspace — all tenants') + ->assertSee('Back to Operations') + ->assertDontSee('← Back to '.$staleTenant->name) + ->assertDontSee($staleTenant->name) + ->assertDontSee('Show all operations'); +}); diff --git a/tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php b/tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php new file mode 100644 index 0000000..e545b8c --- /dev/null +++ b/tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php @@ -0,0 +1,40 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertOk(); + + $panel = Filament::getCurrentOrDefaultPanel(); + + $monitoringLabels = collect($panel->getNavigationItems()) + ->filter(static fn ($item): bool => $item->getGroup() === 'Monitoring') + ->map(static fn ($item): string => $item->getLabel()) + ->values() + ->all(); + + expect($monitoringLabels)->toContain('Runs'); + expect($monitoringLabels)->toContain('Alerts'); + expect($monitoringLabels)->toContain('Audit Log'); + + expect($monitoringLabels)->not->toContain('Operations'); +}); diff --git a/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php b/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php new file mode 100644 index 0000000..5972dda --- /dev/null +++ b/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php @@ -0,0 +1,38 @@ +create([ + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret', + 'status' => 'active', + ]); + + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $service = app(RoleDefinitionsSyncService::class); + + $run = $service->startManualSync($tenant, $user); + + expect($run->type)->toBe('directory_role_definitions.sync'); + + $url = OperationRunLinks::tenantlessView($run); + expect($url)->toContain('/admin/operations/'); + + Bus::assertDispatched( + App\Jobs\SyncRoleDefinitionsJob::class, + fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run) + ); +}); diff --git a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php index c8d216d..d903e4a 100644 --- a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php +++ b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php @@ -5,6 +5,7 @@ use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -19,6 +20,8 @@ $this->actingAs($user); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + $tenantGuid = '11111111-1111-1111-1111-111111111111'; Livewire::test(RegisterTenant::class) @@ -28,6 +31,8 @@ ->set('data.domain', 'acme.example') ->call('register'); + Filament::setCurrentPanel(null); + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); $membership = TenantMembership::query() diff --git a/tests/Feature/TermsAndConditionsPolicyTypeTest.php b/tests/Feature/TermsAndConditionsPolicyTypeTest.php index 84cce93..77c7909 100644 --- a/tests/Feature/TermsAndConditionsPolicyTypeTest.php +++ b/tests/Feature/TermsAndConditionsPolicyTypeTest.php @@ -95,6 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, $client); $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-1']); + ensureDefaultProviderConnection($tenant); $policy = Policy::factory()->create([ 'tenant_id' => $tenant->id, 'external_id' => 'tc-1', @@ -182,6 +183,7 @@ public function request(string $method, string $path, array $options = []): Grap it('syncs terms and conditions from graph', function () { $tenant = Tenant::factory()->create(['status' => 'active']); + ensureDefaultProviderConnection($tenant); $logger = mock(GraphLogger::class); $logger->shouldReceive('logRequest') diff --git a/tests/Pest.php b/tests/Pest.php index e1c324c..4117f08 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ (string) $tenant->external_id]; } + +function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection +{ + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', $provider) + ->where('is_default', true) + ->orderBy('id') + ->first(); + + if (! $connection instanceof ProviderConnection) { + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => $provider, + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? fake()->uuid()), + 'status' => 'connected', + 'health_status' => 'ok', + 'is_default' => true, + ]); + } + + $credential = $connection->credential()->first(); + + if (! $credential instanceof ProviderCredential) { + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => fake()->uuid(), + 'client_secret' => fake()->sha1(), + ], + ]); + + $connection->refresh(); + } + + return $connection; +} diff --git a/tests/Unit/Auth/NoRoleStringChecksTest.php b/tests/Unit/Auth/NoRoleStringChecksTest.php index 3afca09..ce55b0b 100644 --- a/tests/Unit/Auth/NoRoleStringChecksTest.php +++ b/tests/Unit/Auth/NoRoleStringChecksTest.php @@ -17,16 +17,16 @@ $patterns = [ // $membership->role === 'owner' / !== 'owner' - '/->role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i', + '/->role\s*(===|==|!==|!=)\s*(["\'])'.$roleValuePattern.'\2/i', // $role === 'owner' - '/\$role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i', + '/\$role\s*(===|==|!==|!=)\s*(["\'])'.$roleValuePattern.'\2/i', // case 'owner': - '/\bcase\s*[\"\']?'.$roleValuePattern.'[\"\']?\s*:/i', + '/\bcase\s*(["\'])'.$roleValuePattern.'\1\s*:/i', // match (...) { 'owner' => ... } - '/\bmatch\b[\s\S]*?\{[\s\S]*?[\"\']?'.$roleValuePattern.'[\"\']?\s*=>/i', + '/\bmatch\b[\s\S]*?\{[\s\S]*?(["\'])'.$roleValuePattern.'\1\s*=>/i', ]; $filesystem = new Filesystem; diff --git a/tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php b/tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php new file mode 100644 index 0000000..07b1115 --- /dev/null +++ b/tests/Unit/Filament/EditProviderConnectionTenantResolutionTest.php @@ -0,0 +1,43 @@ +create(); + $tenantB = Tenant::factory()->create(); + + $tenantB->makeCurrent(); + + $page = app(EditProviderConnection::class); + $page->scopedTenantExternalId = (string) $tenantA->external_id; + + $method = new ReflectionMethod($page, 'currentTenant'); + $method->setAccessible(true); + + $resolvedTenant = $method->invoke($page); + + expect($resolvedTenant)->toBeInstanceOf(Tenant::class); + expect($resolvedTenant->is($tenantA))->toBeTrue(); +}); + +test('EditProviderConnection falls back to Tenant::current when no scoped tenant is set', function (): void { + $tenantA = Tenant::factory()->create(); + + $tenantA->makeCurrent(); + + $page = app(EditProviderConnection::class); + + $method = new ReflectionMethod($page, 'currentTenant'); + $method->setAccessible(true); + + $resolvedTenant = $method->invoke($page); + + expect($resolvedTenant)->toBeInstanceOf(Tenant::class); + expect($resolvedTenant->is($tenantA))->toBeTrue(); +}); diff --git a/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php b/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php new file mode 100644 index 0000000..f253f46 --- /dev/null +++ b/tests/Unit/Filament/ProviderConnectionResourceLivewireTenantInferenceTest.php @@ -0,0 +1,36 @@ +create([ + 'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf', + ]); + + $request = Request::create('/livewire/update', 'POST'); + $request->headers->set('x-livewire', '1'); + $request->headers->set('referer', "http://localhost/admin/tenants/{$tenant->external_id}/provider-connections/1/edit"); + app()->instance('request', $request); + + expect(Tenant::query()->where('external_id', $tenant->external_id)->exists())->toBeTrue(); + + $method = new ReflectionMethod(ProviderConnectionResource::class, 'resolveScopedTenant'); + $method->setAccessible(true); + $resolvedTenant = $method->invoke(null); + + expect($resolvedTenant)->toBeInstanceOf(Tenant::class); + expect($resolvedTenant->is($tenant))->toBeTrue(); + + $url = ProviderConnectionResource::getUrl('index'); + + expect($url)->toContain((string) $tenant->external_id); + expect($url)->toContain('/admin/tenants/'); + expect($url)->toContain('/provider-connections'); +}); diff --git a/tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php b/tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php new file mode 100644 index 0000000..2788d57 --- /dev/null +++ b/tests/Unit/Policies/ProviderConnectionPolicyTenantResolutionTest.php @@ -0,0 +1,36 @@ +create([ + 'workspace_id' => $tenantA->workspace_id, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + + // Simulate a different "current" tenant (e.g. Livewire update without {tenant} route param). + $tenantB->makeCurrent(); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'tenant_id' => (int) $tenantA->getKey(), + 'provider' => 'microsoft', + ]); + + $policy = app(ProviderConnectionPolicy::class); + + $result = $policy->update($user, $connection); + + expect($result)->toBeTrue(); +}); diff --git a/tests/Unit/Providers/ProviderNextStepsRegistryTest.php b/tests/Unit/Providers/ProviderNextStepsRegistryTest.php new file mode 100644 index 0000000..434a347 --- /dev/null +++ b/tests/Unit/Providers/ProviderNextStepsRegistryTest.php @@ -0,0 +1,60 @@ + 'tenant-1', + 'name' => 'Contoso', + ]); + + $registry = app(ProviderNextStepsRegistry::class); + + $steps = $registry->forReason($tenant, ProviderReasonCodes::ProviderConsentMissing); + + expect($steps)->toBeArray() + ->and($steps)->not->toBeEmpty() + ->and($steps[0]['label'])->toBe('Grant admin consent') + ->and($steps[0]['url'])->toContain('learn.microsoft.com'); +}); + +it('links to the real admin consent endpoint when provider credentials exist', function () { + $tenant = Tenant::factory()->create([ + 'app_client_id' => null, + ]); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->graphTenantId(), + 'is_default' => true, + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'payload' => [ + 'client_id' => 'derived-client-id', + 'client_secret' => 'derived-client-secret', + ], + ]); + + $registry = app(ProviderNextStepsRegistry::class); + + $steps = $registry->forReason($tenant, ProviderReasonCodes::ProviderConsentMissing, $connection); + + expect($steps)->toBeArray() + ->and($steps)->not->toBeEmpty() + ->and($steps[0]['label'])->toBe('Grant admin consent') + ->and($steps[0]['url'])->toContain('login.microsoftonline.com') + ->and($steps[0]['url'])->toContain('adminconsent'); +}); diff --git a/tests/Unit/TenantResourceConsentUrlTest.php b/tests/Unit/TenantResourceConsentUrlTest.php index faa07dd..5add285 100644 --- a/tests/Unit/TenantResourceConsentUrlTest.php +++ b/tests/Unit/TenantResourceConsentUrlTest.php @@ -1,6 +1,8 @@ toContain(urlencode('https://graph.microsoft.com/.default')); } }); + +it('can derive admin consent url from provider connection credentials when tenant app_client_id is missing', function () { + $tenant = Tenant::factory()->create([ + 'app_client_id' => null, + ]); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->graphTenantId(), + 'is_default' => true, + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'payload' => [ + 'client_id' => 'derived-client-id', + 'client_secret' => 'derived-client-secret', + ], + ]); + + $url = TenantResource::adminConsentUrl($tenant); + + expect($url) + ->not->toBeNull() + ->and($url)->toContain('login.microsoftonline.com') + ->and($url)->toContain('adminconsent') + ->and($url)->toContain(urlencode('derived-client-id')); +});