feat(spec113): ops runbooks US1–US3 + hide admin maintenance

This commit is contained in:
Ahmed Darrazi 2026-02-26 03:42:34 +01:00
parent 4f1568b759
commit b776623300
29 changed files with 2758 additions and 169 deletions

View File

@ -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;

View 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;
}
}
}

View File

@ -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

View File

@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
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 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)
->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();
}),
];
}
}

View 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([]);
}
}

View 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;
}
}

View File

@ -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();

View 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;
}
}

View 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,
);
}
}
}

View File

@ -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();
}
}

View File

@ -2,9 +2,9 @@
namespace App\Providers\Filament;
use App\Http\Middleware\UseSystemSessionCookie;
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;
@ -33,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(),

View File

@ -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.
}
}
}

View 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;
}
}

View 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),
];
}
}

View 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');
}
}

View File

@ -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',

View File

@ -0,0 +1,59 @@
<x-filament-panels::page>
<div class="space-y-6">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
<div class="text-sm font-semibold">Operator warning</div>
<div class="mt-1 text-sm">
Runbooks can modify customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Rebuild Findings Lifecycle
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Scope: {{ $this->scopeLabel() }}
</div>
</div>
<div class="mt-4 border-t border-gray-100 pt-4 text-sm text-gray-700 dark:border-gray-800 dark:text-gray-200">
@if (is_array($this->preflight))
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400">Affected</div>
<div class="mt-1 font-semibold">{{ (int) ($this->preflight['affected_count'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400">Total scanned</div>
<div class="mt-1 font-semibold">{{ (int) ($this->preflight['total_count'] ?? 0) }}</div>
</div>
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400">Estimated tenants</div>
<div class="mt-1 font-semibold">{{ is_numeric($this->preflight['estimated_tenants'] ?? null) ? (int) $this->preflight['estimated_tenants'] : '—' }}</div>
</div>
</div>
@if ((int) ($this->preflight['affected_count'] ?? 0) <= 0)
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
Nothing to do for the current scope.
</div>
@endif
@else
<div>
Run <span class="font-medium">Preflight</span> to see how many findings would change for the selected scope.
</div>
@endif
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,4 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -0,0 +1,111 @@
@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', []);
@endphp
<x-filament-panels::page>
<div class="space-y-6">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Run #{{ (int) $run->getKey() }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
</div>
</div>
<div class="text-right text-xs text-gray-500 dark:text-gray-400">
<div>Started: {{ $run->started_at?->toDayDateTimeString() ?? '—' }}</div>
<div>Completed: {{ $run->completed_at?->toDayDateTimeString() ?? '—' }}</div>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400">Status</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-gray-100">{{ (string) $run->status }}</div>
</div>
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400">Outcome</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-gray-100">{{ (string) $run->outcome }}</div>
</div>
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400">Scope</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-gray-100">
@if ($scope === 'single_tenant')
Single tenant {{ is_numeric($targetTenantId) ? '#'.(int) $targetTenantId : '' }}
@elseif ($scope === 'all_tenants')
All tenants
@else
{{ $scope }}
@endif
</div>
</div>
</div>
<div class="mt-4 text-sm text-gray-700 dark:text-gray-200">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<span class="font-medium">Initiator:</span>
{{ (string) ($run->initiator_name ?? '—') }}
</div>
@if (is_array($platformInitiator) && ($platformInitiator['email'] ?? null))
<div class="sm:text-right">
<span class="font-medium">Platform user:</span>
{{ (string) ($platformInitiator['email'] ?? '') }}
</div>
@endif
</div>
@if (is_string($reasonCode) && is_string($reasonText) && trim($reasonCode) !== '' && trim($reasonText) !== '')
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Reason</div>
<div class="mt-1 text-sm">
<span class="font-medium">{{ $reasonCode }}</span>
<span class="text-gray-600 dark:text-gray-300"></span>
<span>{{ $reasonText }}</span>
</div>
</div>
@endif
</div>
</div>
@if (! empty($run->summary_counts))
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Summary counts</div>
<div class="mt-3">
@include('filament.partials.json-viewer', ['value' => $run->summary_counts])
</div>
</div>
@endif
@if (! empty($run->failure_summary))
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Failures</div>
<div class="mt-3">
@include('filament.partials.json-viewer', ['value' => $run->failure_summary])
</div>
</div>
@endif
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Context</div>
<div class="mt-3">
@include('filament.partials.json-viewer', ['value' => $run->context ?? []])
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -52,35 +52,35 @@ ## Phase 3: User Story 1 — Operator runs a runbook safely (Priority: P1) 🎯
### Tests for User Story 1
- [ ] T016 [P] [US1] Add runbook preflight tests in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php (single tenant + all tenants preflight returns affected_count)
- [ ] 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)
- [ ] 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)
- [ ] 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)
- [ ] 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)
- [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
- [ ] T021 [US1] Create runbook service app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php with methods preflight(scope) and start(scope, initiator, reason, source)
- [ ] 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)
- [ ] 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)
- [ ] T024 [US1] Record break-glass marker + reason on OperationRun context and audit in app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php (SR-005)
- [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)
- [ ] 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])
- [ ] 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())
- [ ] T027 [US1] Ensure scope-level lock prevents concurrent all-tenants runs in app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php (lock key includes workspace + scope)
- [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)
- [ ] 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)
- [ ] 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)
- [ ] 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] 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)
- [ ] 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)
- [ ] 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] 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)
- [ ] 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)
- [ ] 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] 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)
- [ ] T035 [US1] Create System run detail page class app/Filament/System/Pages/Ops/ViewRun.php (infolist rendering of OperationRun; show scope/actor/counts/failures)
- [ ] T036 [P] [US1] Create System run detail view resources/views/filament/system/pages/ops/view-run.blade.php
- [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
---
@ -92,13 +92,13 @@ ## Phase 4: User Story 2 — Customers never see maintenance actions (Priority:
### Tests for User Story 2
- [ ] 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)
- [ ] T038 [P] [US2] Add tenant-plane 404 test for /system/ops/runbooks in tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php
- [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
- [ ] 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)
- [ ] 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
- [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
---
@ -110,23 +110,23 @@ ## Phase 5: User Story 3 — Same logic for deploy-time and operator re-run (Pri
### Tests for User Story 3
- [ ] T041 [P] [US3] Add idempotency test in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php (second run updated=0 and/or preflight affected_count=0)
- [ ] T042 [P] [US3] Add deploy-time entry point test in tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php (command delegates to FindingsLifecycleBackfillRunbookService)
- [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
- [ ] T043 [US3] Refactor CLI command to call shared runbook service in app/Console/Commands/TenantpilotBackfillFindingLifecycle.php (single-tenant scope, source=cli)
- [ ] T044 [US3] Add deploy-time runbooks command in app/Console/Commands/TenantpilotRunDeployRunbooks.php (source=deploy_hook; initiator null; uses FindingsLifecycleBackfillRunbookService)
- [ ] T045 [US3] Ensure System UI uses the same runbook service start() call path in app/Filament/System/Pages/Ops/Runbooks.php (source=system_ui)
- [ ] T046 [US3] Ensure initiator-null runs do not emit terminal DB notification in app/Services/OperationRunService.php (system-run behavior; audit/alerts still apply)
- [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
- [ ] T047 [P] Run new Spec 113 tests via vendor/bin/sail artisan test --compact tests/Feature/System/Spec113/ (ensure all new tests pass)
- [ ] T048 [P] Run Ops Runbooks tests via vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/ (ensure US1/US3 tests pass)
- [ ] 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/)
- [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/)
---

View 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);
});

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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']);
});

View File

@ -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());
});

View File

@ -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();
});