feat(113): Platform Ops Runbooks — UX Polish (Filament-native, system theme, live scope) (#137)
## 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 `<x-filament::section>` 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 <ahmed.darrazi@live.de>
Reviewed-on: #137
This commit is contained in:
parent
32c3a64147
commit
200498fa8e
@ -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;
|
||||
|
||||
51
app/Console/Commands/TenantpilotRunDeployRunbooks.php
Normal file
51
app/Console/Commands/TenantpilotRunDeployRunbooks.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantpilotRunDeployRunbooks extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:run-deploy-runbooks';
|
||||
|
||||
protected $description = 'Run deploy-time runbooks idempotently.';
|
||||
|
||||
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||
{
|
||||
try {
|
||||
$runbookService->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
272
app/Filament/System/Pages/Ops/Runbooks.php
Normal file
272
app/Filament/System/Pages/Ops/Runbooks.php
Normal file
@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class Runbooks extends Page
|
||||
{
|
||||
protected static ?string $navigationLabel = 'Runbooks';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||
|
||||
protected static ?string $slug = 'ops/runbooks';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.runbooks';
|
||||
|
||||
public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
/**
|
||||
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
||||
*/
|
||||
public ?array $preflight = null;
|
||||
|
||||
public function scopeLabel(): string
|
||||
{
|
||||
if ($this->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<Action>
|
||||
*/
|
||||
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<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
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<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
104
app/Filament/System/Pages/Ops/Runs.php
Normal file
104
app/Filament/System/Pages/Ops/Runs.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Runs extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Runs';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||
|
||||
protected static ?string $slug = 'ops/runs';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.runs';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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([]);
|
||||
}
|
||||
}
|
||||
54
app/Filament/System/Pages/Ops/ViewRun.php
Normal file
54
app/Filament/System/Pages/Ops/ViewRun.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class ViewRun extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'ops/runs/{run}';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.view-run';
|
||||
|
||||
public OperationRun $run;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
35
app/Http/Middleware/UseSystemSessionCookie.php
Normal file
35
app/Http/Middleware/UseSystemSessionCookie.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UseSystemSessionCookie
|
||||
{
|
||||
/**
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$originalCookieName = (string) config('session.cookie');
|
||||
|
||||
config(['session.cookie' => $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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UseSystemSessionCookieForLivewireRequests
|
||||
{
|
||||
/**
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $this->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';
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
375
app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php
Normal file
375
app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php
Normal file
@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class BackfillFindingLifecycleTenantIntoWorkspaceRunJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $operationRunId,
|
||||
public readonly int $workspaceId,
|
||||
public readonly int $tenantId,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$tenant = Tenant::query()->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<int, Finding> $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;
|
||||
}
|
||||
}
|
||||
95
app/Jobs/BackfillFindingLifecycleWorkspaceJob.php
Normal file
95
app/Jobs/BackfillFindingLifecycleWorkspaceJob.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BackfillFindingLifecycleWorkspaceJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $operationRunId,
|
||||
public readonly int $workspaceId,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
AllowedTenantUniverse $allowedTenantUniverse,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$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;
|
||||
}
|
||||
|
||||
$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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,609 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Runbooks;
|
||||
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Jobs\BackfillFindingLifecycleWorkspaceJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class FindingsLifecycleBackfillRunbookService
|
||||
{
|
||||
public const string RUNBOOK_KEY = 'findings.lifecycle.backfill';
|
||||
|
||||
public function __construct(
|
||||
private readonly AllowedTenantUniverse $allowedTenantUniverse,
|
||||
private readonly BreakGlassSession $breakGlassSession,
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly AlertDispatchService $alertDispatchService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{affected_count: int, total_count: int, estimated_tenants?: int|null}
|
||||
*/
|
||||
public function preflight(FindingsLifecycleBackfillScope $scope): array
|
||||
{
|
||||
$result = $this->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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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.
|
||||
}
|
||||
}
|
||||
}
|
||||
81
app/Services/Runbooks/FindingsLifecycleBackfillScope.php
Normal file
81
app/Services/Runbooks/FindingsLifecycleBackfillScope.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Runbooks;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final readonly class FindingsLifecycleBackfillScope
|
||||
{
|
||||
public const string MODE_ALL_TENANTS = 'all_tenants';
|
||||
|
||||
public const string MODE_SINGLE_TENANT = 'single_tenant';
|
||||
|
||||
private function __construct(
|
||||
public string $mode,
|
||||
public ?int $tenantId,
|
||||
) {}
|
||||
|
||||
public static function allTenants(): self
|
||||
{
|
||||
return new self(
|
||||
mode: self::MODE_ALL_TENANTS,
|
||||
tenantId: null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function singleTenant(int $tenantId): self
|
||||
{
|
||||
$tenantId = (int) $tenantId;
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope.tenant_id' => 'Select a valid tenant.',
|
||||
]);
|
||||
}
|
||||
|
||||
return new self(
|
||||
mode: self::MODE_SINGLE_TENANT,
|
||||
tenantId: $tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
103
app/Services/Runbooks/RunbookReason.php
Normal file
103
app/Services/Runbooks/RunbookReason.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Runbooks;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final readonly class RunbookReason
|
||||
{
|
||||
public const string CODE_DATA_REPAIR = 'DATA_REPAIR';
|
||||
|
||||
public const string CODE_INCIDENT = 'INCIDENT';
|
||||
|
||||
public const string CODE_SUPPORT = 'SUPPORT';
|
||||
|
||||
public const string CODE_SECURITY = 'SECURITY';
|
||||
|
||||
public function __construct(
|
||||
public string $reasonCode,
|
||||
public string $reasonText,
|
||||
) {
|
||||
$reasonCode = trim($this->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<string, mixed>|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<int, string>
|
||||
*/
|
||||
public static function codes(): array
|
||||
{
|
||||
return [
|
||||
self::CODE_DATA_REPAIR,
|
||||
self::CODE_INCIDENT,
|
||||
self::CODE_SUPPORT,
|
||||
self::CODE_SECURITY,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Services/System/AllowedTenantUniverse.php
Normal file
37
app/Services/System/AllowedTenantUniverse.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AllowedTenantUniverse
|
||||
{
|
||||
public const PLATFORM_TENANT_EXTERNAL_ID = 'platform';
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return Tenant::query()
|
||||
->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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string>
|
||||
*/
|
||||
|
||||
24
app/Support/System/SystemOperationRunLinks.php
Normal file
24
app/Support/System/SystemOperationRunLinks.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\System;
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runs;
|
||||
use App\Filament\System\Pages\Ops\ViewRun;
|
||||
use App\Models\OperationRun;
|
||||
|
||||
final class SystemOperationRunLinks
|
||||
{
|
||||
public static function index(): string
|
||||
{
|
||||
return Runs::getUrl(panel: 'system');
|
||||
}
|
||||
|
||||
public static function view(OperationRun|int $run): string
|
||||
{
|
||||
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
|
||||
|
||||
return ViewRun::getUrl(['run' => $runId], panel: 'system');
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
4
resources/css/filament/system/theme.css
Normal file
4
resources/css/filament/system/theme.css
Normal file
@ -0,0 +1,4 @@
|
||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||
|
||||
@source '../../../../app/Filament/System/**/*';
|
||||
@source '../../../../resources/views/filament/system/**/*.blade.php';
|
||||
129
resources/views/filament/system/pages/ops/runbooks.blade.php
Normal file
129
resources/views/filament/system/pages/ops/runbooks.blade.php
Normal file
@ -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
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
{{-- Operator warning banner --}}
|
||||
<x-filament::section>
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-o-exclamation-triangle class="h-6 w-6 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Runbooks can modify customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Runbook card: Rebuild Findings Lifecycle --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Rebuild Findings Lifecycle
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="afterHeader">
|
||||
<x-filament::badge color="info" size="sm">
|
||||
{{ $this->scopeLabel() }}
|
||||
</x-filament::badge>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Last run metadata --}}
|
||||
@if ($lastRun)
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
|
||||
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $lastRun->created_at?->diffForHumans() ?? '—' }}
|
||||
</span>
|
||||
|
||||
@if ($lastRunStatusSpec)
|
||||
<x-filament::badge
|
||||
:color="$lastRunStatusSpec->color"
|
||||
:icon="$lastRunStatusSpec->icon"
|
||||
size="sm"
|
||||
>
|
||||
{{ $lastRunStatusSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($lastRunOutcomeSpec)
|
||||
<x-filament::badge
|
||||
:color="$lastRunOutcomeSpec->color"
|
||||
:icon="$lastRunOutcomeSpec->icon"
|
||||
size="sm"
|
||||
>
|
||||
{{ $lastRunOutcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($lastRun->initiator_name)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
by {{ $lastRun->initiator_name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Preflight results --}}
|
||||
@if (is_array($this->preflight))
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ number_format((int) ($this->preflight['affected_count'] ?? 0)) }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ number_format((int) ($this->preflight['total_count'] ?? 0)) }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ is_numeric($this->preflight['estimated_tenants'] ?? null) ? number_format((int) $this->preflight['estimated_tenants']) : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
@if ((int) ($this->preflight['affected_count'] ?? 0) <= 0)
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
||||
Nothing to do for the current scope.
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
{{-- Preflight CTA --}}
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
|
||||
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
4
resources/views/filament/system/pages/ops/runs.blade.php
Normal file
4
resources/views/filament/system/pages/ops/runs.blade.php
Normal file
@ -0,0 +1,4 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
179
resources/views/filament/system/pages/ops/view-run.blade.php
Normal file
179
resources/views/filament/system/pages/ops/view-run.blade.php
Normal file
@ -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
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
{{-- Run header --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Run #{{ (int) $run->getKey() }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="afterHeader">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$statusSpec->color"
|
||||
:icon="$statusSpec->icon"
|
||||
>
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($outcomeSpec)
|
||||
<x-filament::badge
|
||||
:color="$outcomeSpec->color"
|
||||
:icon="$outcomeSpec->icon"
|
||||
>
|
||||
{{ $outcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Key details --}}
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Scope</dt>
|
||||
<dd class="mt-1">
|
||||
@if ($scope === 'single_tenant')
|
||||
<x-filament::badge color="info" size="sm">
|
||||
Single tenant {{ is_numeric($targetTenantId) ? '#'.(int) $targetTenantId : '' }}
|
||||
</x-filament::badge>
|
||||
@elseif ($scope === 'all_tenants')
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
All tenants
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ $scope }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->started_at?->toDayDateTimeString() ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Completed</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->completed_at?->toDayDateTimeString() ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Initiator</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ (string) ($run->initiator_name ?? '—') }}
|
||||
@if (is_array($platformInitiator) && ($platformInitiator['email'] ?? null))
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ (string) $platformInitiator['email'] }}</div>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{{-- Reason --}}
|
||||
@if (is_string($reasonCode) && is_string($reasonText) && trim($reasonCode) !== '' && trim($reasonText) !== '')
|
||||
<div class="flex items-start gap-3 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<x-heroicon-m-document-text class="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</span>
|
||||
<div class="mt-1 text-sm text-gray-950 dark:text-white">
|
||||
<x-filament::badge color="gray" size="sm">{{ $reasonCode }}</x-filament::badge>
|
||||
<span class="ml-1">{{ $reasonText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Summary counts --}}
|
||||
@if ($hasSummary)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Summary counts
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
@foreach ($summaryCounts as $key => $value)
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ \Illuminate\Support\Str::headline((string) $key) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xl font-bold text-gray-950 dark:text-white">
|
||||
{{ is_numeric($value) ? number_format((int) $value) : $value }}
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
Show raw JSON
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
@include('filament.partials.json-viewer', ['value' => $summaryCounts])
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Failures --}}
|
||||
@if (! empty($run->failure_summary))
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center gap-2 text-danger-600 dark:text-danger-400">
|
||||
<x-heroicon-m-exclamation-circle class="h-5 w-5" />
|
||||
Failures
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
@include('filament.partials.json-viewer', ['value' => $run->failure_summary])
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Context --}}
|
||||
<x-filament::section collapsible :collapsed="true">
|
||||
<x-slot name="heading">
|
||||
Context (raw)
|
||||
</x-slot>
|
||||
|
||||
@include('filament.partials.json-viewer', ['value' => $run->context ?? []])
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
35
specs/113-platform-ops-runbooks/checklists/requirements.md
Normal file
35
specs/113-platform-ops-runbooks/checklists/requirements.md
Normal file
@ -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.
|
||||
@ -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
|
||||
99
specs/113-platform-ops-runbooks/data-model.md
Normal file
99
specs/113-platform-ops-runbooks/data-model.md
Normal file
@ -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.
|
||||
128
specs/113-platform-ops-runbooks/plan.md
Normal file
128
specs/113-platform-ops-runbooks/plan.md
Normal file
@ -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`).
|
||||
35
specs/113-platform-ops-runbooks/quickstart.md
Normal file
35
specs/113-platform-ops-runbooks/quickstart.md
Normal file
@ -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`.
|
||||
82
specs/113-platform-ops-runbooks/research.md
Normal file
82
specs/113-platform-ops-runbooks/research.md
Normal file
@ -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.
|
||||
190
specs/113-platform-ops-runbooks/spec.md
Normal file
190
specs/113-platform-ops-runbooks/spec.md
Normal file
@ -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).
|
||||
192
specs/113-platform-ops-runbooks/tasks.md
Normal file
192
specs/113-platform-ops-runbooks/tasks.md
Normal file
@ -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)
|
||||
@ -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 () {
|
||||
|
||||
31
tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php
Normal file
31
tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('delegates deploy-time runbooks to the shared runbook service', function () {
|
||||
$run = OperationRun::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not expose maintenance actions in /admin findings list by default', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertActionDoesNotExist('backfill_lifecycle');
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->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');
|
||||
});
|
||||
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Dashboard;
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->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');
|
||||
});
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\Finding;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->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']);
|
||||
});
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->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());
|
||||
});
|
||||
47
tests/Feature/System/Spec113/AllowedTenantUniverseTest.php
Normal file
47
tests/Feature/System/Spec113/AllowedTenantUniverseTest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('excludes the platform tenant from the allowed universe query (picker)', function () {
|
||||
$platformTenant = Tenant::factory()->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);
|
||||
});
|
||||
|
||||
43
tests/Feature/System/Spec113/AuthorizationSemanticsTest.php
Normal file
43
tests/Feature/System/Spec113/AuthorizationSemanticsTest.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 when a tenant session accesses the system panel', function () {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
|
||||
67
tests/Feature/System/Spec113/SystemLoginThrottleTest.php
Normal file
67
tests/Feature/System/Spec113/SystemLoginThrottleTest.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Auth\Login;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
});
|
||||
|
||||
it('throttles system login after repeated failures and still audits attempts', function () {
|
||||
$platformTenant = Tenant::factory()->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');
|
||||
});
|
||||
17
tests/Feature/System/Spec113/SystemSessionIsolationTest.php
Normal file
17
tests/Feature/System/Spec113/SystemSessionIsolationTest.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('sets a dedicated session cookie name for /system', function () {
|
||||
$expectedCookieName = Str::slug((string) config('app.name', 'laravel')).'-system-session';
|
||||
|
||||
$this->get('/system/login')
|
||||
->assertSuccessful()
|
||||
->assertCookie($expectedCookieName);
|
||||
});
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 when a tenant session accesses system runbooks', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/system/ops/runbooks')
|
||||
->assertNotFound();
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user