## 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
610 lines
20 KiB
PHP
610 lines
20 KiB
PHP
<?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.
|
|
}
|
|
}
|
|
}
|