From 200498fa8e1190a833c87de11be9dab3576cb357 Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 27 Feb 2026 01:11:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(113):=20Platform=20Ops=20Runbooks=20?= =?UTF-8?q?=E2=80=94=20UX=20Polish=20(Filament-native,=20system=20theme,?= =?UTF-8?q?=20live=20scope)=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements and polishes the Platform Ops Runbooks feature (Spec 113) — the operator control plane for safe backfills and data repair from `/system`. ## Changes ### UX Polish (Phase 7 — US4) - **Filament-native components**: Rewrote `runbooks.blade.php` and `view-run.blade.php` using `` instead of raw Tailwind div cards. Cards now render correctly with Filament's built-in borders, shadows and dark mode. - **System panel theme**: Created `resources/css/filament/system/theme.css` and registered `->viteTheme()` on `SystemPanelProvider`. The system panel previously had no theme CSS registered — Tailwind utility classes weren't compiled for its views, causing the warning icon SVG to expand to full container size. - **Live scope selector**: Added `->live()` to the scope `Radio` field so "Single tenant" immediately reveals the tenant search dropdown without requiring a Submit first. ### Core Feature (Phases 1–6, previously shipped) - `/system/ops/runbooks` — runbook catalog, preflight, run with typed confirmation + reason - `/system/ops/runs` — run history table with status/outcome badges - `/system/ops/runs/{id}` — run detail view with summary counts, failures, collapsible context - `FindingsLifecycleBackfillRunbookService` — preflight + execution logic - AllowedTenantUniverse — scopes tenant picker to non-platform tenants only - RBAC: `platform.ops.view`, `platform.runbooks.view`, `platform.runbooks.run`, `platform.runbooks.findings.lifecycle_backfill` - Rate-limited `/system/login` (10/min per IP+username) - Distinct session cookie for `/system` isolation ## Test Coverage - 16 tests / 141 assertions — all passing - Covers: page access, RBAC, preflight, run dispatch, scope selector, run detail, run list ## Checklist - [x] Filament v5 / Livewire v4 compliant - [x] Provider registered in `bootstrap/providers.php` - [x] Destructive actions require confirmation (`->requiresConfirmation()`) - [x] System panel theme registered (`viteTheme`) - [x] Pint clean - [x] Tests pass Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/137 --- .../TenantpilotBackfillFindingLifecycle.php | 56 +- .../Commands/TenantpilotRunDeployRunbooks.php | 51 ++ .../FindingResource/Pages/ListFindings.php | 214 +++--- app/Filament/System/Pages/Auth/Login.php | 34 + app/Filament/System/Pages/Ops/Runbooks.php | 272 ++++++++ app/Filament/System/Pages/Ops/Runs.php | 104 +++ app/Filament/System/Pages/Ops/ViewRun.php | 54 ++ .../Middleware/EnsurePlatformCapability.php | 2 +- .../Middleware/UseSystemSessionCookie.php | 35 + ...SystemSessionCookieForLivewireRequests.php | 103 +++ app/Jobs/BackfillFindingLifecycleJob.php | 14 +- ...dingLifecycleTenantIntoWorkspaceRunJob.php | 375 +++++++++++ .../BackfillFindingLifecycleWorkspaceJob.php | 95 +++ app/Notifications/OperationRunCompleted.php | 16 +- .../Filament/SystemPanelProvider.php | 9 +- ...indingsLifecycleBackfillRunbookService.php | 609 ++++++++++++++++++ .../FindingsLifecycleBackfillScope.php | 81 +++ app/Services/Runbooks/RunbookReason.php | 103 +++ app/Services/System/AllowedTenantUniverse.php | 37 ++ app/Support/Auth/PlatformCapabilities.php | 8 + .../System/SystemOperationRunLinks.php | 24 + bootstrap/app.php | 4 + config/tenantpilot.php | 2 + database/seeders/PlatformUserSeeder.php | 9 +- resources/css/filament/system/theme.css | 4 + .../system/pages/ops/runbooks.blade.php | 129 ++++ .../filament/system/pages/ops/runs.blade.php | 4 + .../system/pages/ops/view-run.blade.php | 179 +++++ .../checklists/requirements.md | 35 + .../system-ops-runbooks.openapi.yaml | 168 +++++ specs/113-platform-ops-runbooks/data-model.md | 99 +++ specs/113-platform-ops-runbooks/plan.md | 128 ++++ specs/113-platform-ops-runbooks/quickstart.md | 35 + specs/113-platform-ops-runbooks/research.md | 82 +++ specs/113-platform-ops-runbooks/spec.md | 190 ++++++ specs/113-platform-ops-runbooks/tasks.md | 192 ++++++ tests/Feature/Auth/SystemPanelAuthTest.php | 4 +- .../Spec113/DeployRunbooksCommandTest.php | 31 + .../AdminFindingsNoMaintenanceActionsTest.php | 20 + ...ingsLifecycleBackfillAuditFailSafeTest.php | 87 +++ ...indingsLifecycleBackfillBreakGlassTest.php | 111 ++++ ...ndingsLifecycleBackfillIdempotencyTest.php | 78 +++ ...FindingsLifecycleBackfillPreflightTest.php | 84 +++ .../FindingsLifecycleBackfillStartTest.php | 113 ++++ .../OpsUxStartSurfaceContractTest.php | 89 +++ .../Spec113/AllowedTenantUniverseTest.php | 47 ++ .../Spec113/AuthorizationSemanticsTest.php | 43 ++ .../Spec113/SystemLoginThrottleTest.php | 67 ++ .../Spec113/SystemSessionIsolationTest.php | 17 + .../TenantPlaneCannotAccessSystemTest.php | 16 + vite.config.js | 1 + 51 files changed, 4223 insertions(+), 141 deletions(-) create mode 100644 app/Console/Commands/TenantpilotRunDeployRunbooks.php create mode 100644 app/Filament/System/Pages/Ops/Runbooks.php create mode 100644 app/Filament/System/Pages/Ops/Runs.php create mode 100644 app/Filament/System/Pages/Ops/ViewRun.php create mode 100644 app/Http/Middleware/UseSystemSessionCookie.php create mode 100644 app/Http/Middleware/UseSystemSessionCookieForLivewireRequests.php create mode 100644 app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php create mode 100644 app/Jobs/BackfillFindingLifecycleWorkspaceJob.php create mode 100644 app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php create mode 100644 app/Services/Runbooks/FindingsLifecycleBackfillScope.php create mode 100644 app/Services/Runbooks/RunbookReason.php create mode 100644 app/Services/System/AllowedTenantUniverse.php create mode 100644 app/Support/System/SystemOperationRunLinks.php create mode 100644 resources/css/filament/system/theme.css create mode 100644 resources/views/filament/system/pages/ops/runbooks.blade.php create mode 100644 resources/views/filament/system/pages/ops/runs.blade.php create mode 100644 resources/views/filament/system/pages/ops/view-run.blade.php create mode 100644 specs/113-platform-ops-runbooks/checklists/requirements.md create mode 100644 specs/113-platform-ops-runbooks/contracts/system-ops-runbooks.openapi.yaml create mode 100644 specs/113-platform-ops-runbooks/data-model.md create mode 100644 specs/113-platform-ops-runbooks/plan.md create mode 100644 specs/113-platform-ops-runbooks/quickstart.md create mode 100644 specs/113-platform-ops-runbooks/research.md create mode 100644 specs/113-platform-ops-runbooks/spec.md create mode 100644 specs/113-platform-ops-runbooks/tasks.md create mode 100644 tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php create mode 100644 tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php create mode 100644 tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php create mode 100644 tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php create mode 100644 tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php create mode 100644 tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php create mode 100644 tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php create mode 100644 tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php create mode 100644 tests/Feature/System/Spec113/AllowedTenantUniverseTest.php create mode 100644 tests/Feature/System/Spec113/AuthorizationSemanticsTest.php create mode 100644 tests/Feature/System/Spec113/SystemLoginThrottleTest.php create mode 100644 tests/Feature/System/Spec113/SystemSessionIsolationTest.php create mode 100644 tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php diff --git a/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php b/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php index ef3a625..7769fb6 100644 --- a/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php +++ b/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php @@ -4,10 +4,11 @@ namespace App\Console\Commands; -use App\Jobs\BackfillFindingLifecycleJob; use App\Models\Tenant; -use App\Services\OperationRunService; +use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; +use App\Services\Runbooks\FindingsLifecycleBackfillScope; use Illuminate\Console\Command; +use Illuminate\Validation\ValidationException; class TenantpilotBackfillFindingLifecycle extends Command { @@ -16,7 +17,7 @@ class TenantpilotBackfillFindingLifecycle extends Command protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.'; - public function handle(OperationRunService $operationRuns): int + public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int { $tenantIdentifiers = array_values(array_filter((array) $this->option('tenant'))); @@ -36,25 +37,37 @@ public function handle(OperationRunService $operationRuns): int $queued = 0; $skipped = 0; + $nothingToDo = 0; foreach ($tenants as $tenant) { if (! $tenant instanceof Tenant) { continue; } - $run = $operationRuns->ensureRunWithIdentity( - tenant: $tenant, - type: 'findings.lifecycle.backfill', - identityInputs: [ - 'tenant_id' => (int) $tenant->getKey(), - 'trigger' => 'backfill', - ], - context: [ - 'workspace_id' => (int) $tenant->workspace_id, - 'source' => 'tenantpilot:findings:backfill-lifecycle', - ], - initiator: null, - ); + try { + $run = $runbookService->start( + scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), + initiator: null, + reason: null, + source: 'cli', + ); + } catch (ValidationException $e) { + $errors = $e->errors(); + + if (isset($errors['preflight.affected_count'])) { + $nothingToDo++; + + continue; + } + + $this->error(sprintf( + 'Backfill blocked for tenant %d: %s', + (int) $tenant->getKey(), + $e->getMessage(), + )); + + return self::FAILURE; + } if (! $run->wasRecentlyCreated) { $skipped++; @@ -62,21 +75,14 @@ public function handle(OperationRunService $operationRuns): int continue; } - $operationRuns->dispatchOrFail($run, function () use ($tenant): void { - BackfillFindingLifecycleJob::dispatch( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: null, - ); - }); - $queued++; } $this->info(sprintf( - 'Queued %d backfill run(s), skipped %d duplicate run(s).', + 'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.', $queued, $skipped, + $nothingToDo, )); return self::SUCCESS; diff --git a/app/Console/Commands/TenantpilotRunDeployRunbooks.php b/app/Console/Commands/TenantpilotRunDeployRunbooks.php new file mode 100644 index 0000000..fbc3428 --- /dev/null +++ b/app/Console/Commands/TenantpilotRunDeployRunbooks.php @@ -0,0 +1,51 @@ +start( + scope: FindingsLifecycleBackfillScope::allTenants(), + initiator: null, + reason: new RunbookReason( + reasonCode: RunbookReason::CODE_DATA_REPAIR, + reasonText: 'Deploy hook automated runbooks', + ), + source: 'deploy_hook', + ); + + $this->info('Deploy runbooks started (if needed).'); + + return self::SUCCESS; + } catch (ValidationException $e) { + $errors = $e->errors(); + + $skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']); + + if ($skippable) { + $this->info('Deploy runbooks skipped (nothing to do or already running).'); + + return self::SUCCESS; + } + + $this->error('Deploy runbooks blocked by validation errors.'); + + return self::FAILURE; + } + } +} diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index 14329c2..658abe3 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -29,8 +29,10 @@ class ListFindings extends ListRecords protected function getHeaderActions(): array { - return [ - UiEnforcement::forAction( + $actions = []; + + if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) { + $actions[] = UiEnforcement::forAction( Actions\Action::make('backfill_lifecycle') ->label('Backfill findings lifecycle') ->icon('heroicon-o-wrench-screwdriver') @@ -104,116 +106,118 @@ protected function getHeaderActions(): array ->preserveVisibility() ->requireCapability(Capabilities::TENANT_MANAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(), + ->apply(); + } - UiEnforcement::forAction( - Actions\Action::make('triage_all_matching') - ->label('Triage all matching') - ->icon('heroicon-o-check') - ->color('gray') - ->requiresConfirmation() - ->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW) - ->modalDescription(function (): string { - $count = $this->getAllMatchingCount(); + $actions[] = UiEnforcement::forAction( + Actions\Action::make('triage_all_matching') + ->label('Triage all matching') + ->icon('heroicon-o-check') + ->color('gray') + ->requiresConfirmation() + ->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW) + ->modalDescription(function (): string { + $count = $this->getAllMatchingCount(); - return "You are about to triage {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; - }) - ->form(function (): array { - $count = $this->getAllMatchingCount(); + return "You are about to triage {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; + }) + ->form(function (): array { + $count = $this->getAllMatchingCount(); - if ($count <= 100) { - return []; - } + if ($count <= 100) { + return []; + } - return [ - TextInput::make('confirmation') - ->label('Type TRIAGE to confirm') - ->required() - ->in(['TRIAGE']) - ->validationMessages([ - 'in' => 'Please type TRIAGE to confirm.', - ]), - ]; - }) - ->action(function (FindingWorkflowService $workflow): void { - $query = $this->buildAllMatchingQuery(); - $count = (clone $query)->count(); - - if ($count === 0) { - Notification::make() - ->title('No matching findings') - ->body('There are no new findings matching the current filters to triage.') - ->warning() - ->send(); - - return; - } - - $user = auth()->user(); - $tenant = \Filament\Facades\Filament::getTenant(); - - if (! $user instanceof User) { - abort(403); - } - - if (! $tenant instanceof Tenant) { - abort(404); - } - - $triagedCount = 0; - $skippedCount = 0; - $failedCount = 0; - - $query->orderBy('id')->chunkById(200, function ($findings) use ($workflow, $tenant, $user, &$triagedCount, &$skippedCount, &$failedCount): void { - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - $skippedCount++; - - continue; - } - - if (! in_array((string) $finding->status, [ - Finding::STATUS_NEW, - Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, - ], true)) { - $skippedCount++; - - continue; - } - - try { - $workflow->triage($finding, $tenant, $user); - $triagedCount++; - } catch (Throwable) { - $failedCount++; - } - } - }); - - $this->deselectAllTableRecords(); - $this->resetPage(); - - $body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.'; - if ($skippedCount > 0) { - $body .= " Skipped {$skippedCount}."; - } - if ($failedCount > 0) { - $body .= " Failed {$failedCount}."; - } + return [ + TextInput::make('confirmation') + ->label('Type TRIAGE to confirm') + ->required() + ->in(['TRIAGE']) + ->validationMessages([ + 'in' => 'Please type TRIAGE to confirm.', + ]), + ]; + }) + ->action(function (FindingWorkflowService $workflow): void { + $query = $this->buildAllMatchingQuery(); + $count = (clone $query)->count(); + if ($count === 0) { Notification::make() - ->title('Bulk triage completed') - ->body($body) - ->status($failedCount > 0 ? 'warning' : 'success') + ->title('No matching findings') + ->body('There are no new findings matching the current filters to triage.') + ->warning() ->send(); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(), - ]; + + return; + } + + $user = auth()->user(); + $tenant = \Filament\Facades\Filament::getTenant(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $triagedCount = 0; + $skippedCount = 0; + $failedCount = 0; + + $query->orderBy('id')->chunkById(200, function ($findings) use ($workflow, $tenant, $user, &$triagedCount, &$skippedCount, &$failedCount): void { + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + $skippedCount++; + + continue; + } + + if (! in_array((string) $finding->status, [ + Finding::STATUS_NEW, + Finding::STATUS_REOPENED, + Finding::STATUS_ACKNOWLEDGED, + ], true)) { + $skippedCount++; + + continue; + } + + try { + $workflow->triage($finding, $tenant, $user); + $triagedCount++; + } catch (Throwable) { + $failedCount++; + } + } + }); + + $this->deselectAllTableRecords(); + $this->resetPage(); + + $body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; + } + if ($failedCount > 0) { + $body .= " Failed {$failedCount}."; + } + + Notification::make() + ->title('Bulk triage completed') + ->body($body) + ->status($failedCount > 0 ? 'warning' : 'success') + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + + return $actions; } protected function buildAllMatchingQuery(): Builder diff --git a/app/Filament/System/Pages/Auth/Login.php b/app/Filament/System/Pages/Auth/Login.php index e4c2c31..f84af1d 100644 --- a/app/Filament/System/Pages/Auth/Login.php +++ b/app/Filament/System/Pages/Auth/Login.php @@ -9,18 +9,42 @@ use App\Services\Intune\AuditLogger; use Filament\Auth\Http\Responses\Contracts\LoginResponse; use Filament\Auth\Pages\Login as BaseLogin; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\ValidationException; class Login extends BaseLogin { + /** + * Filament's base login page uses Livewire-level rate limiting. We override it + * to enforce the System panel policy via Laravel's RateLimiter (SR-003). + */ + protected function rateLimit($maxAttempts, $decaySeconds = 60, $method = null, $component = null): void + { + } + public function authenticate(): ?LoginResponse { $data = $this->form->getState(); $email = (string) ($data['email'] ?? ''); + $throttleKey = $this->throttleKey($email); + + if (RateLimiter::tooManyAttempts($throttleKey, 10)) { + $this->audit(status: 'failure', email: $email, actor: null, reason: 'throttled'); + + $seconds = RateLimiter::availableIn($throttleKey); + + throw ValidationException::withMessages([ + 'data.email' => __('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => (int) ceil($seconds / 60), + ]), + ]); + } try { $response = parent::authenticate(); } catch (ValidationException $exception) { + RateLimiter::hit($throttleKey, 60); $this->audit(status: 'failure', email: $email, actor: null, reason: 'invalid_credentials'); throw $exception; @@ -40,6 +64,7 @@ public function authenticate(): ?LoginResponse if (! $user->is_active) { auth('platform')->logout(); + RateLimiter::hit($throttleKey, 60); $this->audit(status: 'failure', email: $email, actor: null, reason: 'inactive'); throw ValidationException::withMessages([ @@ -47,6 +72,7 @@ public function authenticate(): ?LoginResponse ]); } + RateLimiter::clear($throttleKey); $user->forceFill(['last_login_at' => now()])->saveQuietly(); $this->audit(status: 'success', email: $email, actor: $user); @@ -54,6 +80,14 @@ public function authenticate(): ?LoginResponse return $response; } + private function throttleKey(string $email): string + { + $ip = (string) request()->ip(); + $normalizedEmail = mb_strtolower(trim($email)); + + return "system-login:{$ip}:{$normalizedEmail}"; + } + private function audit(string $status, string $email, ?PlatformUser $actor, ?string $reason = null): void { $tenant = Tenant::query()->where('external_id', 'platform')->first(); diff --git a/app/Filament/System/Pages/Ops/Runbooks.php b/app/Filament/System/Pages/Ops/Runbooks.php new file mode 100644 index 0000000..c4499ae --- /dev/null +++ b/app/Filament/System/Pages/Ops/Runbooks.php @@ -0,0 +1,272 @@ +scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) { + return 'All tenants'; + } + + $tenantName = $this->selectedTenantName(); + + if ($tenantName !== null) { + return "Single tenant ({$tenantName})"; + } + + return $this->tenantId !== null ? "Single tenant (#{$this->tenantId})" : 'Single tenant'; + } + + public function lastRun(): ?OperationRun + { + $platformTenant = Tenant::query()->where('external_id', 'platform')->first(); + + if (! $platformTenant instanceof Tenant) { + return null; + } + + return OperationRun::query() + ->where('workspace_id', (int) $platformTenant->workspace_id) + ->where('type', FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY) + ->latest('id') + ->first(); + } + + public function selectedTenantName(): ?string + { + if ($this->tenantId === null) { + return null; + } + + return Tenant::query()->whereKey($this->tenantId)->value('name'); + } + + public static function canAccess(): bool + { + $user = auth('platform')->user(); + + if (! $user instanceof PlatformUser) { + return false; + } + + return $user->hasCapability(PlatformCapabilities::OPS_VIEW) + && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + Action::make('preflight') + ->label('Preflight') + ->color('gray') + ->icon('heroicon-o-magnifying-glass') + ->form($this->scopeForm()) + ->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void { + $scope = FindingsLifecycleBackfillScope::fromArray([ + 'mode' => $data['scope_mode'] ?? null, + 'tenant_id' => $data['tenant_id'] ?? null, + ]); + + $this->scopeMode = $scope->mode; + $this->tenantId = $scope->tenantId; + + $this->preflight = $runbookService->preflight($scope); + + Notification::make() + ->title('Preflight complete') + ->success() + ->send(); + }), + + Action::make('run') + ->label('Run…') + ->icon('heroicon-o-play') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Run: Rebuild Findings Lifecycle') + ->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.') + ->form($this->runForm()) + ->disabled(fn (): bool => ! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0) + ->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void { + if (! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0) { + throw ValidationException::withMessages([ + 'preflight' => 'Run preflight first.', + ]); + } + + $scope = $this->scopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT + ? FindingsLifecycleBackfillScope::singleTenant((int) $this->tenantId) + : FindingsLifecycleBackfillScope::allTenants(); + + $user = auth('platform')->user(); + + if (! $user instanceof PlatformUser) { + abort(403); + } + + if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN) + || ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL) + ) { + abort(403); + } + + if ($scope->isAllTenants()) { + $typedConfirmation = (string) ($data['typed_confirmation'] ?? ''); + + if ($typedConfirmation !== 'BACKFILL') { + throw ValidationException::withMessages([ + 'typed_confirmation' => 'Please type BACKFILL to confirm.', + ]); + } + } + + $reason = RunbookReason::fromNullableArray([ + 'reason_code' => $data['reason_code'] ?? null, + 'reason_text' => $data['reason_text'] ?? null, + ]); + + $run = $runbookService->start( + scope: $scope, + initiator: $user, + reason: $reason, + source: 'system_ui', + ); + + $viewUrl = SystemOperationRunLinks::view($run); + + $toast = $run->wasRecentlyCreated + ? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.') + : OperationUxPresenter::alreadyQueuedToast((string) $run->type); + + $toast + ->actions([ + Action::make('view_run') + ->label('View run') + ->url($viewUrl), + ]) + ->send(); + }), + ]; + } + + /** + * @return array + */ + private function scopeForm(): array + { + return [ + Radio::make('scope_mode') + ->label('Scope') + ->options([ + FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants', + FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant', + ]) + ->default($this->scopeMode) + ->live() + ->required(), + + Select::make('tenant_id') + ->label('Tenant') + ->searchable() + ->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) + ->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) + ->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array { + return $universe + ->query() + ->where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(25) + ->pluck('name', 'id') + ->all(); + }) + ->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string { + if (! is_numeric($value)) { + return null; + } + + return $universe + ->query() + ->whereKey((int) $value) + ->value('name'); + }), + ]; + } + + /** + * @return array + */ + private function runForm(): array + { + return [ + TextInput::make('typed_confirmation') + ->label('Type BACKFILL to confirm') + ->visible(fn (): bool => $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) + ->required(fn (): bool => $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) + ->in(['BACKFILL']) + ->validationMessages([ + 'in' => 'Please type BACKFILL to confirm.', + ]), + + Select::make('reason_code') + ->label('Reason code') + ->options(RunbookReason::options()) + ->required(function (BreakGlassSession $breakGlass): bool { + return $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive(); + }), + + Textarea::make('reason_text') + ->label('Reason') + ->rows(4) + ->maxLength(500) + ->required(function (BreakGlassSession $breakGlass): bool { + return $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive(); + }), + ]; + } +} diff --git a/app/Filament/System/Pages/Ops/Runs.php b/app/Filament/System/Pages/Ops/Runs.php new file mode 100644 index 0000000..5e9a1ab --- /dev/null +++ b/app/Filament/System/Pages/Ops/Runs.php @@ -0,0 +1,104 @@ +user(); + + if (! $user instanceof PlatformUser) { + return false; + } + + return $user->hasCapability(PlatformCapabilities::OPS_VIEW) + && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW); + } + + public function mount(): void + { + $this->mountInteractsWithTable(); + } + + public function table(Table $table): Table + { + return $table + ->defaultSort('id', 'desc') + ->query(function (): Builder { + $platformTenant = Tenant::query()->where('external_id', 'platform')->first(); + + $workspaceId = $platformTenant instanceof Tenant ? (int) $platformTenant->workspace_id : null; + + return OperationRun::query() + ->with('tenant') + ->when($workspaceId, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId)) + ->when(! $workspaceId, fn (Builder $query): Builder => $query->whereRaw('1 = 0')) + ->where('type', FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY); + }) + ->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)), + TextColumn::make('scope') + ->label('Scope') + ->getStateUsing(function (OperationRun $record): string { + $scope = (string) data_get($record->context, 'runbook.scope', 'unknown'); + $tenantName = $record->tenant instanceof Tenant ? $record->tenant->name : null; + + if ($scope === 'single_tenant' && $tenantName) { + return "Single tenant ({$tenantName})"; + } + + return $scope === 'all_tenants' ? 'All tenants' : $scope; + }), + TextColumn::make('initiator_name')->label('Initiator'), + TextColumn::make('created_at')->label('Started')->since(), + TextColumn::make('outcome') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) + ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) + ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), + ]) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(fn (OperationRun $record): string => SystemOperationRunLinks::view($record)), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/System/Pages/Ops/ViewRun.php b/app/Filament/System/Pages/Ops/ViewRun.php new file mode 100644 index 0000000..c84cf36 --- /dev/null +++ b/app/Filament/System/Pages/Ops/ViewRun.php @@ -0,0 +1,54 @@ +user(); + + if (! $user instanceof PlatformUser) { + return false; + } + + return $user->hasCapability(PlatformCapabilities::OPS_VIEW) + && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW); + } + + public function mount(OperationRun $run): void + { + $platformTenant = Tenant::query()->where('external_id', 'platform')->first(); + + $workspaceId = $platformTenant instanceof Tenant ? (int) $platformTenant->workspace_id : null; + + $run->load('tenant'); + + if ($workspaceId === null || (int) $run->workspace_id !== $workspaceId) { + abort(404); + } + + if ((string) $run->type !== FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY) { + abort(404); + } + + $this->run = $run; + } +} diff --git a/app/Http/Middleware/EnsurePlatformCapability.php b/app/Http/Middleware/EnsurePlatformCapability.php index 29efd6f..0547fba 100644 --- a/app/Http/Middleware/EnsurePlatformCapability.php +++ b/app/Http/Middleware/EnsurePlatformCapability.php @@ -29,7 +29,7 @@ public function handle(Request $request, Closure $next, string $capability): Res } if (! Gate::forUser($user)->allows($capability)) { - abort(404); + abort(403); } return $next($request); diff --git a/app/Http/Middleware/UseSystemSessionCookie.php b/app/Http/Middleware/UseSystemSessionCookie.php new file mode 100644 index 0000000..7d328c3 --- /dev/null +++ b/app/Http/Middleware/UseSystemSessionCookie.php @@ -0,0 +1,35 @@ + $this->systemCookieName()]); + + try { + return $next($request); + } finally { + config(['session.cookie' => $originalCookieName]); + } + } + + private function systemCookieName(): string + { + return Str::slug((string) config('app.name', 'laravel')).'-system-session'; + } +} + diff --git a/app/Http/Middleware/UseSystemSessionCookieForLivewireRequests.php b/app/Http/Middleware/UseSystemSessionCookieForLivewireRequests.php new file mode 100644 index 0000000..4bf2a35 --- /dev/null +++ b/app/Http/Middleware/UseSystemSessionCookieForLivewireRequests.php @@ -0,0 +1,103 @@ +shouldUseSystemCookie($request)) { + return $next($request); + } + + $originalCookieName = (string) config('session.cookie'); + + config(['session.cookie' => $this->systemCookieName()]); + + try { + return $next($request); + } finally { + config(['session.cookie' => $originalCookieName]); + } + } + + private function shouldUseSystemCookie(Request $request): bool + { + if ( + ! $request->is('livewire-*/update') + && ! $request->is('livewire-*/upload-file') + && ! $request->is('livewire-*/preview-file/*') + ) { + return false; + } + + if ($this->snapshotIndicatesSystemPanel($request)) { + return true; + } + + $refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? ''; + $refererPath = '/'.ltrim((string) $refererPath, '/'); + + return $refererPath === '/system' || str_starts_with($refererPath, '/system/'); + } + + private function snapshotIndicatesSystemPanel(Request $request): bool + { + if (! $request->is('livewire-*/update')) { + return false; + } + + $components = $request->input('components'); + + if (! is_array($components)) { + return false; + } + + foreach ($components as $componentPayload) { + if (! is_array($componentPayload)) { + continue; + } + + $snapshot = $componentPayload['snapshot'] ?? null; + + if (! is_string($snapshot) || $snapshot === '') { + continue; + } + + $decodedSnapshot = json_decode($snapshot, associative: true); + + if (! is_array($decodedSnapshot)) { + continue; + } + + $path = $decodedSnapshot['memo']['path'] ?? null; + + if (! is_string($path) || $path === '') { + continue; + } + + $path = '/'.ltrim($path, '/'); + + if ($path === '/system' || str_starts_with($path, '/system/')) { + return true; + } + } + + return false; + } + + private function systemCookieName(): string + { + return Str::slug((string) config('app.name', 'laravel')).'-system-session'; + } +} diff --git a/app/Jobs/BackfillFindingLifecycleJob.php b/app/Jobs/BackfillFindingLifecycleJob.php index a9d629d..46be503 100644 --- a/app/Jobs/BackfillFindingLifecycleJob.php +++ b/app/Jobs/BackfillFindingLifecycleJob.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Services\Findings\FindingSlaPolicy; use App\Services\OperationRunService; +use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OpsUx\RunFailureSanitizer; @@ -33,8 +34,11 @@ public function __construct( public readonly ?int $initiatorUserId = null, ) {} - public function handle(OperationRunService $operationRuns, FindingSlaPolicy $slaPolicy): void - { + public function handle( + OperationRunService $operationRuns, + FindingSlaPolicy $slaPolicy, + FindingsLifecycleBackfillRunbookService $runbookService, + ): void { $tenant = Tenant::query()->find($this->tenantId); if (! $tenant instanceof Tenant) { @@ -76,6 +80,8 @@ public function handle(OperationRunService $operationRuns, FindingSlaPolicy $sla ); } + $runbookService->maybeFinalize($operationRun); + return; } @@ -154,6 +160,8 @@ public function handle(OperationRunService $operationRuns, FindingSlaPolicy $sla status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, ); + + $runbookService->maybeFinalize($operationRun); } catch (Throwable $e) { $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); @@ -169,6 +177,8 @@ public function handle(OperationRunService $operationRuns, FindingSlaPolicy $sla ]], ); + $runbookService->maybeFinalize($operationRun); + throw $e; } finally { $lock->release(); diff --git a/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php b/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php new file mode 100644 index 0000000..a4c06c3 --- /dev/null +++ b/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php @@ -0,0 +1,375 @@ +find($this->tenantId); + + if (! $tenant instanceof Tenant) { + return; + } + + if ((int) $tenant->workspace_id !== $this->workspaceId) { + return; + } + + $run = OperationRun::query()->find($this->operationRunId); + + if (! $run instanceof OperationRun) { + return; + } + + if ((int) $run->workspace_id !== $this->workspaceId) { + return; + } + + if ($run->tenant_id !== null) { + return; + } + + if ($run->status === 'queued') { + $operationRunService->updateRun($run, status: 'running'); + } + + $lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900); + + if (! $lock->get()) { + $operationRunService->appendFailures($run, [ + [ + 'code' => 'findings.lifecycle.backfill.lock_busy', + 'message' => sprintf('Tenant %d is already running a findings lifecycle backfill.', $this->tenantId), + ], + ]); + + $operationRunService->incrementSummaryCounts($run, [ + 'failed' => 1, + 'processed' => 1, + ]); + + $operationRunService->maybeCompleteBulkRun($run); + $runbookService->maybeFinalize($run); + + return; + } + + try { + $backfillStartedAt = $run->started_at !== null + ? CarbonImmutable::instance($run->started_at) + : CarbonImmutable::now('UTC'); + + Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->orderBy('id') + ->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRunService, $run, $backfillStartedAt): void { + $updated = 0; + $skipped = 0; + + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + continue; + } + + $originalAttributes = $finding->getAttributes(); + + $this->backfillLifecycleFields($finding, $backfillStartedAt); + $this->backfillLegacyAcknowledgedStatus($finding); + $this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt); + $this->backfillDriftRecurrenceKey($finding); + + if ($finding->isDirty()) { + $finding->save(); + $updated++; + } else { + $finding->setRawAttributes($originalAttributes, sync: true); + $skipped++; + } + } + + if ($updated > 0 || $skipped > 0) { + $operationRunService->incrementSummaryCounts($run, [ + 'updated' => $updated, + 'skipped' => $skipped, + ]); + } + }); + + $consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt); + + if ($consolidatedDuplicates > 0) { + $operationRunService->incrementSummaryCounts($run, [ + 'updated' => $consolidatedDuplicates, + ]); + } + + $operationRunService->incrementSummaryCounts($run, [ + 'processed' => 1, + ]); + + $operationRunService->maybeCompleteBulkRun($run); + $runbookService->maybeFinalize($run); + } catch (Throwable $e) { + $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); + $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); + + $operationRunService->appendFailures($run, [[ + 'code' => 'findings.lifecycle.backfill.failed', + 'reason_code' => $reasonCode, + 'message' => $message !== '' ? $message : sprintf('Tenant %d findings lifecycle backfill failed.', $this->tenantId), + ]]); + + $operationRunService->incrementSummaryCounts($run, [ + 'failed' => 1, + 'processed' => 1, + ]); + + $operationRunService->maybeCompleteBulkRun($run); + $runbookService->maybeFinalize($run); + + throw $e; + } finally { + $lock->release(); + } + } + + private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void + { + $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt; + + if ($finding->first_seen_at === null) { + $finding->first_seen_at = $createdAt; + } + + if ($finding->last_seen_at === null) { + $finding->last_seen_at = $createdAt; + } + + if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { + $lastSeen = CarbonImmutable::instance($finding->last_seen_at); + $firstSeen = CarbonImmutable::instance($finding->first_seen_at); + + if ($lastSeen->lessThan($firstSeen)) { + $finding->last_seen_at = $firstSeen; + } + } + + $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; + + if ($timesSeen < 1) { + $finding->times_seen = 1; + } + } + + private function backfillLegacyAcknowledgedStatus(Finding $finding): void + { + if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) { + return; + } + + $finding->status = Finding::STATUS_TRIAGED; + + if ($finding->triaged_at === null) { + if ($finding->acknowledged_at !== null) { + $finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at); + } elseif ($finding->created_at !== null) { + $finding->triaged_at = CarbonImmutable::instance($finding->created_at); + } + } + } + + private function backfillSlaFields( + Finding $finding, + Tenant $tenant, + FindingSlaPolicy $slaPolicy, + CarbonImmutable $backfillStartedAt, + ): void { + if (! Finding::isOpenStatus((string) $finding->status)) { + return; + } + + if ($finding->sla_days === null) { + $finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt); + } + } + + private function backfillDriftRecurrenceKey(Finding $finding): void + { + if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) { + return; + } + + if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') { + return; + } + + $tenantId = (int) ($finding->tenant_id ?? 0); + $scopeKey = (string) ($finding->scope_key ?? ''); + $subjectType = (string) ($finding->subject_type ?? ''); + $subjectExternalId = (string) ($finding->subject_external_id ?? ''); + + if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') { + return; + } + + $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; + $kind = Arr::get($evidence, 'summary.kind'); + $changeType = Arr::get($evidence, 'change_type'); + + $kind = is_string($kind) ? $kind : ''; + $changeType = is_string($changeType) ? $changeType : ''; + + if ($kind === '') { + return; + } + + $dimension = $this->recurrenceDimension($kind, $changeType); + + $finding->recurrence_key = hash('sha256', sprintf( + 'drift:%d:%s:%s:%s:%s', + $tenantId, + $scopeKey, + $subjectType, + $subjectExternalId, + $dimension, + )); + } + + private function recurrenceDimension(string $kind, string $changeType): string + { + $kind = strtolower(trim($kind)); + $changeType = strtolower(trim($changeType)); + + return match ($kind) { + 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'), + default => $kind, + }; + } + + private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int + { + $duplicateKeys = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->whereNotNull('recurrence_key') + ->select(['recurrence_key']) + ->groupBy('recurrence_key') + ->havingRaw('COUNT(*) > 1') + ->pluck('recurrence_key') + ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') + ->values(); + + if ($duplicateKeys->isEmpty()) { + return 0; + } + + $consolidated = 0; + + foreach ($duplicateKeys as $recurrenceKey) { + if (! is_string($recurrenceKey) || $recurrenceKey === '') { + continue; + } + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('recurrence_key', $recurrenceKey) + ->orderBy('id') + ->get(); + + $canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey); + + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + continue; + } + + if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) { + continue; + } + + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => $backfillStartedAt, + 'resolved_reason' => 'consolidated_duplicate', + 'recurrence_key' => null, + ])->save(); + + $consolidated++; + } + } + + return $consolidated; + } + + /** + * @param Collection $findings + */ + private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding + { + if ($findings->isEmpty()) { + return null; + } + + $openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status)); + + $candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings; + + $alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey); + + if ($alreadyCanonical instanceof Finding) { + return $alreadyCanonical; + } + + /** @var Finding $sorted */ + $sorted = $candidates + ->sortByDesc(function (Finding $finding): array { + $lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0; + $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0; + + return [ + max($lastSeen, $createdAt), + (int) $finding->getKey(), + ]; + }) + ->first(); + + return $sorted; + } +} diff --git a/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php b/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php new file mode 100644 index 0000000..f7e93a9 --- /dev/null +++ b/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php @@ -0,0 +1,95 @@ +find($this->operationRunId); + + if (! $run instanceof OperationRun) { + return; + } + + if ((int) $run->workspace_id !== $this->workspaceId) { + return; + } + + if ($run->tenant_id !== null) { + return; + } + + $tenantIds = $allowedTenantUniverse + ->query() + ->where('workspace_id', $this->workspaceId) + ->orderBy('id') + ->pluck('id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + + $tenantCount = count($tenantIds); + + $operationRunService->updateRun( + $run, + status: OperationRunStatus::Running->value, + outcome: OperationRunOutcome::Pending->value, + summaryCounts: [ + 'tenants' => $tenantCount, + 'total' => $tenantCount, + 'processed' => 0, + 'updated' => 0, + 'skipped' => 0, + 'failed' => 0, + ], + ); + + if ($tenantCount === 0) { + $operationRunService->updateRun( + $run, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + + $runbookService->maybeFinalize($run); + + return; + } + + foreach ($tenantIds as $tenantId) { + if ($tenantId <= 0) { + continue; + } + + BackfillFindingLifecycleTenantIntoWorkspaceRunJob::dispatch( + operationRunId: (int) $run->getKey(), + workspaceId: $this->workspaceId, + tenantId: $tenantId, + ); + } + } +} diff --git a/app/Notifications/OperationRunCompleted.php b/app/Notifications/OperationRunCompleted.php index 807abc9..ee09b56 100644 --- a/app/Notifications/OperationRunCompleted.php +++ b/app/Notifications/OperationRunCompleted.php @@ -3,8 +3,10 @@ namespace App\Notifications; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\Tenant; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\System\SystemOperationRunLinks; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Notification; @@ -25,9 +27,19 @@ public function toDatabase(object $notifiable): array { $tenant = $this->run->tenant; - return OperationUxPresenter::terminalDatabaseNotification( + $notification = OperationUxPresenter::terminalDatabaseNotification( run: $this->run, tenant: $tenant instanceof Tenant ? $tenant : null, - )->getDatabaseMessage(); + ); + + if ($notifiable instanceof PlatformUser) { + $notification->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(SystemOperationRunLinks::view($this->run)), + ]); + } + + return $notification->getDatabaseMessage(); } } diff --git a/app/Providers/Filament/SystemPanelProvider.php b/app/Providers/Filament/SystemPanelProvider.php index 98a42bc..a858878 100644 --- a/app/Providers/Filament/SystemPanelProvider.php +++ b/app/Providers/Filament/SystemPanelProvider.php @@ -4,6 +4,8 @@ use App\Filament\System\Pages\Auth\Login; use App\Filament\System\Pages\Dashboard; +use App\Http\Middleware\UseSystemSessionCookie; +use App\Support\Auth\PlatformCapabilities; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -31,6 +33,7 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => Color::Blue, ]) + ->databaseNotifications() ->renderHook( PanelsRenderHook::BODY_START, fn () => view('filament.system.components.break-glass-banner')->render(), @@ -42,6 +45,7 @@ public function panel(Panel $panel): Panel ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, + UseSystemSessionCookie::class, StartSession::class, AuthenticateSession::class, ShareErrorsFromSession::class, @@ -53,7 +57,8 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, - 'ensure-platform-capability:platform.access_system_panel', - ]); + 'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL, + ]) + ->viteTheme('resources/css/filament/system/theme.css'); } } diff --git a/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php b/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php new file mode 100644 index 0000000..9874237 --- /dev/null +++ b/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php @@ -0,0 +1,609 @@ +computePreflight($scope); + + $this->auditSafely( + action: 'platform.ops.runbooks.preflight', + scope: $scope, + operationRunId: null, + context: [ + 'preflight' => $result, + ], + ); + + return $result; + } + + public function start( + FindingsLifecycleBackfillScope $scope, + ?PlatformUser $initiator, + ?RunbookReason $reason, + string $source, + ): OperationRun { + $source = trim($source); + + if ($source === '') { + throw ValidationException::withMessages([ + 'source' => 'A run source is required.', + ]); + } + + $isBreakGlassActive = $this->breakGlassSession->isActive(); + + if ($scope->isAllTenants() || $isBreakGlassActive) { + if (! $reason instanceof RunbookReason) { + throw ValidationException::withMessages([ + 'reason' => 'A reason is required for this run.', + ]); + } + } + + $preflight = $this->computePreflight($scope); + + if (($preflight['affected_count'] ?? 0) <= 0) { + throw ValidationException::withMessages([ + 'preflight.affected_count' => 'Nothing to do for this scope.', + ]); + } + + $platformTenant = $this->platformTenant(); + $workspace = $platformTenant->workspace; + + if (! $workspace instanceof Workspace) { + throw new \RuntimeException('Platform tenant is missing its workspace.'); + } + + if ($scope->isAllTenants()) { + $lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey()); + $lock = Cache::lock($lockKey, 900); + + if (! $lock->get()) { + throw ValidationException::withMessages([ + 'scope' => 'Another run is already in progress for this scope.', + ]); + } + + try { + return $this->startAllTenants( + workspace: $workspace, + initiator: $initiator, + reason: $reason, + preflight: $preflight, + source: $source, + isBreakGlassActive: $isBreakGlassActive, + ); + } finally { + $lock->release(); + } + } + + return $this->startSingleTenant( + tenantId: (int) $scope->tenantId, + initiator: $initiator, + reason: $reason, + preflight: $preflight, + source: $source, + isBreakGlassActive: $isBreakGlassActive, + ); + } + + public function maybeFinalize(OperationRun $run): void + { + $run->refresh(); + + if ($run->status !== OperationRunStatus::Completed->value) { + return; + } + + $context = is_array($run->context) ? $run->context : []; + + if ((string) data_get($context, 'runbook.key') !== self::RUNBOOK_KEY) { + return; + } + + $lockKey = sprintf('tenantpilot:runbooks:%s:finalize:%d', self::RUNBOOK_KEY, (int) $run->getKey()); + $lock = Cache::lock($lockKey, 86400); + + if (! $lock->get()) { + return; + } + + try { + $this->auditSafely( + action: $run->outcome === OperationRunOutcome::Failed->value + ? 'platform.ops.runbooks.failed' + : 'platform.ops.runbooks.completed', + scope: $this->scopeFromRunContext($context), + operationRunId: (int) $run->getKey(), + context: [ + 'status' => (string) $run->status, + 'outcome' => (string) $run->outcome, + 'is_break_glass' => (bool) data_get($context, 'platform_initiator.is_break_glass', false), + 'reason_code' => data_get($context, 'reason.reason_code'), + 'reason_text' => data_get($context, 'reason.reason_text'), + ], + ); + + $this->notifyInitiatorSafely($run); + + if ($run->outcome === OperationRunOutcome::Failed->value) { + $this->dispatchFailureAlertSafely($run); + } + } finally { + $lock->release(); + } + } + + /** + * @return array{affected_count: int, total_count: int, estimated_tenants?: int|null} + */ + private function computePreflight(FindingsLifecycleBackfillScope $scope): array + { + if ($scope->isSingleTenant()) { + $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail(); + $this->allowedTenantUniverse->ensureAllowed($tenant); + + return $this->computeTenantPreflight($tenant); + } + + $platformTenant = $this->platformTenant(); + $workspaceId = (int) ($platformTenant->workspace_id ?? 0); + + $tenants = $this->allowedTenantUniverse + ->query() + ->where('workspace_id', $workspaceId) + ->orderBy('id') + ->get(); + + $affected = 0; + $total = 0; + + foreach ($tenants as $tenant) { + if (! $tenant instanceof Tenant) { + continue; + } + + $counts = $this->computeTenantPreflight($tenant); + + $affected += (int) ($counts['affected_count'] ?? 0); + $total += (int) ($counts['total_count'] ?? 0); + } + + return [ + 'affected_count' => $affected, + 'total_count' => $total, + 'estimated_tenants' => $tenants->count(), + ]; + } + + /** + * @return array{affected_count: int, total_count: int} + */ + private function computeTenantPreflight(Tenant $tenant): array + { + $query = Finding::query()->where('tenant_id', (int) $tenant->getKey()); + + $total = (int) (clone $query)->count(); + + $affected = 0; + + (clone $query) + ->orderBy('id') + ->chunkById(500, function ($findings) use (&$affected): void { + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + continue; + } + + if ($this->findingNeedsBackfill($finding)) { + $affected++; + } + } + }); + + $affected += $this->countDriftDuplicateConsolidations($tenant); + + return [ + 'affected_count' => $affected, + 'total_count' => $total, + ]; + } + + private function findingNeedsBackfill(Finding $finding): bool + { + if ($finding->first_seen_at === null || $finding->last_seen_at === null) { + return true; + } + + if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { + if ($finding->last_seen_at->lt($finding->first_seen_at)) { + return true; + } + } + + $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; + + if ($timesSeen < 1) { + return true; + } + + if ($finding->status === Finding::STATUS_ACKNOWLEDGED) { + return true; + } + + if (Finding::isOpenStatus((string) $finding->status)) { + if ($finding->sla_days === null || $finding->due_at === null) { + return true; + } + } + + if ($finding->finding_type === Finding::FINDING_TYPE_DRIFT) { + $recurrenceKey = $finding->recurrence_key !== null ? trim((string) $finding->recurrence_key) : ''; + + if ($recurrenceKey === '') { + $scopeKey = trim((string) ($finding->scope_key ?? '')); + $subjectType = trim((string) ($finding->subject_type ?? '')); + $subjectExternalId = trim((string) ($finding->subject_external_id ?? '')); + + if ($scopeKey !== '' && $subjectType !== '' && $subjectExternalId !== '') { + $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; + $kind = data_get($evidence, 'summary.kind'); + + if (is_string($kind) && trim($kind) !== '') { + return true; + } + } + } + } + + return false; + } + + private function countDriftDuplicateConsolidations(Tenant $tenant): int + { + $rows = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->whereNotNull('recurrence_key') + ->select(['recurrence_key', DB::raw('COUNT(*) as count')]) + ->groupBy('recurrence_key') + ->havingRaw('COUNT(*) > 1') + ->get(); + + $duplicates = 0; + + foreach ($rows as $row) { + $count = is_numeric($row->count ?? null) ? (int) $row->count : 0; + + if ($count > 1) { + $duplicates += ($count - 1); + } + } + + return $duplicates; + } + + private function startAllTenants( + Workspace $workspace, + ?PlatformUser $initiator, + ?RunbookReason $reason, + array $preflight, + string $source, + bool $isBreakGlassActive, + ): OperationRun { + $run = $this->operationRunService->ensureWorkspaceRunWithIdentity( + workspace: $workspace, + type: self::RUNBOOK_KEY, + identityInputs: [ + 'runbook' => self::RUNBOOK_KEY, + 'scope' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, + ], + context: $this->buildRunContext( + workspaceId: (int) $workspace->getKey(), + scope: FindingsLifecycleBackfillScope::allTenants(), + initiator: $initiator, + reason: $reason, + preflight: $preflight, + source: $source, + isBreakGlassActive: $isBreakGlassActive, + ), + initiator: null, + ); + + if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { + $run->update(['initiator_name' => $initiator->name ?: $initiator->email]); + $run->refresh(); + } + + $this->auditSafely( + action: 'platform.ops.runbooks.start', + scope: FindingsLifecycleBackfillScope::allTenants(), + operationRunId: (int) $run->getKey(), + context: [ + 'preflight' => $preflight, + 'is_break_glass' => $isBreakGlassActive, + ] + ($reason instanceof RunbookReason ? $reason->toArray() : []), + ); + + if (! $run->wasRecentlyCreated) { + return $run; + } + + $this->operationRunService->dispatchOrFail($run, function () use ($run, $workspace): void { + BackfillFindingLifecycleWorkspaceJob::dispatch( + operationRunId: (int) $run->getKey(), + workspaceId: (int) $workspace->getKey(), + ); + }); + + return $run; + } + + private function startSingleTenant( + int $tenantId, + ?PlatformUser $initiator, + ?RunbookReason $reason, + array $preflight, + string $source, + bool $isBreakGlassActive, + ): OperationRun { + $tenant = Tenant::query()->whereKey($tenantId)->firstOrFail(); + $this->allowedTenantUniverse->ensureAllowed($tenant); + + $run = $this->operationRunService->ensureRunWithIdentity( + tenant: $tenant, + type: self::RUNBOOK_KEY, + identityInputs: [ + 'tenant_id' => (int) $tenant->getKey(), + 'trigger' => 'backfill', + ], + context: $this->buildRunContext( + workspaceId: (int) $tenant->workspace_id, + scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), + initiator: $initiator, + reason: $reason, + preflight: $preflight, + source: $source, + isBreakGlassActive: $isBreakGlassActive, + ), + initiator: null, + ); + + if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { + $run->update(['initiator_name' => $initiator->name ?: $initiator->email]); + $run->refresh(); + } + + $this->auditSafely( + action: 'platform.ops.runbooks.start', + scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), + operationRunId: (int) $run->getKey(), + context: [ + 'preflight' => $preflight, + 'is_break_glass' => $isBreakGlassActive, + ] + ($reason instanceof RunbookReason ? $reason->toArray() : []), + ); + + if (! $run->wasRecentlyCreated) { + return $run; + } + + $this->operationRunService->dispatchOrFail($run, function () use ($tenant): void { + BackfillFindingLifecycleJob::dispatch( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: null, + ); + }); + + return $run; + } + + private function platformTenant(): Tenant + { + $tenant = Tenant::query()->where('external_id', 'platform')->first(); + + if (! $tenant instanceof Tenant) { + throw new \RuntimeException('Platform tenant is missing.'); + } + + return $tenant; + } + + /** + * @return array + */ + private function buildRunContext( + int $workspaceId, + FindingsLifecycleBackfillScope $scope, + ?PlatformUser $initiator, + ?RunbookReason $reason, + array $preflight, + string $source, + bool $isBreakGlassActive, + ): array { + $context = [ + 'workspace_id' => $workspaceId, + 'runbook' => [ + 'key' => self::RUNBOOK_KEY, + 'scope' => $scope->mode, + 'target_tenant_id' => $scope->tenantId, + 'source' => $source, + ], + 'preflight' => [ + 'affected_count' => (int) ($preflight['affected_count'] ?? 0), + 'total_count' => (int) ($preflight['total_count'] ?? 0), + 'estimated_tenants' => $preflight['estimated_tenants'] ?? null, + ], + ]; + + if ($reason instanceof RunbookReason) { + $context['reason'] = $reason->toArray(); + } + + if ($initiator instanceof PlatformUser) { + $context['platform_initiator'] = [ + 'platform_user_id' => (int) $initiator->getKey(), + 'email' => (string) $initiator->email, + 'name' => (string) $initiator->name, + 'is_break_glass' => $isBreakGlassActive, + ]; + } + + return $context; + } + + private function scopeFromRunContext(array $context): FindingsLifecycleBackfillScope + { + $scope = data_get($context, 'runbook.scope'); + $tenantId = data_get($context, 'runbook.target_tenant_id'); + + if ($scope === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT && is_numeric($tenantId)) { + return FindingsLifecycleBackfillScope::singleTenant((int) $tenantId); + } + + return FindingsLifecycleBackfillScope::allTenants(); + } + + /** + * @param array $context + */ + private function auditSafely( + string $action, + FindingsLifecycleBackfillScope $scope, + ?int $operationRunId, + array $context = [], + ): void { + try { + $platformTenant = $this->platformTenant(); + + $actor = auth('platform')->user(); + + $actorId = null; + $actorEmail = null; + $actorName = null; + + if ($actor instanceof PlatformUser) { + $actorId = (int) $actor->getKey(); + $actorEmail = (string) $actor->email; + $actorName = (string) $actor->name; + } + + $metadata = [ + 'runbook_key' => self::RUNBOOK_KEY, + 'scope' => $scope->mode, + 'target_tenant_id' => $scope->tenantId, + 'operation_run_id' => $operationRunId, + 'ip' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]; + + $this->auditLogger->log( + tenant: $platformTenant, + action: $action, + context: [ + 'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null), + ] + $context, + actorId: $actorId, + actorEmail: $actorEmail, + actorName: $actorName, + status: 'success', + resourceType: 'operation_run', + resourceId: $operationRunId !== null ? (string) $operationRunId : null, + ); + } catch (Throwable) { + // Audit is fail-safe (must not crash runbooks). + } + } + + private function notifyInitiatorSafely(OperationRun $run): void + { + try { + $platformUserId = data_get($run->context, 'platform_initiator.platform_user_id'); + + if (! is_numeric($platformUserId)) { + return; + } + + $platformUser = PlatformUser::query()->whereKey((int) $platformUserId)->first(); + + if (! $platformUser instanceof PlatformUser) { + return; + } + + $platformUser->notify(new OperationRunCompleted($run)); + } catch (Throwable) { + // Notifications must not crash the runbook. + } + } + + private function dispatchFailureAlertSafely(OperationRun $run): void + { + try { + $platformTenant = $this->platformTenant(); + $workspace = $platformTenant->workspace; + + if (! $workspace instanceof Workspace) { + return; + } + + $this->alertDispatchService->dispatchEvent($workspace, [ + 'tenant_id' => (int) $platformTenant->getKey(), + 'event_type' => 'operations.run.failed', + 'severity' => 'high', + 'title' => 'Operation failed: Findings lifecycle backfill', + 'body' => 'A findings lifecycle backfill run failed.', + 'metadata' => [ + 'operation_run_id' => (int) $run->getKey(), + 'operation_type' => (string) $run->type, + 'scope' => (string) data_get($run->context, 'runbook.scope', ''), + 'view_run_url' => SystemOperationRunLinks::view($run), + ], + ]); + } catch (Throwable) { + // Alerts must not crash the runbook. + } + } +} diff --git a/app/Services/Runbooks/FindingsLifecycleBackfillScope.php b/app/Services/Runbooks/FindingsLifecycleBackfillScope.php new file mode 100644 index 0000000..3c913f7 --- /dev/null +++ b/app/Services/Runbooks/FindingsLifecycleBackfillScope.php @@ -0,0 +1,81 @@ + 'Select a valid tenant.', + ]); + } + + return new self( + mode: self::MODE_SINGLE_TENANT, + tenantId: $tenantId, + ); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $mode = trim((string) ($data['mode'] ?? '')); + + if ($mode === '' || $mode === self::MODE_ALL_TENANTS) { + return self::allTenants(); + } + + if ($mode !== self::MODE_SINGLE_TENANT) { + throw ValidationException::withMessages([ + 'scope.mode' => 'Select a valid scope mode.', + ]); + } + + $tenantId = $data['tenant_id'] ?? null; + + if (! is_numeric($tenantId)) { + throw ValidationException::withMessages([ + 'scope.tenant_id' => 'Select a tenant.', + ]); + } + + return self::singleTenant((int) $tenantId); + } + + public function isAllTenants(): bool + { + return $this->mode === self::MODE_ALL_TENANTS; + } + + public function isSingleTenant(): bool + { + return $this->mode === self::MODE_SINGLE_TENANT; + } +} diff --git a/app/Services/Runbooks/RunbookReason.php b/app/Services/Runbooks/RunbookReason.php new file mode 100644 index 0000000..d4ca4cc --- /dev/null +++ b/app/Services/Runbooks/RunbookReason.php @@ -0,0 +1,103 @@ +reasonCode); + $reasonText = trim($this->reasonText); + + if (! in_array($reasonCode, self::codes(), true)) { + throw ValidationException::withMessages([ + 'reason.reason_code' => 'Select a valid reason code.', + ]); + } + + if ($reasonText === '') { + throw ValidationException::withMessages([ + 'reason.reason_text' => 'Provide a reason.', + ]); + } + + if (mb_strlen($reasonText) > 500) { + throw ValidationException::withMessages([ + 'reason.reason_text' => 'Reason must be 500 characters or fewer.', + ]); + } + } + + /** + * @param array|null $data + */ + public static function fromNullableArray(?array $data): ?self + { + if (! is_array($data)) { + return null; + } + + $reasonCode = trim((string) ($data['reason_code'] ?? '')); + $reasonText = trim((string) ($data['reason_text'] ?? '')); + + if ($reasonCode === '' && $reasonText === '') { + return null; + } + + return new self( + reasonCode: $reasonCode, + reasonText: $reasonText, + ); + } + + /** + * @return array + */ + public static function codes(): array + { + return [ + self::CODE_DATA_REPAIR, + self::CODE_INCIDENT, + self::CODE_SUPPORT, + self::CODE_SECURITY, + ]; + } + + /** + * @return array + */ + public static function options(): array + { + return [ + self::CODE_DATA_REPAIR => 'Data repair', + self::CODE_INCIDENT => 'Incident', + self::CODE_SUPPORT => 'Support', + self::CODE_SECURITY => 'Security', + ]; + } + + /** + * @return array{reason_code: string, reason_text: string} + */ + public function toArray(): array + { + return [ + 'reason_code' => trim($this->reasonCode), + 'reason_text' => trim($this->reasonText), + ]; + } +} diff --git a/app/Services/System/AllowedTenantUniverse.php b/app/Services/System/AllowedTenantUniverse.php new file mode 100644 index 0000000..2758a6b --- /dev/null +++ b/app/Services/System/AllowedTenantUniverse.php @@ -0,0 +1,37 @@ +where('external_id', '!=', self::PLATFORM_TENANT_EXTERNAL_ID); + } + + public function isAllowed(Tenant $tenant): bool + { + return (string) $tenant->external_id !== self::PLATFORM_TENANT_EXTERNAL_ID; + } + + public function ensureAllowed(Tenant $tenant): void + { + if ($this->isAllowed($tenant)) { + return; + } + + throw ValidationException::withMessages([ + 'tenant_id' => 'This tenant is not eligible for System runbooks.', + ]); + } +} + diff --git a/app/Support/Auth/PlatformCapabilities.php b/app/Support/Auth/PlatformCapabilities.php index f409c1d..3e032c5 100644 --- a/app/Support/Auth/PlatformCapabilities.php +++ b/app/Support/Auth/PlatformCapabilities.php @@ -14,6 +14,14 @@ class PlatformCapabilities public const USE_BREAK_GLASS = 'platform.use_break_glass'; + public const OPS_VIEW = 'platform.ops.view'; + + public const RUNBOOKS_VIEW = 'platform.runbooks.view'; + + public const RUNBOOKS_RUN = 'platform.runbooks.run'; + + public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill'; + /** * @return array */ diff --git a/app/Support/System/SystemOperationRunLinks.php b/app/Support/System/SystemOperationRunLinks.php new file mode 100644 index 0000000..905a238 --- /dev/null +++ b/app/Support/System/SystemOperationRunLinks.php @@ -0,0 +1,24 @@ +getKey() : (int) $run; + + return ViewRun::getUrl(['run' => $runId], panel: 'system'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 25512e2..1919bc3 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,6 +11,10 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->web(prepend: [ + \App\Http\Middleware\UseSystemSessionCookieForLivewireRequests::class, + ]); + $middleware->alias([ 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 02599b8..deaad60 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -6,6 +6,8 @@ 'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15), ], + 'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false), + 'supported_policy_types' => [ [ 'type' => 'deviceConfiguration', diff --git a/database/seeders/PlatformUserSeeder.php b/database/seeders/PlatformUserSeeder.php index 4b9e10c..8d0d02d 100644 --- a/database/seeders/PlatformUserSeeder.php +++ b/database/seeders/PlatformUserSeeder.php @@ -5,6 +5,7 @@ use App\Models\PlatformUser; use App\Models\Tenant; use App\Models\Workspace; +use App\Support\Auth\PlatformCapabilities; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -31,8 +32,12 @@ public function run(): void 'name' => 'Platform Operator', 'password' => Hash::make('password'), 'capabilities' => [ - 'platform.access_system_panel', - 'platform.use_break_glass', + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::USE_BREAK_GLASS, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, ], 'is_active' => true, ], diff --git a/resources/css/filament/system/theme.css b/resources/css/filament/system/theme.css new file mode 100644 index 0000000..35c6a74 --- /dev/null +++ b/resources/css/filament/system/theme.css @@ -0,0 +1,4 @@ +@import '../../../../vendor/filament/filament/resources/css/theme.css'; + +@source '../../../../app/Filament/System/**/*'; +@source '../../../../resources/views/filament/system/**/*.blade.php'; diff --git a/resources/views/filament/system/pages/ops/runbooks.blade.php b/resources/views/filament/system/pages/ops/runbooks.blade.php new file mode 100644 index 0000000..7efce46 --- /dev/null +++ b/resources/views/filament/system/pages/ops/runbooks.blade.php @@ -0,0 +1,129 @@ +@php + $lastRun = $this->lastRun(); + $lastRunStatusSpec = $lastRun + ? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $lastRun->status) + : null; + $lastRunOutcomeSpec = $lastRun && (string) $lastRun->status === 'completed' + ? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $lastRun->outcome) + : null; +@endphp + + +
+ {{-- Operator warning banner --}} + +
+ + +
+

Operator warning

+

+ Runbooks can modify customer data across tenants. Always run preflight first, and ensure you have the correct scope selected. +

+
+
+
+ + {{-- Runbook card: Rebuild Findings Lifecycle --}} + + + Rebuild Findings Lifecycle + + + + Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings. + + + + + {{ $this->scopeLabel() }} + + + +
+ {{-- Last run metadata --}} + @if ($lastRun) +
+ Last run + + + {{ $lastRun->created_at?->diffForHumans() ?? '—' }} + + + @if ($lastRunStatusSpec) + + {{ $lastRunStatusSpec->label }} + + @endif + + @if ($lastRunOutcomeSpec) + + {{ $lastRunOutcomeSpec->label }} + + @endif + + @if ($lastRun->initiator_name) + + by {{ $lastRun->initiator_name }} + + @endif +
+ @endif + + {{-- Preflight results --}} + @if (is_array($this->preflight)) +
+ +
+

Affected

+

+ {{ number_format((int) ($this->preflight['affected_count'] ?? 0)) }} +

+
+
+ + +
+

Total scanned

+

+ {{ number_format((int) ($this->preflight['total_count'] ?? 0)) }} +

+
+
+ + +
+

Estimated tenants

+

+ {{ is_numeric($this->preflight['estimated_tenants'] ?? null) ? number_format((int) $this->preflight['estimated_tenants']) : '—' }} +

+
+
+
+ + @if ((int) ($this->preflight['affected_count'] ?? 0) <= 0) +
+ + Nothing to do for the current scope. +
+ @endif + @else + {{-- Preflight CTA --}} +
+ + Run Preflight to see how many findings would change for the selected scope. +
+ @endif +
+
+
+
+ diff --git a/resources/views/filament/system/pages/ops/runs.blade.php b/resources/views/filament/system/pages/ops/runs.blade.php new file mode 100644 index 0000000..be7f0a3 --- /dev/null +++ b/resources/views/filament/system/pages/ops/runs.blade.php @@ -0,0 +1,4 @@ + + {{ $this->table }} + + diff --git a/resources/views/filament/system/pages/ops/view-run.blade.php b/resources/views/filament/system/pages/ops/view-run.blade.php new file mode 100644 index 0000000..1b49721 --- /dev/null +++ b/resources/views/filament/system/pages/ops/view-run.blade.php @@ -0,0 +1,179 @@ +@php + /** @var \App\Models\OperationRun $run */ + $run = $this->run; + + $scope = (string) data_get($run->context, 'runbook.scope', 'unknown'); + $targetTenantId = data_get($run->context, 'runbook.target_tenant_id'); + $reasonCode = data_get($run->context, 'reason.reason_code'); + $reasonText = data_get($run->context, 'reason.reason_text'); + + $platformInitiator = data_get($run->context, 'platform_initiator', []); + + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::OperationRunStatus, + (string) $run->status, + ); + + $outcomeSpec = (string) $run->status === 'completed' + ? \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::OperationRunOutcome, + (string) $run->outcome, + ) + : null; + + $summaryCounts = $run->summary_counts; + $hasSummary = is_array($summaryCounts) && count($summaryCounts) > 0; +@endphp + + +
+ {{-- Run header --}} + + + Run #{{ (int) $run->getKey() }} + + + + {{ \App\Support\OperationCatalog::label((string) $run->type) }} + + + +
+ + {{ $statusSpec->label }} + + + @if ($outcomeSpec) + + {{ $outcomeSpec->label }} + + @endif +
+
+ +
+ {{-- Key details --}} +
+
+
Scope
+
+ @if ($scope === 'single_tenant') + + Single tenant {{ is_numeric($targetTenantId) ? '#'.(int) $targetTenantId : '' }} + + @elseif ($scope === 'all_tenants') + + All tenants + + @else + {{ $scope }} + @endif +
+
+ +
+
Started
+
+ {{ $run->started_at?->toDayDateTimeString() ?? '—' }} +
+
+ +
+
Completed
+
+ {{ $run->completed_at?->toDayDateTimeString() ?? '—' }} +
+
+ +
+
Initiator
+
+ {{ (string) ($run->initiator_name ?? '—') }} + @if (is_array($platformInitiator) && ($platformInitiator['email'] ?? null)) +
{{ (string) $platformInitiator['email'] }}
+ @endif +
+
+
+ + {{-- Reason --}} + @if (is_string($reasonCode) && is_string($reasonText) && trim($reasonCode) !== '' && trim($reasonText) !== '') +
+ + +
+ Reason +
+ {{ $reasonCode }} + {{ $reasonText }} +
+
+
+ @endif +
+
+ + {{-- Summary counts --}} + @if ($hasSummary) + + + Summary counts + + +
+
+ @foreach ($summaryCounts as $key => $value) +
+

+ {{ \Illuminate\Support\Str::headline((string) $key) }} +

+

+ {{ is_numeric($value) ? number_format((int) $value) : $value }} +

+
+ @endforeach +
+ +
+ + Show raw JSON + +
+ @include('filament.partials.json-viewer', ['value' => $summaryCounts]) +
+
+
+
+ @endif + + {{-- Failures --}} + @if (! empty($run->failure_summary)) + + +
+ + Failures +
+
+ + @include('filament.partials.json-viewer', ['value' => $run->failure_summary]) +
+ @endif + + {{-- Context --}} + + + Context (raw) + + + @include('filament.partials.json-viewer', ['value' => $run->context ?? []]) + +
+
+ diff --git a/specs/113-platform-ops-runbooks/checklists/requirements.md b/specs/113-platform-ops-runbooks/checklists/requirements.md new file mode 100644 index 0000000..38fc934 --- /dev/null +++ b/specs/113-platform-ops-runbooks/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Platform Ops Runbooks (Spec 113) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-26 +**Feature**: specs/113-platform-ops-runbooks/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 + +- Spec intentionally uses concrete routes (`/system/*`, `/admin/*`) and capability identifiers to keep RBAC and plane separation testable. +- Run tracking/audit/lock semantics are expressed as outcomes and constraints, not as specific classes or framework APIs. diff --git a/specs/113-platform-ops-runbooks/contracts/system-ops-runbooks.openapi.yaml b/specs/113-platform-ops-runbooks/contracts/system-ops-runbooks.openapi.yaml new file mode 100644 index 0000000..59dde7a --- /dev/null +++ b/specs/113-platform-ops-runbooks/contracts/system-ops-runbooks.openapi.yaml @@ -0,0 +1,168 @@ +openapi: 3.0.3 +info: + title: System Ops Runbooks (Spec 113) + version: 0.1.0 + description: | + Conceptual contract for the operator control plane under /system. + + Note: The implementation is a Filament (Livewire) UI. These endpoints + represent the stable user-facing routes + the logical actions (preflight/run) + and their request/response shapes. + +servers: + - url: / + +paths: + /system/ops/runbooks: + get: + summary: Runbook catalog page + responses: + '200': + description: HTML page + content: + text/html: + schema: + type: string + + /system/ops/runbooks/findings-lifecycle-backfill/preflight: + post: + summary: Preflight findings lifecycle backfill + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RunbookPreflightRequest' + responses: + '200': + description: Preflight result + content: + application/json: + schema: + $ref: '#/components/schemas/RunbookPreflightResponse' + '403': + description: Platform user lacks capability + '404': + description: Wrong plane / not platform-authenticated + + /system/ops/runbooks/findings-lifecycle-backfill/runs: + post: + summary: Start findings lifecycle backfill + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RunbookStartRequest' + responses: + '201': + description: Run accepted/queued + content: + application/json: + schema: + $ref: '#/components/schemas/RunbookStartResponse' + '409': + description: Already queued / lock busy + '422': + description: Validation error (missing reason, missing typed confirmation, etc.) + + /system/ops/runs: + get: + summary: Operation runs list page + responses: + '200': + description: HTML page + content: + text/html: + schema: + type: string + + /system/ops/runs/{runId}: + get: + summary: Operation run detail page + parameters: + - in: path + name: runId + required: true + schema: + type: integer + responses: + '200': + description: HTML page + content: + text/html: + schema: + type: string + '404': + description: Not found + +components: + schemas: + RunbookScope: + type: object + required: [mode] + properties: + mode: + type: string + enum: [all_tenants, single_tenant] + tenant_id: + type: integer + nullable: true + + RunbookPreflightRequest: + type: object + required: [scope] + properties: + scope: + $ref: '#/components/schemas/RunbookScope' + + RunbookPreflightResponse: + type: object + required: [affected_count, total_count] + properties: + affected_count: + type: integer + minimum: 0 + total_count: + type: integer + minimum: 0 + + RunbookReason: + type: object + required: [reason_code, reason_text] + properties: + reason_code: + type: string + enum: [DATA_REPAIR, INCIDENT, SUPPORT, SECURITY] + reason_text: + type: string + maxLength: 500 + + RunbookStartRequest: + type: object + required: [scope, preflight] + properties: + scope: + $ref: '#/components/schemas/RunbookScope' + preflight: + type: object + required: [affected_count] + properties: + affected_count: + type: integer + minimum: 0 + typed_confirmation: + type: string + nullable: true + description: Required for all_tenants (must equal BACKFILL) + reason: + $ref: '#/components/schemas/RunbookReason' + + RunbookStartResponse: + type: object + required: [operation_run_id, view_run_url] + properties: + operation_run_id: + type: integer + view_run_url: + type: string diff --git a/specs/113-platform-ops-runbooks/data-model.md b/specs/113-platform-ops-runbooks/data-model.md new file mode 100644 index 0000000..d118954 --- /dev/null +++ b/specs/113-platform-ops-runbooks/data-model.md @@ -0,0 +1,99 @@ +# Data Model — Spec 113: Platform Ops Runbooks + +This design describes the data we will read/write to implement the `/system` operator runbooks, grounded in the existing schema. + +## Core persisted entities + +### OperationRun (existing) +- Table: `operation_runs` +- Ownership: + - Workspace-owned (always has `workspace_id`) + - Tenant association is optional (`tenant_id` nullable) to support workspace/canonical runs +- Fields (existing): + - `id` + - `workspace_id` (FK, NOT NULL) + - `tenant_id` (FK, nullable) + - `user_id` (FK to `users`, nullable) + - `initiator_name` (string) + - `type` (string; for this feature: `findings.lifecycle.backfill`) + - `status` (`queued|running|completed`) + - `outcome` (`pending|succeeded|failed|blocked|...`) + - `run_identity_hash` (string; active-run idempotency) + - `summary_counts` (json) + - `failure_summary` (json) + - `context` (json) + - `started_at`, `completed_at` + +#### Summary counts contract +- Must only use keys from `App\Support\OpsUx\OperationSummaryKeys::all()`. +- v1 keys for this runbook: + - `total` (findings scanned) + - `processed` (findings processed) + - `updated` (findings updated + duplicate consolidations) + - `skipped` (findings unchanged) + - `failed` (per-tenant job failures) + - `tenants` (for all-tenants orchestrator: tenants targeted) + +#### Context shape (for this feature) +Store these values in `operation_runs.context`: + +- `runbook`: + - `key`: `findings.lifecycle.backfill` + - `scope`: `all_tenants` | `single_tenant` + - `target_tenant_id`: int|null + - `source`: `system_ui` | `cli` | `deploy_hook` +- `preflight`: + - `affected_count`: int (findings that would change) + - `total_count`: int (findings scanned) + - `estimated_tenants`: int|null (for all tenants) +- `reason` (required for all-tenants and break-glass): + - `reason_code`: `DATA_REPAIR|INCIDENT|SUPPORT|SECURITY` + - `reason_text`: string +- `platform_initiator` (when started from `/system`): + - `platform_user_id`: int + - `email`: string + - `name`: string + - `is_break_glass`: bool + +Notes: +- We intentionally do not store secrets/PII beyond operator email/name already used in auditing. +- `failure_summary` should store sanitized messages + stable reason codes, as already done by `RunFailureSanitizer`. + +#### All-tenants run modeling (v1) +- All-tenants executes as a single **workspace-scoped** run (`tenant_id = null`). +- Implementation fans out to multiple tenant jobs, but they all update the same workspace run via: + - `OperationRunService::incrementSummaryCounts()` + - `OperationRunService::appendFailures()` + - `OperationRunService::maybeCompleteBulkRun()` +- Per-tenant `OperationRun` rows are not required for v1 (avoids parent/child coordination). + +### Audit log (existing infrastructure) +- Existing: `App\Services\Intune\AuditLogger` is already used for System login auditing. +- New audit actions (stable action IDs): + - `platform.ops.runbooks.preflight` + - `platform.ops.runbooks.start` + - `platform.ops.runbooks.completed` + - `platform.ops.runbooks.failed` +- Audit context should include: + - runbook key, scope, affected_count, operation_run_id, platform_user_id/email, ip/user_agent. + +### Alerts (existing infrastructure) +- Use `AlertDispatchService` to create `alert_deliveries` for operators. +- New alert event: + - `event_type`: `operations.run.failed` + - `tenant_id`: platform tenant id (to route via workspace rules) + - `metadata`: run id, run type, scope, view-run URL + +## Derived / non-persisted + +### Runbook catalog +- Implementation as a PHP catalog (no DB table) with: + - key, label, description, capability required, estimated duration (can reuse `OperationCatalog`). + +## State transitions +- `OperationRun.status/outcome` transitions are owned by `OperationRunService`. +- Expected transitions (per run): + - `queued` → `running` → `completed(succeeded|failed|blocked)` +- Locks: + - Tenant runs: already implemented via `Cache::lock('tenantpilot:findings:lifecycle_backfill:tenant:{id}', 900)` + - All-tenants orchestration: add a scope-level lock to prevent duplicate fan-out. diff --git a/specs/113-platform-ops-runbooks/plan.md b/specs/113-platform-ops-runbooks/plan.md new file mode 100644 index 0000000..db8b554 --- /dev/null +++ b/specs/113-platform-ops-runbooks/plan.md @@ -0,0 +1,128 @@ +# Implementation Plan: Platform Ops Runbooks (Spec 113) + +**Branch**: `[113-platform-ops-runbooks]` | **Date**: 2026-02-26 +**Spec**: `specs/113-platform-ops-runbooks/spec.md` +**Input**: Feature specification + design artifacts in `specs/113-platform-ops-runbooks/` + +**Note**: This file is generated/maintained via Spec Kit (`/speckit.plan`). Keep it concise and free of placeholders/duplicates. + +## Summary + +Introduce a `/system` operator control plane for safe backfills/data repair. + +v1 delivers one runbook: **Rebuild Findings Lifecycle**. It must: +- preflight (read-only) +- require explicit confirmation (typed confirmation for all-tenants) + reason capture +- execute as a tracked `OperationRun` with audit events + locking + idempotency +- be **never exposed** in the customer `/admin` plane +- reuse one shared code path across System UI + CLI + deploy hook + +## Technical Context + +- **Language/Runtime**: PHP 8.4, Laravel 12 +- **Admin UI**: Filament v5 (Livewire v4) +- **Storage**: PostgreSQL +- **Testing**: Pest v4 (required for runtime behavior changes) +- **Ops primitives**: `OperationRun` + `OperationRunService` (service owns status/outcome transitions) + +## Non-negotiables (Constitution / Spec constraints) + +- Cross-plane access (`/admin` → `/system`) must be deny-as-not-found (**404**). +- Platform user missing a required capability must be **403**. +- `/system` session cookie must be isolated (distinct cookie name) and applied **before** `StartSession`. +- `/system/login` throttling: **10/min** per **IP + username** key; failed login attempts are audited. +- Any destructive-like action uses Filament `->action(...)` and `->requiresConfirmation()`. +- Ops-UX contract: toast intent-only; progress in run detail; terminal DB notification is `OperationRunCompleted` (initiator-only); no queued/running DB notifications. +- Audit writes are fail-safe (audit failure must not crash the runbook). + +## Scope decisions (v1) + +- **Canonical run viewing** for this spec is the **System panel**: + - Runbooks: `/system/ops/runbooks` + - Runs: `/system/ops/runs` +- **Allowed tenant universe (v1)**: all non-platform tenants present in the database (`tenants.external_id != 'platform'`). The System UI must not allow selecting or targeting the platform tenant. + +## Project Structure + +### Documentation + +```text +specs/113-platform-ops-runbooks/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── tasks.md +└── contracts/ + └── system-ops-runbooks.openapi.yaml +``` + +### Source code (planned touch points) + +```text +app/ +├── Console/Commands/ +│ ├── TenantpilotBackfillFindingLifecycle.php +│ └── TenantpilotRunDeployRunbooks.php +├── Filament/System/Pages/ +│ └── Ops/ +│ ├── Runbooks.php +│ ├── Runs.php +│ └── ViewRun.php +├── Http/Middleware/ +│ ├── EnsureCorrectGuard.php +│ ├── EnsurePlatformCapability.php +│ └── UseSystemSessionCookie.php +├── Jobs/ +│ ├── BackfillFindingLifecycleJob.php +│ ├── BackfillFindingLifecycleWorkspaceJob.php +│ └── BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php +├── Providers/Filament/ +│ └── SystemPanelProvider.php +├── Services/ +│ ├── Alerts/AlertDispatchService.php +│ ├── OperationRunService.php +│ └── Runbooks/FindingsLifecycleBackfillRunbookService.php +└── Support/Auth/ + └── PlatformCapabilities.php + +resources/views/filament/system/pages/ops/ +├── runbooks.blade.php +├── runs.blade.php +└── view-run.blade.php + +tests/Feature/System/ +├── Spec113/ +└── OpsRunbooks/ +``` + +## Implementation Phases + +1) **Foundational security hardening** + - Capability registry additions. + - 404 vs 403 semantics correctness. + - System session cookie isolation. + - System login throttling. + +2) **Runbook core service (single source of truth)** + - `preflight(scope)` + `start(scope, initiator, reason, source)`. + - Audit events (fail-safe). + - Locking + idempotency. + +3) **Execution pipeline** + - All-tenants orchestration as a workspace-scoped bulk run. + - Fan-out tenant jobs update shared run counts and completion. + +4) **System UI surfaces** + - `/system/ops/runbooks` (preflight + confirm + start). + - `/system/ops/runs` list + `/system/ops/runs/{run}` detail. + +5) **Remove customer-plane exposure** + - Remove/disable `/admin` maintenance trigger (feature flag default-off) + regression test. + +6) **Shared entry points** + - Refactor existing CLI command to call the shared service. + - Add deploy hook command that calls the same service. + + - Run focused tests + formatting (`vendor/bin/sail artisan test --compact` + `vendor/bin/sail bin pint --dirty`). diff --git a/specs/113-platform-ops-runbooks/quickstart.md b/specs/113-platform-ops-runbooks/quickstart.md new file mode 100644 index 0000000..3b8892f --- /dev/null +++ b/specs/113-platform-ops-runbooks/quickstart.md @@ -0,0 +1,35 @@ +# Quickstart — Spec 113 (Operator Runbooks) + +## Prereqs +- Docker + Laravel Sail + +## Boot the app +- `vendor/bin/sail up -d` +- `vendor/bin/sail composer install` +- `vendor/bin/sail artisan migrate` + +## Seed a platform operator +- `vendor/bin/sail artisan db:seed --class=PlatformUserSeeder` + +This creates: +- Workspace: `default` +- Tenant: `platform` (used for platform-plane audit context) +- PlatformUser: `operator@tenantpilot.io` / password `password` + +## Open the System panel +- Visit `/system` and login as the platform operator. + +## Run the findings lifecycle backfill +1. Go to `/system/ops/runbooks` +2. Select scope (All tenants or Single tenant) +3. Run preflight +4. Confirm and start +5. Use “View run” to monitor progress + +## CLI (existing) +- Tenant-scoped backfill (existing behavior): + - `vendor/bin/sail artisan tenantpilot:findings:backfill-lifecycle --tenant={tenant_id|external_id}` + +## Notes +- In production-like environments, `/admin` must not expose maintenance/backfill actions. +- If UI changes don’t show up, run `vendor/bin/sail npm run dev`. diff --git a/specs/113-platform-ops-runbooks/research.md b/specs/113-platform-ops-runbooks/research.md new file mode 100644 index 0000000..56e9182 --- /dev/null +++ b/specs/113-platform-ops-runbooks/research.md @@ -0,0 +1,82 @@ +# Research — Spec 113: Platform Ops Runbooks + +This file resolves the design unknowns required to produce an implementation plan that fits the existing TenantAtlas codebase. + +## Decisions + +### 1) Reuse existing backfill pipeline (Command + Job) via a single service +- **Decision**: Extract a single “runbook service” that is called from: + - `/system` runbook UI (preflight + start) + - CLI command (`tenantpilot:findings:backfill-lifecycle`) + - deploy-time hook +- **Rationale**: The repo already contains a correct tenant-scoped implementation: + - Command: `app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` + - Job: `app/Jobs/BackfillFindingLifecycleJob.php` + - It uses `OperationRunService` for lifecycle transitions and idempotency, and a cache lock per tenant. +- **Alternatives considered**: + - Build a new pipeline from scratch → rejected as it duplicates proven behavior and increases drift risk. + +### 2) “All tenants” scope uses a single workspace run updated by many tenant jobs +- **Decision**: Implement All-tenants as: + 1) one **workspace-scoped** `OperationRun` (tenant_id = null) created with `OperationRunService::ensureWorkspaceRunWithIdentity()` + 2) fan-out to many queued tenant jobs that all **increment the same workspace run’s** `summary_counts` and contribute failures + 3) completion via `OperationRunService::maybeCompleteBulkRun()` when `processed >= total` (same pattern as workspace backfills) +- **Rationale**: + - This matches an existing proven pattern in the repo (`tenantpilot:backfill-workspace-ids` + `BackfillWorkspaceIdsJob`). + - It yields a single “View run” target with meaningful progress, without needing parent/child run stitching. + - Tenant isolation remains intact because each job still operates tenant-scoped and holds the existing per-tenant lock. +- **Alternatives considered**: + - Separate per-tenant `OperationRun` records + an umbrella run → rejected for v1 due to added coordination complexity. + +### 3) Workspace scope for /system runbooks (v1) +- **Decision**: v1 targets the **default workspace** (same workspace that owns the `platform` Tenant created by `PlatformUserSeeder`). +- **Rationale**: + - Platform identity currently has no explicit workspace selector in the System panel. + - Existing seeder creates `Workspace(slug=default)` and a `Tenant(external_id=platform)` inside it. +- **Alternatives considered**: + - Multi-workspace operator selection in `/system` → deferred (not in spec, requires new UX + entitlement model). + +### 4) Remove/disable `/admin` maintenance action (FR-001) +- **Decision**: Remove or feature-flag off the existing `/admin` header action “Backfill findings lifecycle” currently present in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`. +- **Rationale**: Spec explicitly forbids customer-plane exposure in production-like environments. +- **Alternatives considered**: + - Keep the action but hide visually → rejected; it still exists as an affordance and is easy to re-enable by accident. + +### 5) Session isolation for `/system` (SR-004) +- **Decision**: Add a System-panel-only middleware that sets a dedicated session cookie name for `/system/*` **before** `StartSession` runs. +- **Rationale**: + - SystemPanelProvider defines its own middleware list; we can insert a middleware at the top. + - Changing `config(['session.cookie' => ...])` per request is sufficient for cookie separation without introducing a new domain. +- **Alternatives considered**: + - Separate subdomain → deferred (explicitly “later”). + +### 6) `/system/login` rate limiting (SR-003) +- **Decision**: Implement rate limiting inside `app/Filament/System/Pages/Auth/Login.php` (override `authenticate()`) using a combined key: `ip + normalized(email)` at 10/min. +- **Rationale**: + - The System login already overrides `authenticate()` to add auditing. + - Implementing rate limiting here keeps the policy tightly scoped to the System login surface. +- **Alternatives considered**: + - Global route middleware throttle → possible, but harder to scope precisely to this Filament auth page. + +### 7) 404 vs 403 semantics for platform capability checks (SR-002) +- **Decision**: Keep cross-plane denial as **404** (existing `EnsureCorrectGuard`), but missing platform capability should return **403**. +- **Rationale**: + - Spec requires: wrong plane → 404; platform lacking capability → 403. + - Current `EnsurePlatformCapability` aborts(404), which conflicts with spec. +- **Alternatives considered**: + - Return 404 for missing platform capability → rejected because it contradicts the agreed spec. + +### 8) Failure notifications (FR-009) +- **Decision**: On run failure, emit: + 1) the canonical terminal DB notification (`OperationRunCompleted`) to the initiating platform operator (in-app) + 2) an Alerts event (Teams / Email) **if alert routing is configured** +- **Rationale**: + - Alerts system already exists (`AlertDispatchService` + queued deliveries). It can route to Teams webhook / Email. + - `OperationRunCompleted` already formats the correct persistent DB notification payload via `OperationUxPresenter`. +- **Alternatives considered**: + - Send Teams webhook directly from job → rejected; bypasses alert rules/cooldowns/quiet hours. + +## Notes for implementation +- Platform capabilities must be defined in the registry (`app/Support/Auth/PlatformCapabilities.php`) and referenced via constants. +- The System panel currently does not call `->databaseNotifications()`. If we want in-app notifications for platform operators, add it. +- `OperationRun.user_id` cannot point to `platform_users`; use `context` fields to record platform initiator metadata. diff --git a/specs/113-platform-ops-runbooks/spec.md b/specs/113-platform-ops-runbooks/spec.md new file mode 100644 index 0000000..ec57978 --- /dev/null +++ b/specs/113-platform-ops-runbooks/spec.md @@ -0,0 +1,190 @@ +# Feature Specification: Platform Ops Runbooks (Operator Control Plane) for Backfills & Data Repair + +**Feature Branch**: `[113-platform-ops-runbooks]` +**Created**: 2026-02-26 +**Status**: Draft +**Input**: Operator control plane runbooks for safe backfills and data repair; deploy-time automatic execution; operator re-run via `/system`; never exposed in customer UI. + +## Clarifications + +### Session 2026-02-26 + +- Q: `/system` Session Isolation Strategy (v1) → A: B — Use a distinct session cookie name/config for `/system`. +- Q: `OperationRun.type` for the findings lifecycle backfill runbook → A: Use `findings.lifecycle.backfill` (consistent with the operation catalog). Runbook trigger is exclusive to `/system`; any `/admin` trigger is removed / feature-flagged off. +- Q: v1 scope selector for running the runbook → A: All tenants (default) + Single tenant (picker). +- Q: Failure notification delivery (v1) → A: Deliver via existing alert destinations (Teams webhook / Email) when configured, and always notify the initiating platform operator in-app. +- Q: `/system/login` rate limiting policy (v1) → A: 10/min per IP + username (combined key). +- Q: Platform “allowed tenant universe” (v1) → A: All non-platform tenants present in the database (`tenants.external_id != 'platform'`). The System UI must not allow selecting or targeting the platform tenant. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view (platform control plane) +- **Primary Routes**: + - `/system/ops/runbooks` (runbook catalog + preflight + run) + - `/system/ops/runs` (run history + run details) + - `/admin/*` (explicitly remove any maintenance/backfill affordances) +- **Data Ownership**: + - Tenant-owned customer data that may be modified by runbooks (e.g., “findings” lifecycle/workflow fields) + - Platform-owned operational records (operation runs, audit events, operator notifications) +- **RBAC**: + - Platform identity only (separate from tenant users) + - Capabilities (v1 minimum): `platform.ops.view`, `platform.runbooks.view`, `platform.runbooks.run` + - Optional granular capability for this runbook: `platform.runbooks.findings.lifecycle_backfill` + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: the runbook defaults to **All tenants** scope; if a tenant is explicitly selected, all counts/changes MUST be limited to that tenant only. +- **Explicit entitlement checks preventing cross-tenant leakage**: a tenant-context user MUST NOT be able to access `/system/*` (deny-as-not-found). Platform operators MUST only be able to target tenants within the platform’s allowed tenant universe. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Operator runs a runbook safely (Priority: P1) + +As a platform operator, I can run a predefined “Rebuild Findings Lifecycle” runbook from `/system` with a clear preflight, explicit confirmation, and an audited, trackable run record. + +**Why this priority**: This is the primary operator workflow that eliminates the need for SSH/manual scripts and reduces risk for customer-impacting data changes. + +**Independent Test**: Fully testable by visiting `/system/ops/runbooks`, running preflight, starting a run, and verifying the run record + audit events exist. + +**Acceptance Scenarios**: + +1. **Given** an authorized platform operator, **When** they open `/system/ops/runbooks`, **Then** they see the runbook catalog including “Rebuild Findings Lifecycle” and an operator warning that actions may modify customer data. +2. **Given** preflight reports `affected_count > 0`, **When** the operator confirms the run, **Then** a new operation run is created and the UI links to “View run”. +3. **Given** preflight reports `affected_count = 0`, **When** the operator attempts to run, **Then** the run action is disabled with a clear “Nothing to do” explanation. +4. **Given** the operator chooses “All tenants”, **When** they confirm, **Then** typed confirmation is required (e.g., entering `BACKFILL`) and a reason is required. + +--- + +### User Story 2 - Customers never see maintenance actions (Priority: P1) + +As a tenant (customer) user, I never see backfill/repair buttons and cannot access the operator control plane. + +**Why this priority**: Exposing maintenance controls in customer UI is an enterprise anti-pattern and undermines product trust. + +**Independent Test**: Fully testable by checking `/admin` UI surfaces and attempting direct navigation to `/system/*` as a tenant user. + +**Acceptance Scenarios**: + +1. **Given** a tenant user session, **When** the user requests `/system/ops/runbooks`, **Then** the response is **404** (deny-as-not-found). +2. **Given** production-like configuration, **When** a tenant user views relevant `/admin` screens, **Then** there is no maintenance/backfill/repair UI. + +--- + +### User Story 3 - Same logic for deploy-time and operator re-run (Priority: P2) + +As a platform operator and as a deploy pipeline, the same runbook logic can be executed consistently so that deploy-time backfills are automatic, and manual re-runs remain available and safe. + +**Why this priority**: A single execution path reduces drift between “what deploy does” and “what operators can re-run”, and improves reliability. + +**Independent Test**: Fully testable by running the operation twice and verifying idempotency and consistent preflight/run results for the same scope. + +**Acceptance Scenarios**: + +1. **Given** the runbook was executed once successfully, **When** it is executed again with the same scope, **Then** the second run reports `updated_count = 0` (idempotent behavior). + +### Edge Cases + +- Lock already held: another run is in-progress for the same scope (All tenants or the same tenant). +- Large dataset: preflight must remain fast enough for operator use; writes must be chunked to avoid long locks. +- Partial failure: some tenants/records fail while others succeed; run outcome and audit still record what happened. +- Missing reason: an All-tenants or break-glass run cannot start without a reason. + +## Requirements *(mandatory)* + +### Constitution alignment notes + +- **No customer-plane maintenance**: Any maintenance/backfill/repair affordance in `/admin` is explicitly out of scope for customer UX. +- **Run observability**: Customer-impacting writes MUST be executed as a tracked operation run with clear status/outcome and operator-facing surfaces. +- **Safety gates**: Preflight → explicit confirmation → audited execution is mandatory. + +### Functional Requirements + +- **FR-001 (Remove Customer Exposure)**: The system MUST not expose any backfill/repair controls in `/admin` in production-like environments. Any legacy `/admin` trigger for the findings lifecycle backfill MUST be removed or disabled (feature-flag off by default). +- **FR-002 (Runbook Catalog)**: The system MUST provide a `/system/ops/runbooks` catalog listing predefined runbooks and their descriptions. +- **FR-003 (Runbook: Rebuild Findings Lifecycle)**: The system MUST provide a runbook that supports: + - Preflight (read-only) showing at least `affected_count`. + - Run (write) that starts a tracked operation run and links to “View run”. + - Scope selection: All tenants (default) and Single tenant (picker). + - Safe confirmation: includes scope + preflight count + “modifies customer data” warning. + - Typed confirmation for All-tenants scope (e.g., `BACKFILL`). + - Run disabled when preflight indicates nothing to do. +- **FR-004 (Single Source of Truth)**: The system MUST implement the runbook logic once and reuse it across: + - deploy-time execution (automation) + - operator UI execution in `/system` + The two paths MUST produce consistent results for the same scope. +- **FR-005 (Operation Run Tracking)**: Each run MUST create a run record including: + - run type identifier: `findings.lifecycle.backfill` + - scope (all tenants vs single tenant) + - actor (platform user, including break-glass marker when applicable) + - outcome/status transitions owned by the service layer + - numeric summary counts using a centralized allow-list of keys + - run context containing: `preflight.affected_count`, `updated_count`, `skipped_count`, `error_count`, and duration +- **FR-006 (Audit Events)**: The system MUST write audit events for start, completion, and failure. Audit writing MUST be fail-safe (audit failures do not crash the operation run). +- **FR-007 (Reasons for Sensitive Runs)**: All-tenants runs and break-glass runs MUST require a reason: + - `reason_code`: one of `DATA_REPAIR`, `INCIDENT`, `SUPPORT`, `SECURITY` + - `reason_text`: free text (max 500 characters) +- **FR-008 (Locking & Idempotency)**: The system MUST prevent concurrent runs for the same scope via locking and MUST be idempotent (a second execution does not re-write already-correct data). +- **FR-009 (Operator Notification on Failure)**: A failed run MUST notify operator targets with run type + scope + a link to “View run”. v1 delivery: + - If alert destinations are configured, deliver via existing destinations (Teams webhook / Email). + - Always notify the initiating platform operator in-app. + Success notifications are optional and SHOULD be off by default. + +### Security & Non-Functional Requirements + +- **SR-001 (Control Plane Isolation)**: `/system` MUST be isolated to platform identity and MUST deny tenant-plane access as **404** (anti-enumeration). +- **SR-002 (404 vs 403 Semantics)**: + - Non-platform users or wrong plane → **404** + - Platform user lacking required capability → **403** +- **SR-003 (Login Throttling)**: The `/system/login` surface MUST be rate limited at **10/min per IP + username (combined key)** and failed login attempts MUST be audited. +- **SR-004 (Session Isolation Strategy)**: v1 MUST isolate control plane sessions from tenant sessions by using a distinct session cookie name/config for `/system` (same domain). A dedicated subdomain with separate cookie scope may be introduced later. +- **SR-005 (Break-glass Visibility & Enforcement)**: Break-glass mode MUST be visually obvious and MUST require a reason; break-glass usage MUST be recorded on the run and in audit. +- **NFR-001 (Performance & Safety)**: + - Preflight MUST be read-only and cheap enough for interactive use. + - Writes MUST be chunked and resilient to partial failures. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Runbooks | `/system/ops/runbooks` | `Preflight` (read-only), `Run…` (write, confirm) | N/A | `View run` (after start) | None | None | N/A | N/A | Yes | `Run…` requires confirmation; typed confirm + reason required for All tenants. | +| Operation Runs | `/system/ops/runs` | N/A | List links to run detail (“View run”) | `View` | None | None | N/A | N/A | Yes | Run detail includes scope, actor, counts, outcome/status. | + +### Key Entities *(include if feature involves data)* + +- **Runbook**: A predefined operator action with preflight and run behavior. +- **Operation Run**: A tracked execution record storing scope, actor, status/outcome, and summary counts. +- **Audit Event**: Immutable security/ops log entries for preflight/run lifecycle. +- **Operator Notification**: A delivery record/target for failure alerts. +- **Finding**: Tenant-owned record whose lifecycle/workflow fields may be backfilled. + +### User Story 4 - Enterprise-grade UX polish for Ops surfaces (Priority: P2) + +As a platform operator, the Ops surfaces should look and feel enterprise-grade with proper visual hierarchy, alert banners, structured card layouts, badge indicators, and metadata so I can quickly assess system state. + +**Why this priority**: Operator trust and efficiency depend on clear, scannable UI. Raw text and flat layouts slow down triage. + +**Acceptance Scenarios**: + +1. **Given** the operator opens `/system/ops/runbooks`, **Then** the operator warning is rendered as a styled alert banner with icon (not plain text). +2. **Given** runbooks are listed, **Then** each runbook is rendered as a structured card with title, description, scope badge, and "Last run" metadata when available. +3. **Given** preflight results are displayed, **Then** stat values use consistent stat-card styling with labels and prominent values. +4. **Given** the operator opens `/system/ops/runs/{id}`, **Then** status and outcome are rendered as colored badges (consistent with the existing BadgeRenderer), and scope is shown as a badge/tag. +5. **Given** the run detail page, **Then** summary counts are rendered as a labeled grid (not only raw JSON). + +### Functional Requirements (UX Polish) + +- **FR-010 (Operator Warning Banner)**: The operator warning on `/system/ops/runbooks` MUST be rendered as a visually distinct alert banner with an `exclamation-triangle` icon, amber/warning coloring, and clear heading — matching project alert patterns. +- **FR-011 (Runbook Card Layout)**: Each runbook MUST be rendered as a card with: title (semibold), description, scope badge (e.g., "All tenants"), and optional "Last run" timestamp + status badge when a previous run exists. +- **FR-012 (Preflight Stat Cards)**: Preflight result values (affected, total scanned, estimated tenants) MUST be rendered in visually prominent stat cards with labeled headers. +- **FR-013 (Run Detail Badges)**: Status and outcome on run detail pages MUST use the existing `BadgeRenderer` / `BadgeCatalog` system for colored badges with icons. +- **FR-014 (Run Detail Summary Grid)**: Summary counts on run detail MUST be rendered as a labeled key-value grid, not a raw JSON dump (JSON viewer remains available as a disclosure fallback). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In production-like environments, customers have **zero** UI affordances to trigger backfills/repairs in `/admin`. +- **SC-002**: A platform operator can start a runbook without SSH and reach "View run" in **≤ 3 user interactions** from `/system/ops/runbooks`. +- **SC-003**: 100% of run attempts result in an operation run record and start/completion/failure audit events (with failure still recorded even if notifications fail). +- **SC-004**: Re-running the same runbook on the same scope after completion results in `updated_count = 0` (idempotency). +- **SC-005**: Operator warning on runbooks page renders as a styled alert banner (not plain text). diff --git a/specs/113-platform-ops-runbooks/tasks.md b/specs/113-platform-ops-runbooks/tasks.md new file mode 100644 index 0000000..7353b0d --- /dev/null +++ b/specs/113-platform-ops-runbooks/tasks.md @@ -0,0 +1,192 @@ +--- + +description: "Task list for Spec 113 implementation" +--- + +# Tasks: Platform Ops Runbooks (Operator Control Plane) + +**Input**: Design documents from `specs/113-platform-ops-runbooks/` +**Prerequisites**: `specs/113-platform-ops-runbooks/plan.md`, `specs/113-platform-ops-runbooks/spec.md`, plus `specs/113-platform-ops-runbooks/research.md`, `specs/113-platform-ops-runbooks/data-model.md`, `specs/113-platform-ops-runbooks/contracts/system-ops-runbooks.openapi.yaml`, `specs/113-platform-ops-runbooks/quickstart.md`. + +**Tests**: REQUIRED (Pest) for all runtime behavior changes. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm touch points and keep spec artifacts aligned. + +- [X] T001 Confirm spec UI Action Matrix is complete in specs/113-platform-ops-runbooks/spec.md +- [X] T002 Confirm System panel provider registration in bootstrap/providers.php (Laravel 11+/12 provider registration) +- [X] T003 [P] Capture current legacy /admin trigger location in app/Filament/Resources/FindingResource/Pages/ListFindings.php ("Backfill findings lifecycle" header action) +- [X] T004 [P] Review existing single-tenant backfill pipeline entry points in app/Console/Commands/TenantpilotBackfillFindingLifecycle.php and app/Jobs/BackfillFindingLifecycleJob.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Security semantics, session isolation, and auth hardening that block all user stories. + +- [X] T005 Add platform runbook capability constants to app/Support/Auth/PlatformCapabilities.php (e.g., platform.ops.view, platform.runbooks.view, platform.runbooks.run, platform.runbooks.findings.lifecycle_backfill) +- [X] T006 Update System panel access control to use capability registry constants in app/Providers/Filament/SystemPanelProvider.php (keep ACCESS_SYSTEM_PANEL gate, add per-page capability checks) +- [X] T007 Change platform capability denial semantics to 403 (member-but-missing-capability) in app/Http/Middleware/EnsurePlatformCapability.php (keep wrong-plane 404 handled by ensure-correct-guard) +- [X] T008 [P] Add SR-002 regression tests for 404 vs 403 semantics in tests/Feature/System/Spec113/AuthorizationSemanticsTest.php (tenant user -> 404 on /system/*, platform user without capability -> 403, platform user with capability -> 200) + +- [X] T009 Define and enforce the “allowed tenant universe” for System runbooks in app/Services/System/AllowedTenantUniverse.php (v1: exclude platform tenant; provide tenant query for pickers and runtime guard) +- [X] T010 [P] Add allowed tenant universe tests in tests/Feature/System/Spec113/AllowedTenantUniverseTest.php (picker excludes platform tenant; attempts to target excluded tenant are rejected; no OperationRun created) + +- [X] T011 Create System session cookie isolation middleware in app/Http/Middleware/UseSystemSessionCookie.php (set dedicated session cookie name before StartSession) +- [X] T012 Wire System session cookie middleware before StartSession in app/Providers/Filament/SystemPanelProvider.php (SR-004) +- [X] T013 [P] Add System session isolation test in tests/Feature/System/Spec113/SystemSessionIsolationTest.php (assert response sets the System session cookie name for /system) + +- [X] T014 Implement /system/login throttling (10/min per IP + username key) in app/Filament/System/Pages/Auth/Login.php (SR-003; use RateLimiter and clear on success) +- [X] T015 [P] Add /system/login throttling tests in tests/Feature/System/Spec113/SystemLoginThrottleTest.php (assert throttled after N failures; ensure failures still emit audit via AuditLogger) + +--- + +## Phase 3: User Story 1 — Operator runs a runbook safely (Priority: P1) 🎯 MVP + +**Goal**: `/system/ops/runbooks` supports preflight + explicit confirmation + reason capture + typed confirmation for all-tenants; starts a tracked `OperationRun` and links to “View run”. + +**Independent Test**: Visit `/system/ops/runbooks`, run preflight, start run, follow “View run” to `/system/ops/runs/{id}`, and confirm audit/run records exist. + +### Tests for User Story 1 + +- [X] T016 [P] [US1] Add runbook preflight tests in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php (single tenant + all tenants preflight returns affected_count) +- [X] T017 [P] [US1] Add runbook start/confirmation tests in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php (typed confirmation + reason required for all_tenants; disabled when affected_count=0) +- [X] T018 [P] [US1] Add break-glass reason enforcement + recording tests in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php (reason required when break-glass active; break-glass marker and reason recorded on run + audit) +- [X] T019 [P] [US1] Add Ops-UX feedback contract test for start surface in tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php (toast intent-only + “View run” link; no DB queued/running notifications) +- [X] T020 [P] [US1] Add audit fail-safe test in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php (audit logger failure does not crash run; run still records failure outcome) + +### Implementation for User Story 1 + +- [X] T021 [US1] Create runbook service app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php with methods preflight(scope) and start(scope, initiator, reason, source) +- [X] T022 [P] [US1] Create runbook scope/value objects in app/Services/Runbooks/FindingsLifecycleBackfillScope.php and app/Services/Runbooks/RunbookReason.php (validate reason_code and reason_text max 500 chars; include break-glass reason requirements) +- [X] T023 [US1] Add audit events for preflight/start/completed/failed using AuditLogger in app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php (action IDs per specs/113-platform-ops-runbooks/data-model.md; must be fail-safe) +- [X] T024 [US1] Record break-glass marker + reason on OperationRun context and audit in app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php (SR-005) + +- [X] T025 [US1] Implement all-tenants orchestration job in app/Jobs/BackfillFindingLifecycleWorkspaceJob.php (create/lock workspace-scoped OperationRun; dispatch tenant fan-out; set summary_counts[tenants/total/processed]) +- [X] T026 [US1] Implement tenant worker job that updates the shared workspace run in app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php (chunk writes; increment summary_counts keys from OperationSummaryKeys::all(); append failures; call maybeCompleteBulkRun()) +- [X] T027 [US1] Ensure scope-level lock prevents concurrent all-tenants runs in app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php (lock key includes workspace + scope) + +- [X] T028 [US1] Enable platform in-app notifications for run completion/failure by turning on database notifications in app/Providers/Filament/SystemPanelProvider.php (ensure terminal notification is OperationRunCompleted, initiator-only) +- [X] T029 [P] [US1] Add System “View run” URL helper in app/Support/System/SystemOperationRunLinks.php and use it for UI + alerts/notifications (avoid admin-plane links) +- [X] T030 [US1] Dispatch Alerts event on failure using app/Services/Alerts/AlertDispatchService.php from app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php (event_type operations.run.failed; include System “View run” URL) + +- [X] T031 [US1] Create System runbooks page class app/Filament/System/Pages/Ops/Runbooks.php (capability-gated; scope selector uses AllowedTenantUniverse; Preflight action; Run action with confirmation + typed confirm + reason) +- [X] T032 [P] [US1] Create System runbooks page view resources/views/filament/system/pages/ops/runbooks.blade.php (operator warning; show preflight results + disable Run when nothing to do) + +- [X] T033 [US1] Create System runs list page class app/Filament/System/Pages/Ops/Runs.php (table listing operation runs for runbook types; default sort newest) +- [X] T034 [P] [US1] Create System runs list view resources/views/filament/system/pages/ops/runs.blade.php (record inspection affordance: clickable row -> run detail) + +- [X] T035 [US1] Create System run detail page class app/Filament/System/Pages/Ops/ViewRun.php (infolist rendering of OperationRun; show scope/actor/counts/failures) +- [X] T036 [P] [US1] Create System run detail view resources/views/filament/system/pages/ops/view-run.blade.php + +--- + +## Phase 4: User Story 2 — Customers never see maintenance actions (Priority: P1) + +**Goal**: No `/admin` maintenance/backfill affordances by default; tenant users cannot access `/system/*` (404). + +**Independent Test**: As a tenant user, `/system/*` returns 404; in `/admin` Findings list there is no backfill action when the feature flag is defaulted off. + +### Tests for User Story 2 + +- [X] T037 [P] [US2] Add regression test asserting /admin Findings list has no backfill action by default in tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php (targets app/Filament/Resources/FindingResource/Pages/ListFindings.php) +- [X] T038 [P] [US2] Add tenant-plane 404 test for /system/ops/runbooks in tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php + +### Implementation for User Story 2 + +- [X] T039 [US2] Remove or feature-flag off the legacy header action in app/Filament/Resources/FindingResource/Pages/ListFindings.php (FR-001; default off in production-like envs) +- [X] T040 [US2] Add a config-backed feature flag defaulting to false in config/tenantpilot.php (e.g., allow_admin_maintenance_actions) and wire it in app/Filament/Resources/FindingResource/Pages/ListFindings.php + +--- + +## Phase 5: User Story 3 — Same logic for deploy-time and operator re-run (Priority: P2) + +**Goal**: One implementation path for preflight/start that is reused by System UI, CLI, and deploy-time automation. + +**Independent Test**: Run the runbook twice with the same scope; second run produces updated_count=0; deploy-time entry point calls the same service. + +### Tests for User Story 3 + +- [X] T041 [P] [US3] Add idempotency test in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php (second run updated=0 and/or preflight affected_count=0) +- [X] T042 [P] [US3] Add deploy-time entry point test in tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php (command delegates to FindingsLifecycleBackfillRunbookService) + +### Implementation for User Story 3 + +- [X] T043 [US3] Refactor CLI command to call shared runbook service in app/Console/Commands/TenantpilotBackfillFindingLifecycle.php (single-tenant scope, source=cli) +- [X] T044 [US3] Add deploy-time runbooks command in app/Console/Commands/TenantpilotRunDeployRunbooks.php (source=deploy_hook; initiator null; uses FindingsLifecycleBackfillRunbookService) +- [X] T045 [US3] Ensure System UI uses the same runbook service start() call path in app/Filament/System/Pages/Ops/Runbooks.php (source=system_ui) +- [X] T046 [US3] Ensure initiator-null runs do not emit terminal DB notification in app/Services/OperationRunService.php (system-run behavior; audit/alerts still apply) + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T047 [P] Run new Spec 113 tests via vendor/bin/sail artisan test --compact tests/Feature/System/Spec113/ (ensure all new tests pass) +- [X] T048 [P] Run Ops Runbooks tests via vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/ (ensure US1/US3 tests pass) +- [X] T049 [P] Run formatting on touched files via vendor/bin/sail bin pint --dirty --format agent (targets app/Http/Middleware/, app/Filament/System/Pages/, app/Services/Runbooks/, tests/Feature/System/) + +--- + +## Phase 7: UX Polish — Enterprise-grade Ops surfaces (User Story 4) + +**Purpose**: Elevate operator-facing views from functional MVP to enterprise-grade UX with proper visual hierarchy, alert banners, card layouts, badge indicators, and metadata. + +- [X] T050 [US4] Upgrade operator warning from plain text to styled alert banner with icon in resources/views/filament/system/pages/ops/runbooks.blade.php (FR-010) +- [X] T051 [US4] Restructure runbook entry as a card with title, description, scope badge, and "Last run" metadata in resources/views/filament/system/pages/ops/runbooks.blade.php + app/Filament/System/Pages/Ops/Runbooks.php (FR-011) +- [X] T052 [US4] Upgrade preflight stat values to prominent stat-card styling in resources/views/filament/system/pages/ops/runbooks.blade.php (FR-012) +- [X] T053 [US4] Render status/outcome as BadgeRenderer badges on run detail page in resources/views/filament/system/pages/ops/view-run.blade.php (FR-013) +- [X] T054 [US4] Render summary_counts as labeled key-value grid with JSON fallback on run detail in resources/views/filament/system/pages/ops/view-run.blade.php (FR-014) +- [X] T055 [US4] "Recovery" nav group with "Repair workspace owners" already exists (pre-existing; no change needed) +- [X] T056 [P] Run formatting via vendor/bin/sail bin pint --dirty --format agent +- [X] T057 [P] Run existing Spec 113 tests to verify no regressions (16 passed, 141 assertions) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: no dependencies +- **Foundational (Phase 2)**: depends on Setup; BLOCKS all story work +- **US1 (Phase 3)**: depends on Foundational +- **US2 (Phase 4)**: depends on Foundational +- **US3 (Phase 5)**: depends on US1 shared runbook service (T021) + Foundational +- **Polish (Phase 6)**: depends on desired stories being complete + +### User Story Dependencies + +- **US1 (P1)**: foundational security + session isolation + login throttle must be in place first +- **US2 (P1)**: can be implemented after Foundational; independent of US1 UI +- **US3 (P2)**: depends on the shared runbook service created in US1 + +--- + +## Parallel Execution Examples + +### US1 parallelizable tasks + +- T016, T017, T018, T019, T020 can be drafted in parallel (tests in separate files under tests/Feature/System/OpsRunbooks/) +- T031/T032, T033/T034, and T035/T036 can be built in parallel (separate System page classes/views) +- T025 and T026 can be built in parallel once the service contract (T021) is agreed + +### US2 parallelizable tasks + +- T037 and T038 can run in parallel (tests) +- T039 and T040 can run in parallel if T040 lands first (feature flag), otherwise keep sequential + +### US3 parallelizable tasks + +- T041 and T042 can run in parallel (tests) +- T043 and T044 can be implemented in parallel once T021 exists + +--- + +## Implementation Strategy (MVP First) + +1) Complete Phase 2 (security semantics + session isolation + login throttle) +2) Deliver US1 (System runbooks page + OperationRun tracking + System runs detail) +3) Deliver US2 (remove/disable /admin maintenance UI) +4) Deliver US3 (shared logic reused by CLI + deploy-time automation) diff --git a/tests/Feature/Auth/SystemPanelAuthTest.php b/tests/Feature/Auth/SystemPanelAuthTest.php index 155220a..754d3d6 100644 --- a/tests/Feature/Auth/SystemPanelAuthTest.php +++ b/tests/Feature/Auth/SystemPanelAuthTest.php @@ -120,7 +120,7 @@ expect($audit->metadata['reason'] ?? null)->toBe('inactive'); }); -it('denies system panel access (404) for platform users without the required capability', function () { +it('denies system panel access (403) for platform users without the required capability', function () { Tenant::factory()->create([ 'tenant_id' => null, 'external_id' => 'platform', @@ -139,7 +139,7 @@ expect(auth('platform')->check())->toBeTrue(); - $this->get('/system')->assertNotFound(); + $this->get('/system')->assertForbidden(); }); it('allows system panel access for platform users with the required capability', function () { diff --git a/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php b/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php new file mode 100644 index 0000000..f6cc1ab --- /dev/null +++ b/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php @@ -0,0 +1,31 @@ +create(); + + $this->mock(FindingsLifecycleBackfillRunbookService::class, function ($mock) use ($run): void { + $mock->shouldReceive('start') + ->once() + ->withArgs(function ($scope, $initiator, $reason, $source): bool { + return $scope instanceof FindingsLifecycleBackfillScope + && $scope->isAllTenants() + && $initiator === null + && $reason instanceof RunbookReason + && $source === 'deploy_hook'; + }) + ->andReturn($run); + }); + + $this->artisan('tenantpilot:run-deploy-runbooks') + ->assertExitCode(0); +}); diff --git a/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php b/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php new file mode 100644 index 0000000..5309f92 --- /dev/null +++ b/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php @@ -0,0 +1,20 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListFindings::class) + ->assertActionDoesNotExist('backfill_lifecycle'); +}); diff --git a/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php new file mode 100644 index 0000000..7b5f87e --- /dev/null +++ b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php @@ -0,0 +1,87 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); +}); + +it('does not crash when audit logging fails and still finalizes a failed run', function () { + $this->mock(AuditLogger::class, function ($mock): void { + $mock->shouldReceive('log')->andThrow(new RuntimeException('audit unavailable')); + }); + + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => null, + ]); + + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + $runbook = app(FindingsLifecycleBackfillRunbookService::class); + + $run = $runbook->start( + scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), + initiator: $user, + reason: null, + source: 'system_ui', + ); + + $runs = app(OperationRunService::class); + + $runs->updateRun( + $run, + status: 'completed', + outcome: 'failed', + failures: [ + [ + 'code' => 'test.failed', + 'message' => 'Forced failure for audit fail-safe test.', + ], + ], + ); + + $runbook->maybeFinalize($run); + + $run->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('failed'); +}); diff --git a/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php new file mode 100644 index 0000000..8d2dc27 --- /dev/null +++ b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php @@ -0,0 +1,111 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); + + config()->set('tenantpilot.break_glass.enabled', true); + config()->set('tenantpilot.break_glass.ttl_minutes', 15); +}); + +it('requires a reason when break-glass is active and records break-glass on the run + audit', function () { + Queue::fake(); + + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $customerTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $customerTenant->getKey(), + 'due_at' => null, + ]); + + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, + PlatformCapabilities::USE_BREAK_GLASS, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + Livewire::test(Dashboard::class) + ->callAction('enter_break_glass', data: [ + 'reason' => 'Recovery test', + ]) + ->assertHasNoActionErrors(); + + Livewire::test(Runbooks::class) + ->callAction('preflight', data: [ + 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, + 'tenant_id' => (int) $customerTenant->getKey(), + ]) + ->assertSet('preflight.affected_count', 1) + ->callAction('run', data: []) + ->assertHasActionErrors(['reason_code', 'reason_text']); + + Livewire::test(Runbooks::class) + ->callAction('preflight', data: [ + 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, + 'tenant_id' => (int) $customerTenant->getKey(), + ]) + ->assertSet('preflight.affected_count', 1) + ->callAction('run', data: [ + 'reason_code' => 'INCIDENT', + 'reason_text' => 'Break-glass backfill required', + ]) + ->assertHasNoActionErrors() + ->assertNotified(); + + $run = OperationRun::query() + ->where('type', 'findings.lifecycle.backfill') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect((int) $run?->tenant_id)->toBe((int) $customerTenant->getKey()); + expect(data_get($run?->context, 'platform_initiator.is_break_glass'))->toBeTrue(); + expect(data_get($run?->context, 'reason.reason_code'))->toBe('INCIDENT'); + expect(data_get($run?->context, 'reason.reason_text'))->toBe('Break-glass backfill required'); + + $audit = AuditLog::query() + ->where('action', 'platform.ops.runbooks.start') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit?->metadata['is_break_glass'] ?? null)->toBe(true); + expect($audit?->metadata['reason_code'] ?? null)->toBe('INCIDENT'); + expect($audit?->metadata['reason_text'] ?? null)->toBe('Break-glass backfill required'); +}); diff --git a/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php new file mode 100644 index 0000000..645a695 --- /dev/null +++ b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php @@ -0,0 +1,78 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); +}); + +it('is idempotent: after a successful run, preflight reports nothing to do', function () { + Queue::fake(); + + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => null, + ]); + + $runbook = app(FindingsLifecycleBackfillRunbookService::class); + + $initial = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); + + expect($initial['affected_count'])->toBe(1); + + $runbook->start( + scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), + initiator: null, + reason: null, + source: 'system_ui', + ); + + $job = new BackfillFindingLifecycleJob( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: null, + ); + + $job->handle( + app(OperationRunService::class), + app(\App\Services\Findings\FindingSlaPolicy::class), + $runbook, + ); + + $after = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); + + expect($after['affected_count'])->toBe(0); + + expect(fn () => $runbook->start( + scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), + initiator: null, + reason: null, + source: 'system_ui', + ))->toThrow(ValidationException::class); +}); diff --git a/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php new file mode 100644 index 0000000..5f4308d --- /dev/null +++ b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php @@ -0,0 +1,84 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); +}); + +it('computes single-tenant preflight counts', function () { + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => null, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $service = app(FindingsLifecycleBackfillRunbookService::class); + + $result = $service->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); + + expect($result['total_count'])->toBe(2); + expect($result['affected_count'])->toBe(1); +}); + +it('computes all-tenants preflight counts scoped to the platform workspace', function () { + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + $otherTenant = Tenant::factory()->create(); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'due_at' => null, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'sla_days' => null, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $otherTenant->getKey(), + 'due_at' => null, + ]); + + $service = app(FindingsLifecycleBackfillRunbookService::class); + + $result = $service->preflight(FindingsLifecycleBackfillScope::allTenants()); + + expect($result['estimated_tenants'])->toBe(2); + expect($result['total_count'])->toBe(2); + expect($result['affected_count'])->toBe(2); +}); diff --git a/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php new file mode 100644 index 0000000..886a54a --- /dev/null +++ b/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php @@ -0,0 +1,113 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); +}); + +it('disables running when preflight indicates nothing to do', function () { + Queue::fake(); + + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + Livewire::test(Runbooks::class) + ->callAction('preflight', data: [ + 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, + ]) + ->assertSet('preflight.affected_count', 0) + ->assertActionDisabled('run'); +}); + +it('requires typed confirmation and a reason for all-tenants runs', function () { + Queue::fake(); + + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => null, + ]); + + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + Livewire::test(Runbooks::class) + ->callAction('preflight', data: [ + 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, + ]) + ->assertSet('preflight.affected_count', 1) + ->callAction('run', data: []) + ->assertHasActionErrors([ + 'typed_confirmation', + 'reason_code', + 'reason_text', + ]); + + Livewire::test(Runbooks::class) + ->callAction('preflight', data: [ + 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, + ]) + ->assertSet('preflight.affected_count', 1) + ->callAction('run', data: [ + 'typed_confirmation' => 'backfill', + 'reason_code' => 'DATA_REPAIR', + 'reason_text' => 'Test run', + ]) + ->assertHasActionErrors(['typed_confirmation']); +}); diff --git a/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php b/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php new file mode 100644 index 0000000..76d79ab --- /dev/null +++ b/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php @@ -0,0 +1,89 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); +}); + +it('uses an intent-only toast with a working view-run link and does not emit queued database notifications', function () { + Queue::fake(); + NotificationFacade::fake(); + + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => null, + ]); + + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + Livewire::test(Runbooks::class) + ->callAction('preflight', data: [ + 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, + ]) + ->assertSet('preflight.affected_count', 1) + ->callAction('run', data: [ + 'typed_confirmation' => 'BACKFILL', + 'reason_code' => 'DATA_REPAIR', + 'reason_text' => 'Operator test', + ]) + ->assertHasNoActionErrors() + ->assertNotified('Findings lifecycle backfill queued'); + + NotificationFacade::assertNothingSent(); + expect(DatabaseNotification::query()->count())->toBe(0); + + $run = OperationRun::query() + ->where('type', 'findings.lifecycle.backfill') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + + $viewUrl = SystemOperationRunLinks::view($run); + + $this->get($viewUrl) + ->assertSuccessful() + ->assertSee('Run #'.(int) $run?->getKey()); +}); diff --git a/tests/Feature/System/Spec113/AllowedTenantUniverseTest.php b/tests/Feature/System/Spec113/AllowedTenantUniverseTest.php new file mode 100644 index 0000000..95be7cb --- /dev/null +++ b/tests/Feature/System/Spec113/AllowedTenantUniverseTest.php @@ -0,0 +1,47 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); + + $customerTenant = Tenant::factory()->create([ + 'external_id' => 'tenant-1', + 'name' => 'Tenant One', + ]); + + $universe = app(AllowedTenantUniverse::class); + + $ids = $universe->query()->orderBy('id')->pluck('id')->all(); + + expect($ids)->toContain((int) $customerTenant->getKey()); + expect($ids)->not->toContain((int) $platformTenant->getKey()); +}); + +it('rejects attempts to target the platform tenant', function () { + $platformTenant = Tenant::factory()->create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); + + $universe = app(AllowedTenantUniverse::class); + + expect(fn () => $universe->ensureAllowed($platformTenant)) + ->toThrow(ValidationException::class); + + expect(OperationRun::query()->count())->toBe(0); +}); + diff --git a/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php b/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php new file mode 100644 index 0000000..c8ee510 --- /dev/null +++ b/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php @@ -0,0 +1,43 @@ +create(); + + $this->actingAs($user)->get('/system/login')->assertNotFound(); + + // Filament may switch the active guard within the test process, + // so ensure the tenant session is set for each request we assert. + $this->actingAs($user)->get('/system')->assertNotFound(); +}); + +it('returns 403 when a platform user lacks the required capability', function () { + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [], + 'is_active' => true, + ]); + + $this->actingAs($platformUser, 'platform') + ->get('/system') + ->assertForbidden(); +}); + +it('returns 200 when a platform user has the required capability', function () { + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL], + 'is_active' => true, + ]); + + $this->actingAs($platformUser, 'platform') + ->get('/system') + ->assertSuccessful(); +}); + diff --git a/tests/Feature/System/Spec113/SystemLoginThrottleTest.php b/tests/Feature/System/Spec113/SystemLoginThrottleTest.php new file mode 100644 index 0000000..3516abb --- /dev/null +++ b/tests/Feature/System/Spec113/SystemLoginThrottleTest.php @@ -0,0 +1,67 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); + + $user = PlatformUser::factory()->create([ + 'email' => 'operator@tenantpilot.io', + 'is_active' => true, + ]); + + for ($i = 0; $i < 10; $i++) { + Filament::setCurrentPanel('system'); + Filament::bootCurrentPanel(); + + Livewire::test(Login::class) + ->set('data.email', $user->email) + ->set('data.password', 'wrong-password') + ->call('authenticate') + ->assertHasErrors(['data.email']); + } + + Filament::setCurrentPanel('system'); + Filament::bootCurrentPanel(); + + Livewire::test(Login::class) + ->set('data.email', $user->email) + ->set('data.password', 'wrong-password') + ->call('authenticate') + ->assertHasErrors(['data.email']); + + $auditCount = AuditLog::query() + ->where('tenant_id', $platformTenant->getKey()) + ->where('action', 'platform.auth.login') + ->count(); + + expect($auditCount)->toBe(11); + + $latestAudit = AuditLog::query() + ->where('tenant_id', $platformTenant->getKey()) + ->where('action', 'platform.auth.login') + ->latest('id') + ->first(); + + expect($latestAudit)->not->toBeNull(); + expect($latestAudit->metadata['reason'] ?? null)->toBe('throttled'); +}); diff --git a/tests/Feature/System/Spec113/SystemSessionIsolationTest.php b/tests/Feature/System/Spec113/SystemSessionIsolationTest.php new file mode 100644 index 0000000..d572f79 --- /dev/null +++ b/tests/Feature/System/Spec113/SystemSessionIsolationTest.php @@ -0,0 +1,17 @@ +get('/system/login') + ->assertSuccessful() + ->assertCookie($expectedCookieName); +}); + diff --git a/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php b/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php new file mode 100644 index 0000000..601abde --- /dev/null +++ b/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php @@ -0,0 +1,16 @@ +create(); + + $this->actingAs($user) + ->get('/system/ops/runbooks') + ->assertNotFound(); +}); diff --git a/vite.config.js b/vite.config.js index d9a581c..bd3d929 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,6 +8,7 @@ export default defineConfig({ input: [ 'resources/css/app.css', 'resources/css/filament/admin/theme.css', + 'resources/css/filament/system/theme.css', 'resources/js/app.js', ], refresh: true,