Compare commits

...

2 Commits

Author SHA1 Message Date
54fb65a63a chore: promote platform-dev to dev (#297)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
This pull request promotes the current state of `platform-dev` to the main integration branch `dev`. It includes recent features, fixes, and architectural refinements validated on the platform development track.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #297
2026-04-29 07:50:16 +00:00
29ad8852ca merge: platform-dev into dev (#295)
Some checks failed
Main Confidence / confidence (push) Failing after 1m1s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- integrate the current `platform-dev` branch into `dev`
- bring the latest platform work from the integration branch into the main development branch
- include the recent findings lifecycle backfill removal slice together with the already accumulated `platform-dev` changes

## Scope
- source branch: `platform-dev`
- target branch: `dev`
- branch role: integration PR, not a single-feature PR

## Validation
- branch state reviewed before PR creation
- `platform-dev` is ahead of `dev` with the expected integration history
- this PR intentionally carries the accumulated `platform-dev` commits into `dev`

## Notes
- this is the correct merge direction for the current workflow, where feature branches land in `platform-dev` first and `platform-dev` is then merged into `dev`
- after merging, `platform-dev` can be recreated fresh from `dev` as usual

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #295
2026-04-28 22:11:20 +00:00
111 changed files with 3080 additions and 3927 deletions

View File

@ -264,6 +264,8 @@ ## Active Technologies
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -298,9 +300,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

@ -6,12 +6,14 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command
{
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
{--type=* : Limit cleanup to baseline.compare and/or baseline.capture runs (legacy aliases also accepted)}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect}
@ -99,21 +101,35 @@ public function handle(): int
*/
private function normalizedTypes(): array
{
$types = array_values(array_unique(array_filter(
$requestedTypes = array_values(array_unique(array_filter(
array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'),
),
)));
if ($types === []) {
return ['baseline_compare', 'baseline_capture'];
$canonicalTypes = array_values(array_unique(array_filter(array_map(
static fn (string $type): ?string => match ($type) {
OperationRunType::BaselineCompare->value, 'baseline_compare' => OperationRunType::BaselineCompare->value,
OperationRunType::BaselineCapture->value, 'baseline_capture' => OperationRunType::BaselineCapture->value,
default => null,
},
$requestedTypes,
))));
if ($canonicalTypes === []) {
$canonicalTypes = [
OperationRunType::BaselineCompare->value,
OperationRunType::BaselineCapture->value,
];
}
return array_values(array_filter(
$types,
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
));
return array_values(array_unique(array_merge(
...array_map(
static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type),
$canonicalTypes,
),
)));
}
/**

View File

@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\OperationalControls\OperationalControlBlockedException;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
class TenantpilotBackfillFindingLifecycle extends Command
{
protected $signature = 'tenantpilot:findings:backfill-lifecycle
{--tenant=* : Limit to tenant_id/external_id}';
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
{
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
if ($tenantIdentifiers === []) {
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
return self::FAILURE;
}
$tenants = $this->resolveTenants($tenantIdentifiers);
if ($tenants->isEmpty()) {
$this->info('No tenants matched the provided identifiers.');
return self::SUCCESS;
}
$queued = 0;
$skipped = 0;
$nothingToDo = 0;
foreach ($tenants as $tenant) {
if (! $tenant instanceof Tenant) {
continue;
}
try {
$run = $runbookService->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: null,
reason: null,
source: 'cli',
);
} catch (OperationalControlBlockedException $e) {
$this->error(sprintf(
'Backfill paused for tenant %d: %s',
(int) $tenant->getKey(),
$e->getMessage(),
));
return self::FAILURE;
} 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++;
continue;
}
$queued++;
}
$this->info(sprintf(
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
$queued,
$skipped,
$nothingToDo,
));
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return \Illuminate\Support\Collection<int, Tenant>
*/
private function resolveTenants(array $tenantIdentifiers)
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->forTenant($identifier)
->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
$tenantIds = array_values(array_unique($tenantIds));
if ($tenantIds === []) {
return collect();
}
return Tenant::query()
->whereIn('id', $tenantIds)
->orderBy('id')
->get();
}
}

View File

@ -1,56 +0,0 @@
<?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 App\Support\OperationalControls\OperationalControlBlockedException;
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 (OperationalControlBlockedException $e) {
$this->info('Deploy runbooks paused: '.$e->getMessage());
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

@ -16,6 +16,11 @@
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
@ -57,6 +62,16 @@ class CustomerReviewWorkspace extends Page implements HasTable
protected string $view = 'filament.pages.reviews.customer-review-workspace';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
}
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');

View File

@ -308,8 +308,6 @@ public static function infolist(Schema $schema): Schema
? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record))
: null)
->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
@ -1000,7 +998,6 @@ public static function table(Table $table): Table
if (! in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++;
@ -1416,7 +1413,6 @@ public static function triageAction(): Actions\Action
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
@ -1441,7 +1437,6 @@ public static function startProgressAction(): Actions\Action
->color('info')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(

View File

@ -10,14 +10,8 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
@ -108,77 +102,6 @@ protected function getHeaderActions(): array
{
$actions = [];
$actions[] = UiEnforcement::forAction(
Actions\Action::make('backfill_lifecycle')
->label('Backfill findings lifecycle')
->icon('heroicon-o-wrench-screwdriver')
->color('gray')
->requiresConfirmation()
->modalHeading('Backfill findings lifecycle')
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
abort(404);
}
try {
$opRun = $runbookService->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: $user,
reason: null,
source: 'tenant_ui',
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
$runUrl = OperationRunLinks::view($opRun, $tenant);
if ($opRun->wasRecentlyCreated === false) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url($runUrl),
])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->body('The backfill will run in the background. You can continue working while it completes.')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url($runUrl),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
$actions[] = UiEnforcement::forAction(
Actions\Action::make('triage_all_matching')
->label('Triage all matching')
@ -248,7 +171,6 @@ protected function getHeaderActions(): array
if (! in_array((string) $finding->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++;

View File

@ -57,11 +57,6 @@ public static function canAccess(): bool
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
}
public function mount(): void
{
abort_unless(static::canAccess(), 403);
}
public function getHeader(): ?View
{
return view('filament.system.pages.ops.partials.controls-header', [

View File

@ -4,26 +4,9 @@
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Auth\BreakGlassSession;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Services\Runbooks\RunbookReason;
use App\Services\System\AllowedTenantUniverse;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OperationalControls\OperationalControlBlockedException;
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
{
@ -37,53 +20,6 @@ class Runbooks extends Page
protected string $view = 'filament.system.pages.ops.runbooks';
public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
public ?int $findingsTenantId = null;
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 $findingsPreflight = null;
/**
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
*/
public ?array $preflight = null;
public function findingsScopeLabel(): string
{
if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
return 'All tenants';
}
$tenantName = $this->selectedTenantName($this->findingsTenantId);
if ($tenantName !== null) {
return "Single tenant ({$tenantName})";
}
return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant';
}
public function findingsLastRun(): ?OperationRun
{
return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
}
public function selectedTenantName(?int $tenantId): ?string
{
if ($tenantId === null) {
return null;
}
return Tenant::query()->whereKey($tenantId)->value('name');
}
public static function canAccess(): bool
{
$user = auth('platform')->user();
@ -95,231 +31,4 @@ public static function canAccess(): bool
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->findingsScopeForm())
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
$this->findingsScopeMode = $scope->mode;
$this->findingsTenantId = $scope->tenantId;
$this->scopeMode = $scope->mode;
$this->tenantId = $scope->tenantId;
$this->findingsPreflight = $runbookService->preflight($scope);
$this->preflight = $this->findingsPreflight;
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->findingsRunForm())
->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) {
throw ValidationException::withMessages([
'preflight' => 'Run preflight first.',
]);
}
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
$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,
]);
try {
$run = $runbookService->start(
scope: $scope,
initiator: $user,
reason: $reason,
source: 'system_ui',
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
$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 findingsScopeForm(): array
{
return [
Radio::make('scope_mode')
->label('Scope')
->options([
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
])
->default($this->findingsScopeMode)
->live()
->required(),
Select::make('tenant_id')
->label('Tenant')
->searchable()
->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array {
return $universe
->query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string {
if (! is_numeric($value)) {
return null;
}
return $universe
->query()
->whereKey((int) $value)
->value('name');
}),
];
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function findingsRunForm(): array
{
return [
TextInput::make('typed_confirmation')
->label('Type BACKFILL to confirm')
->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->required(fn (): bool => $this->findingsScopeMode === 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->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
Textarea::make('reason_text')
->label('Reason')
->rows(4)
->maxLength(500)
->required(function (BreakGlassSession $breakGlass): bool {
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
];
}
private function lastRunForType(string $type): ?OperationRun
{
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
if (! $platformTenant instanceof Tenant) {
return null;
}
return OperationRun::query()
->where('workspace_id', (int) $platformTenant->workspace_id)
->where('type', $type)
->latest('id')
->first();
}
/**
* @param array<string, mixed> $data
*/
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
if (! $scope->isSingleTenant()) {
return $scope;
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
return FindingsLifecycleBackfillScope::allTenants();
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
}

View File

@ -1,398 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Finding;
use App\Models\Tenant;
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;
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 BackfillFindingLifecycleJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $tenantId,
public readonly int $workspaceId,
public readonly ?int $initiatorUserId = null,
) {}
public function handle(
OperationRunService $operationRuns,
FindingSlaPolicy $slaPolicy,
FindingsLifecycleBackfillRunbookService $runbookService,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $this->initiatorUserId !== null
? User::query()->find($this->initiatorUserId)
: null;
$operationRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: 'findings.lifecycle.backfill',
identityInputs: [
'tenant_id' => $this->tenantId,
'trigger' => 'backfill',
],
context: [
'workspace_id' => $this->workspaceId,
'initiator_user_id' => $this->initiatorUserId,
],
initiator: $initiator instanceof User ? $initiator : null,
);
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
if (! $lock->get()) {
if ($operationRun->status !== OperationRunStatus::Completed->value) {
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Blocked->value,
failures: [
[
'code' => 'findings.lifecycle.backfill.lock_busy',
'message' => 'Another findings lifecycle backfill is already running for this tenant.',
],
],
);
}
$runbookService->maybeFinalize($operationRun);
return;
}
try {
$total = (int) Finding::query()
->where('tenant_id', $tenant->getKey())
->count();
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
summaryCounts: [
'total' => $total,
'processed' => 0,
'updated' => 0,
'skipped' => 0,
'failed' => 0,
],
);
$operationRun->refresh();
$backfillStartedAt = $operationRun->started_at !== null
? CarbonImmutable::instance($operationRun->started_at)
: CarbonImmutable::now('UTC');
Finding::query()
->where('tenant_id', $tenant->getKey())
->orderBy('id')
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void {
$processed = 0;
$updated = 0;
$skipped = 0;
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
$processed++;
$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++;
}
}
$operationRuns->incrementSummaryCounts($operationRun, [
'processed' => $processed,
'updated' => $updated,
'skipped' => $skipped,
]);
});
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
if ($consolidatedDuplicates > 0) {
$operationRuns->incrementSummaryCounts($operationRun, [
'updated' => $consolidatedDuplicates,
]);
}
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$runbookService->maybeFinalize($operationRun);
} catch (Throwable $e) {
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'findings.lifecycle.backfill.failed',
'reason_code' => $reasonCode,
'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.',
]],
);
$runbookService->maybeFinalize($operationRun);
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_CLOSED,
'resolved_at' => null,
'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'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

@ -1,378 +0,0 @@
<?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_CLOSED,
'resolved_at' => null,
'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'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

@ -1,95 +0,0 @@
<?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

@ -33,8 +33,6 @@ class Finding extends Model
public const string STATUS_NEW = 'new';
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
public const string STATUS_TRIAGED = 'triaged';
public const string STATUS_IN_PROGRESS = 'in_progress';
@ -169,10 +167,7 @@ public static function terminalStatuses(): array
*/
public static function openStatusesForQuery(): array
{
return [
...self::openStatuses(),
self::STATUS_ACKNOWLEDGED,
];
return self::openStatuses();
}
/**
@ -295,10 +290,6 @@ public static function isReopenReason(?string $reason): bool
public static function canonicalizeStatus(?string $status): ?string
{
if ($status === self::STATUS_ACKNOWLEDGED) {
return self::STATUS_TRIAGED;
}
return $status;
}
@ -324,23 +315,6 @@ public function isRiskAccepted(): bool
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
}
public function acknowledge(User $user): self
{
if ($this->status === self::STATUS_ACKNOWLEDGED) {
return $this;
}
$this->forceFill([
'status' => self::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
$this->save();
return $this;
}
public function resolve(string $reason): self
{
$this->forceFill([

View File

@ -49,10 +49,7 @@ public function update(User $user, Finding $finding): Response|bool
public function triage(User $user, Finding $finding): Response|bool
{
return $this->canMutateWithAnyCapability($user, $finding, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE);
}
public function assign(User $user, Finding $finding): Response|bool

View File

@ -28,7 +28,6 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -74,7 +73,6 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -112,7 +110,6 @@ class RoleCapabilityMap
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW,

View File

@ -46,17 +46,13 @@ public static function meaningfulActivityActionValues(): array
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
{
$this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
$currentStatus = (string) $finding->status;
if (! in_array($currentStatus, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
throw new InvalidArgumentException('Finding cannot be triaged from the current status.');
}
@ -82,12 +78,9 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding
{
$this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) {
if ((string) $finding->status !== Finding::STATUS_TRIAGED) {
throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.');
}
@ -369,10 +362,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{
$this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
throw new InvalidArgumentException('Only terminal findings can be reopened.');

View File

@ -1,739 +0,0 @@
<?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\User;
use App\Models\Workspace;
use App\Notifications\OperationRunCompleted;
use App\Services\Alerts\AlertDispatchService;
use App\Services\Audit\AuditRecorder;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\BreakGlassSession;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\System\AllowedTenantUniverse;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
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,
private readonly OperationalControlEvaluator $operationalControls,
private readonly AuditRecorder $auditRecorder,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
/**
* @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,
initiator: null,
context: [
'preflight' => $result,
],
);
return $result;
}
public function start(
FindingsLifecycleBackfillScope $scope,
User|PlatformUser|null $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.',
]);
}
$workspace = null;
$tenant = null;
if ($scope->isSingleTenant()) {
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
$this->allowedTenantUniverse->ensureAllowed($tenant);
$workspace = $tenant->workspace;
} else {
$platformTenant = $this->platformTenant();
$workspace = $platformTenant->workspace;
}
if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Platform tenant is missing its workspace.');
}
$decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace);
if ($decision->isPaused()) {
$this->auditBlockedStart(
decision: $decision,
scope: $scope,
workspace: $workspace,
tenant: $tenant,
initiator: $initiator,
source: $source,
);
throw OperationalControlBlockedException::forDecision(
decision: $decision,
actionLabel: OperationCatalog::label(self::RUNBOOK_KEY),
);
}
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(
tenant: $tenant,
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,
User|PlatformUser|null $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: $initiator instanceof User ? $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(),
initiator: $initiator,
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(
?Tenant $tenant,
User|PlatformUser|null $initiator,
?RunbookReason $reason,
array $preflight,
string $source,
bool $isBreakGlassActive,
): OperationRun {
if (! $tenant instanceof Tenant) {
throw new \RuntimeException('Target tenant is required for single-tenant runs.');
}
$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: $initiator instanceof User ? $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(),
initiator: $initiator,
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,
User|PlatformUser|null $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,
];
} elseif ($initiator instanceof User) {
$context['tenant_initiator'] = [
'user_id' => (int) $initiator->getKey(),
'email' => (string) $initiator->email,
'name' => (string) $initiator->name,
];
}
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,
User|PlatformUser|null $initiator,
array $context = [],
): void {
try {
$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(),
];
if ($initiator instanceof User && $scope->isSingleTenant()) {
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first();
if ($tenant instanceof Tenant) {
$this->auditLogger->log(
tenant: $tenant,
action: $action,
context: [
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
] + $context,
actorId: (int) $initiator->getKey(),
actorEmail: (string) $initiator->email,
actorName: (string) $initiator->name,
status: 'success',
resourceType: 'operation_run',
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
);
return;
}
}
$platformTenant = $this->platformTenant();
$platformActor = $initiator instanceof PlatformUser
? $initiator
: auth('platform')->user();
$actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null;
$actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null;
$actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null;
$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 auditBlockedStart(
\App\Support\OperationalControls\OperationalControlDecision $decision,
FindingsLifecycleBackfillScope $scope,
Workspace $workspace,
?Tenant $tenant,
User|PlatformUser|null $initiator,
string $source,
): void {
try {
$metadata = array_filter([
'control_key' => $decision->controlKey,
'scope_type' => $decision->matchedScopeType,
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => $decision->reasonText,
'expires_at' => $decision->expiresAt?->toIso8601String(),
'actor_id' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null,
'requested_scope' => $scope->mode,
'target_tenant_id' => $scope->tenantId,
'source' => $source,
'runbook_key' => self::RUNBOOK_KEY,
], static fn (mixed $value): bool => $value !== null && $value !== '');
$summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY));
if ($scope->isAllTenants()) {
$this->auditRecorder->record(
action: AuditActionId::OperationalControlExecutionBlocked,
context: ['metadata' => $metadata],
actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null,
target: new AuditTargetSnapshot(
type: 'operational_control',
id: $decision->sourceActivationId,
label: OperationCatalog::label(self::RUNBOOK_KEY),
),
outcome: 'blocked',
summary: $summary,
);
return;
}
if (! $tenant instanceof Tenant) {
return;
}
$this->workspaceAuditLogger->log(
workspace: $workspace,
action: AuditActionId::OperationalControlExecutionBlocked,
context: ['metadata' => $metadata],
actor: $initiator,
status: 'blocked',
resourceType: 'operational_control',
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
targetLabel: OperationCatalog::label(self::RUNBOOK_KEY),
summary: $summary,
tenant: $tenant,
);
} 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' => $run->canonicalOperationType(),
'scope' => (string) data_get($run->context, 'runbook.scope', ''),
'view_run_url' => SystemOperationRunLinks::view($run),
],
]);
} catch (Throwable) {
// Alerts must not crash the runbook.
}
}
}

View File

@ -1,81 +0,0 @@
<?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

@ -17,7 +17,6 @@ final class OperationRunTriageService
'inventory.sync',
'policy.sync',
'directory.groups.sync',
'findings.lifecycle.backfill',
'rbac.health_check',
'entra.admin_roles.scan',
'tenant.review_pack.generate',
@ -28,7 +27,6 @@ final class OperationRunTriageService
'inventory.sync',
'policy.sync',
'directory.groups.sync',
'findings.lifecycle.backfill',
'rbac.health_check',
'entra.admin_roles.scan',
'tenant.review_pack.generate',

View File

@ -128,7 +128,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
{
$summary = $this->summary($findingsItem);
$entries = collect(Arr::wrap($summary['entries'] ?? []))
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true))
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'new', 'triaged', 'in_progress', 'reopened'], true))
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
'critical' => 4,
'high' => 3,

View File

@ -91,8 +91,6 @@ class Capabilities
public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept';
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
public const FINDING_EXCEPTION_VIEW = 'finding_exception.view';
public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage';

View File

@ -30,8 +30,6 @@ class PlatformCapabilities
public const RUNBOOKS_RUN = 'platform.runbooks.run';
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
/**

View File

@ -796,7 +796,6 @@ private static function findingAttentionCounts(Tenant $tenant): array
$activeNonNewFindingsCount = Finding::query()
->where('tenant_id', $tenantId)
->whereIn('status', [
Finding::STATUS_ACKNOWLEDGED,
Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS,
Finding::STATUS_REOPENED,

View File

@ -99,7 +99,7 @@ private static function resolveTenantWorkspaceId(Model $model, int $tenantId): i
$tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null;
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
$tenant = Tenant::query()->find($tenantId);
$tenant = Tenant::query()->withTrashed()->find($tenantId);
}
if (! $tenant instanceof Tenant) {

View File

@ -103,9 +103,9 @@ public static function baselineProfileStatuses(): array
/**
* @return array<string, string>
*/
public static function findingStatuses(bool $includeLegacyAcknowledged = true): array
public static function findingStatuses(): array
{
$options = self::badgeOptions(BadgeDomain::FindingStatus, [
return self::badgeOptions(BadgeDomain::FindingStatus, [
Finding::STATUS_NEW,
Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS,
@ -114,21 +114,6 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true):
Finding::STATUS_CLOSED,
Finding::STATUS_RISK_ACCEPTED,
]);
if (! $includeLegacyAcknowledged) {
return $options;
}
return [
Finding::STATUS_NEW => $options[Finding::STATUS_NEW],
Finding::STATUS_TRIAGED => $options[Finding::STATUS_TRIAGED],
Finding::STATUS_ACKNOWLEDGED => self::legacyFindingAcknowledgedLabel(),
Finding::STATUS_IN_PROGRESS => $options[Finding::STATUS_IN_PROGRESS],
Finding::STATUS_REOPENED => $options[Finding::STATUS_REOPENED],
Finding::STATUS_RESOLVED => $options[Finding::STATUS_RESOLVED],
Finding::STATUS_CLOSED => $options[Finding::STATUS_CLOSED],
Finding::STATUS_RISK_ACCEPTED => $options[Finding::STATUS_RISK_ACCEPTED],
];
}
/**
@ -312,11 +297,6 @@ private static function badgeOptions(BadgeDomain $domain, array $values): array
->all();
}
private static function legacyFindingAcknowledgedLabel(): string
{
return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)';
}
private static function platformLabel(string $platform): string
{
return match (Str::of($platform)

View File

@ -12,8 +12,6 @@ final class TrustedStatePolicy
public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions';
public const SYSTEM_RUNBOOKS = 'system_runbooks';
/**
* @return array{
* name: string,
@ -329,92 +327,6 @@ public function firstSlice(): array
'scopedTenant',
],
],
self::SYSTEM_RUNBOOKS => [
'component_name' => 'System runbooks',
'plane' => 'system_platform',
'route_anchor' => null,
'authority_sources' => [
'allowed_tenant_universe',
'explicit_scoped_query',
],
'locked_identities' => [],
'locked_identity_fields' => [],
'mutable_selectors' => [
'findingsTenantId',
'tenantId',
'findingsScopeMode',
'scopeMode',
],
'mutable_selector_fields' => [
$this->field(
name: 'findingsTenantId',
stateClass: TrustedStateClass::Presentation,
phpType: '?int',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public ?int $findingsTenantId = null;',
'resolveAllowedOrFail($this->findingsTenantId)',
],
notes: 'Single-tenant runbook proposal only; it must be validated against the operator allowed-tenant universe.',
),
$this->field(
name: 'tenantId',
stateClass: TrustedStateClass::Presentation,
phpType: '?int',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public ?int $tenantId = null;',
],
notes: 'Mirrored display state for the last trusted preflight result.',
),
$this->field(
name: 'findingsScopeMode',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
'trustedFindingsScopeFromState(',
],
notes: 'Scope mode remains mutable UI state but protected actions re-normalize it into a trusted scope DTO.',
),
$this->field(
name: 'scopeMode',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
],
notes: 'Mirrored display state for the last trusted preflight result.',
),
],
'server_derived_authority_fields' => [
$this->field(
name: 'findingsScope',
stateClass: TrustedStateClass::ServerDerivedAuthority,
phpType: 'FindingsLifecycleBackfillScope',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'trustedFindingsScopeFromFormData(',
'trustedFindingsScopeFromState(',
'resolveAllowedOrFail(',
],
notes: 'Protected actions must convert mutable selector state into a trusted scope DTO via AllowedTenantUniverse.',
),
],
'forbidden_public_authority_fields' => [],
],
];
}

View File

@ -278,7 +278,6 @@ private static function canonicalDefinitions(): array
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300),
];
}
@ -290,27 +289,36 @@ private static function operationAliases(): array
return [
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
new OperationTypeAlias('policy.snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
new OperationTypeAlias('policy.restore', 'policy.restore', 'canonical', true),
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
new OperationTypeAlias('inventory.sync', 'inventory.sync', 'canonical', true),
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
new OperationTypeAlias('directory.groups.sync', 'directory.groups.sync', 'canonical', true),
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.archive', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
new OperationTypeAlias('backup.schedule.execute', 'backup.schedule.execute', 'canonical', true),
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
new OperationTypeAlias('backup.schedule.retention', 'backup.schedule.retention', 'canonical', true),
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
new OperationTypeAlias('directory.role_definitions.sync', 'directory.role_definitions.sync', 'canonical', true),
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
@ -325,13 +333,13 @@ private static function operationAliases(): array
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
new OperationTypeAlias('permission.posture.check', 'permission.posture.check', 'canonical', true),
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
];
}
}

View File

@ -640,23 +640,18 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.',
'explicitReason' => 'Runbooks remains a system utility shell outside the declaration-backed record or table surface; it currently exposes no supported launch action after lifecycle-backfill removal.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php',
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.',
'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php',
'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
],
[
'kind' => 'guard_test',
'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php',
'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,

View File

@ -73,18 +73,6 @@ public function permissionPosture(): static
]);
}
/**
* State for legacy acknowledged findings.
*/
public function acknowledged(): static
{
return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => null,
]);
}
/**
* State for triaged findings.
*/

View File

@ -41,7 +41,6 @@ public function run(): void
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,

View File

@ -1,7 +1,10 @@
@php
use App\Support\Verification\VerificationLinkBehavior;
$help = is_array($help ?? null) ? $help : [];
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
$linkBehavior = app(VerificationLinkBehavior::class);
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
? (string) ($help['headline'])
: 'Contextual help';
@ -57,9 +60,16 @@
<div class="flex flex-wrap gap-2">
@foreach ($links as $link)
@php
$linkLabel = is_string($link['label'] ?? null) && trim((string) ($link['label'] ?? '')) !== ''
? (string) $link['label']
: 'Open';
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
? (string) $link['url']
: null;
$behavior = $linkUrl !== null
? $linkBehavior->describe($linkLabel, $linkUrl)
: null;
$testId = 'contextual-help-link-'.\Illuminate\Support\Str::slug($linkLabel);
@endphp
@if ($linkUrl)
@ -68,8 +78,11 @@
:href="$linkUrl"
size="sm"
color="primary"
:target="(bool) ($behavior['opens_in_new_tab'] ?? false) ? '_blank' : null"
:rel="(bool) ($behavior['opens_in_new_tab'] ?? false) ? 'noopener noreferrer' : null"
:data-testid="$testId"
>
{{ (string) ($link['label'] ?? 'Open') }}
{{ $linkLabel }}
</x-filament::button>
@endif
@endforeach

View File

@ -1,13 +1,3 @@
@php
$findingsLastRun = $this->findingsLastRun();
$findingsLastRunStatusSpec = $findingsLastRun
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status)
: null;
$findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed'
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome)
: null;
@endphp
<x-filament-panels::page>
<div class="space-y-6">
<x-filament::section>
@ -17,7 +7,7 @@
<div>
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.
</p>
</div>
</div>
@ -25,100 +15,17 @@
<x-filament::section>
<x-slot name="heading">
Rebuild Findings Lifecycle
No supported runbooks
</x-slot>
<x-slot name="description">
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
Supported platform runbooks will appear here when they are part of current product truth.
</x-slot>
<x-slot name="afterHeader">
<x-filament::badge color="info" size="sm">
{{ $this->findingsScopeLabel() }}
</x-filament::badge>
</x-slot>
<div class="space-y-4">
@if ($findingsLastRun)
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }}
</span>
@if ($findingsLastRunStatusSpec)
<x-filament::badge
:color="$findingsLastRunStatusSpec->color"
:icon="$findingsLastRunStatusSpec->icon"
size="sm"
>
{{ $findingsLastRunStatusSpec->label }}
</x-filament::badge>
@endif
@if ($findingsLastRunOutcomeSpec)
<x-filament::badge
:color="$findingsLastRunOutcomeSpec->color"
:icon="$findingsLastRunOutcomeSpec->icon"
size="sm"
>
{{ $findingsLastRunOutcomeSpec->label }}
</x-filament::badge>
@endif
@if ($findingsLastRun->initiator_name)
<span class="text-xs text-gray-500 dark:text-gray-400">
by {{ $findingsLastRun->initiator_name }}
</span>
@endif
</div>
@endif
@if (is_array($this->findingsPreflight))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }}
</p>
</div>
</x-filament::section>
</div>
@if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
Nothing to do for the current scope.
</div>
@endif
@else
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
</div>
@endif
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
There are no operator-run repair runbooks exposed on this surface.
</div>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@ -61,18 +61,6 @@
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$visibleSelectValue = <<<'JS'
(() => {
const select = [...document.querySelectorAll('select')].find((element) => {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
return select?.value ?? null;
})()
JS;
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$page
@ -87,8 +75,8 @@
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Verify access')
->assertSee('Status: Not started')
->click('Provider connection')
->assertScript($visibleSelectValue, (string) $connection->getKey())
->click('Select an existing connection or create a new one.')
->assertSee('Edit selected connection')
->click('Create new connection')
->check('internal:label="Dedicated override"s')
->fill('[type="password"]', 'browser-only-secret')
@ -97,8 +85,8 @@
->waitForText('Status: Not started')
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Verify access')
->click('Provider connection')
->assertScript($visibleSelectValue, (string) $connection->getKey())
->click('Select an existing connection or create a new one.')
->assertSee('Edit selected connection')
->click('Create new connection')
->check('internal:label="Dedicated override"s')
->assertValue('[type="password"]', '');

View File

@ -86,18 +86,6 @@
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$visibleSelectValue = <<<'JS'
(() => {
const select = [...document.querySelectorAll('select')].find((element) => {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
return select?.value ?? null;
})()
JS;
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$page
@ -113,8 +101,8 @@
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Status: Needs attention')
->assertSee('Start verification')
->click('Provider connection')
->assertScript($visibleSelectValue, (string) $selectedConnection->getKey());
->click('Select an existing connection or create a new one.')
->assertSee('Edit selected connection');
});
it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void {
@ -328,32 +316,14 @@
->assertNoJavaScriptErrors()
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->wait(1)
->assertScript("document.querySelector('[data-testid=\"verification-assist-trigger\"]') !== null", true)
->click('[data-testid="verification-assist-trigger"]')
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank');
$page->script(<<<'JS'
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: async () => Promise.resolve(),
},
});
document.querySelector('[data-testid="verification-assist-copy-application"]')?.click();
JS);
$page
->waitForText('Copied')
->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer')
->click('[data-testid="verification-assist-full-page"]')
->assertScript("document.querySelector('[data-testid=\"contextual-help-link-open-required-permissions\"]') !== null", true)
->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'target', '_blank')
->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'rel', 'noopener noreferrer')
->click('[data-testid="contextual-help-link-open-required-permissions"]')
->wait(1)
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
->click('Close')
->click('Provider connection')
->assertSee('Select an existing connection or create a new one.');
->click('Select an existing connection or create a new one.')
->assertSee('Edit selected connection');
});
it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void {

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
it('removes the acknowledged findings capability alias from shared RBAC truth', function (): void {
expect(Capabilities::isKnown('tenant_findings.acknowledge'))->toBeFalse();
expect(RoleCapabilityMap::rolesWithCapability('tenant_findings.acknowledge'))->toBe([]);
});
it('keeps the canonical findings triage capability available to operators', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
expect(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::TENANT_FINDINGS_TRIAGE))->toBeTrue();
expect(Gate::forUser($user)->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue();
});

View File

@ -9,6 +9,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\OperationRunType;
it('writes audit events for baseline capture start and completion with scope + gap summary', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -35,7 +36,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -58,7 +58,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -12,6 +12,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -73,7 +74,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -18,6 +18,7 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunType;
it('Baseline capture (full content) captures evidence on demand when missing', function () {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
@ -119,7 +120,7 @@ public function capture(
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -64,7 +64,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -14,6 +14,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -104,7 +105,7 @@
$operationRuns = app(OperationRunService::class);
$run = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
function createBaselineCaptureInventoryBasis(
@ -65,7 +66,7 @@ function runBaselineCaptureJob(
/** @var OperationRun $run */
$run = $result['run'];
expect($run->type)->toBe('baseline_capture');
expect($run->type)->toBe(OperationRunType::BaselineCapture->value);
expect($run->status)->toBe('queued');
expect($run->tenant_id)->toBe((int) $tenant->getKey());
@ -104,7 +105,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('rejects capture when the latest inventory sync was blocked', function () {
@ -135,7 +136,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
@ -166,7 +167,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
@ -189,7 +190,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('rejects capture for a draft profile with reason code', function () {
@ -209,7 +210,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('rejects capture for an archived profile with reason code', function () {
@ -228,7 +229,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('rejects capture for a tenant from a different workspace', function () {
@ -274,7 +275,7 @@ function runBaselineCaptureJob(
expect($result2['ok'])->toBeTrue();
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(1);
});
// --- Snapshot dedupe + capture job execution ---
@ -321,7 +322,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -476,7 +477,7 @@ function runBaselineCaptureJob(
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -499,7 +500,7 @@ function runBaselineCaptureJob(
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'baseline_capture',
'type' => OperationRunType::BaselineCapture->value,
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
@ -586,7 +587,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -662,7 +663,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -15,6 +15,7 @@
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -69,14 +70,14 @@
$activeRuns = OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare')
->where('type', OperationRunType::BaselineCompare->value)
->get();
expect($activeRuns)->toHaveCount(2)
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0);
->and(OperationRun::query()->whereNull('tenant_id')->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
@ -97,7 +98,7 @@
expect(OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare')
->where('type', OperationRunType::BaselineCompare->value)
->whereNull('tenant_id')
->count())->toBe(0);
});

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
it('does not register findings lifecycle backfill or deploy-runbook commands', function (): void {
$commands = array_keys(Artisan::all());
expect($commands)
->not->toContain('tenantpilot:findings:backfill-lifecycle')
->not->toContain('tenantpilot:run-deploy-runbooks');
expect((string) file_get_contents(base_path('routes/console.php')))
->not->toContain('tenantpilot:findings:backfill-lifecycle')
->not->toContain('tenantpilot:run-deploy-runbooks');
});

View File

@ -1,31 +0,0 @@
<?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

@ -3,6 +3,7 @@
use App\Jobs\EntraGroupSyncJob;
use App\Services\Directory\EntraGroupSyncService;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
it('starts a manual group sync by creating a run and dispatching a job', function () {
@ -21,7 +22,7 @@
expect($run)
->and($run->tenant_id)->toBe($tenant->getKey())
->and($run->user_id)->toBe($user->getKey())
->and($run->type)->toBe('entra_group_sync')
->and($run->type)->toBe(OperationRunType::DirectoryGroupsSync->value)
->and($run->status)->toBe('queued')
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all')
->and($run->context['provider_connection_id'] ?? null)->toBeInt();

View File

@ -7,6 +7,7 @@
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunType;
it('sync job upserts groups and updates run counters', function () {
@ -54,7 +55,7 @@
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'entra_group_sync',
type: OperationRunType::DirectoryGroupsSync->value,
inputs: ['selection_key' => 'groups-v1:all'],
initiator: $user,
);

View File

@ -7,6 +7,7 @@
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Config;
it('purges cached groups older than the retention window', function () {
@ -34,7 +35,7 @@
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'entra_group_sync',
type: OperationRunType::DirectoryGroupsSync->value,
inputs: ['selection_key' => 'groups-v1:all'],
initiator: $user,
);

View File

@ -444,7 +444,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
->and($finding->subject_external_id)->toBe('user-1:def-ga');
});
it('auto-resolve applies to acknowledged findings too', function (): void {
it('auto-resolve applies to triaged findings too', function (): void {
[$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator();
@ -456,20 +456,19 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
);
$generator->generate($tenant, $payload);
// Acknowledge the finding
// Triage the finding
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('subject_external_id', 'user-1:def-ga')
->first();
$finding->forceFill([
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
'status' => Finding::STATUS_TRIAGED,
'triaged_at' => now(),
])->save();
expect($finding->fresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
expect($finding->fresh()->status)->toBe(Finding::STATUS_TRIAGED);
// Scan 2: remove → should auto-resolve even though acknowledged
// Scan 2: remove -> should auto-resolve even though triaged
$payload2 = buildPayload([gaRoleDef()], []);
$result = $generator->generate($tenant, $payload2);

View File

@ -115,7 +115,7 @@
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'baseline_compare')
->where('type', OperationRunType::BaselineCompare->value)
->latest('id')
->first();
@ -192,7 +192,7 @@
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void {
@ -250,7 +250,7 @@
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('can refresh stats without calling mount directly', function (): void {

View File

@ -9,6 +9,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@ -121,7 +122,7 @@ function seedCaptureProfileForTenant(
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'baseline_capture')
->where('type', OperationRunType::BaselineCapture->value)
->latest('id')
->first();
@ -151,7 +152,7 @@ function seedCaptureProfileForTenant(
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('does not start full-content capture when rollout is disabled', function (): void {
@ -174,7 +175,7 @@ function seedCaptureProfileForTenant(
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});
it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void {
@ -228,5 +229,5 @@ function seedCaptureProfileForTenant(
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
});

View File

@ -14,6 +14,7 @@
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@ -92,7 +93,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'baseline_compare')
->where('type', OperationRunType::BaselineCompare->value)
->latest('id')
->first();
@ -120,7 +121,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
@ -167,7 +168,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
@ -275,5 +276,5 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});

View File

@ -1,21 +0,0 @@
<?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('exposes the findings lifecycle backfill action for entitled tenant operators', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle');
});

View File

@ -34,7 +34,6 @@ protected function makeFindingForWorkflow(Tenant $tenant, string $status = Findi
$factory = Finding::factory()->for($tenant);
$factory = match ($status) {
Finding::STATUS_ACKNOWLEDGED => $factory->acknowledged(),
Finding::STATUS_TRIAGED => $factory->triaged(),
Finding::STATUS_IN_PROGRESS => $factory->inProgress(),
Finding::STATUS_REOPENED => $factory->reopened(),

View File

@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('backfills legacy findings (ack → triaged, lifecycle fields, due_at from backfill time)', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
[$user, $tenant] = createUserWithTenant(role: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'severity' => Finding::SEVERITY_MEDIUM,
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
'acknowledged_by_user_id' => (int) $user->getKey(),
'first_seen_at' => null,
'last_seen_at' => null,
'times_seen' => null,
'sla_days' => null,
'due_at' => null,
'triaged_at' => null,
'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
]);
BackfillFindingLifecycleJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
$finding->refresh();
expect($finding->status)->toBe(Finding::STATUS_TRIAGED)
->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
->and($finding->times_seen)->toBe(1)
->and($finding->sla_days)->toBe(14)
->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00')
->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
CarbonImmutable::setTestNow();
});
it('computes drift recurrence keys and consolidates drift duplicates', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
[$user, $tenant] = createUserWithTenant(role: 'manager');
$scopeKey = hash('sha256', 'scope-drift-backfill-duplicate');
$evidence = [
'change_type' => 'modified',
'summary' => [
'kind' => 'policy_snapshot',
'changed_fields' => ['snapshot_hash'],
],
'baseline' => ['policy_id' => 'policy-dupe'],
'current' => ['policy_id' => 'policy-dupe'],
];
$open = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'subject_type' => 'policy',
'subject_external_id' => 'policy-dupe',
'status' => Finding::STATUS_NEW,
'recurrence_key' => null,
'evidence_jsonb' => $evidence,
'first_seen_at' => null,
'last_seen_at' => null,
'times_seen' => null,
'sla_days' => null,
'due_at' => null,
'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
]);
$duplicate = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'subject_type' => 'policy',
'subject_external_id' => 'policy-dupe',
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
'recurrence_key' => null,
'evidence_jsonb' => $evidence,
'first_seen_at' => null,
'last_seen_at' => null,
'times_seen' => null,
'created_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
'updated_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
]);
BackfillFindingLifecycleJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
$tenantId = (int) $tenant->getKey();
$expectedRecurrenceKey = hash(
'sha256',
sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, 'policy-dupe'),
);
expect(Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('recurrence_key', $expectedRecurrenceKey)
->count())->toBe(1);
$open->refresh();
$duplicate->refresh();
expect($open->recurrence_key)->toBe($expectedRecurrenceKey)
->and($open->status)->toBe(Finding::STATUS_NEW);
expect($duplicate->recurrence_key)->toBeNull()
->and($duplicate->status)->toBe(Finding::STATUS_CLOSED)
->and($duplicate->resolved_reason)->toBeNull()
->and($duplicate->resolved_at)->toBeNull()
->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
CarbonImmutable::setTestNow();
});

View File

@ -22,9 +22,8 @@
->toContain(AuditActionId::FindingReopened->value);
});
it('keeps only legacy compatibility lifecycle helpers on the model', function (): void {
expect(method_exists(Finding::class, 'acknowledge'))->toBeTrue()
->and(method_exists(Finding::class, 'resolve'))->toBeTrue()
it('keeps only the surviving model lifecycle helpers', function (): void {
expect(method_exists(Finding::class, 'resolve'))->toBeTrue()
->and(method_exists(Finding::class, 'reopen'))->toBeTrue()
->and(method_exists(Finding::class, 'triage'))->toBeFalse()
->and(method_exists(Finding::class, 'startProgress'))->toBeFalse()

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps canonical findings workflow behavior after removing the backfill runtime surfaces', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$service = app(FindingWorkflowService::class);
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW, [
'owner_user_id' => null,
'assignee_user_id' => null,
'sla_days' => 14,
'due_at' => now()->addDays(14),
]);
$triaged = $service->triage($finding, $tenant, $owner);
$assigned = $service->assign(
finding: $triaged,
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $assignee->getKey(),
ownerUserId: (int) $owner->getKey(),
);
$inProgress = $service->startProgress($assigned, $tenant, $owner);
$resolved = $service->resolve($inProgress, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED);
$riskAccepted = $service->riskAccept(
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
$tenant,
$owner,
Finding::CLOSE_REASON_ACCEPTED_RISK,
);
expect($triaged->status)->toBe(Finding::STATUS_TRIAGED)
->and($triaged->triaged_at)->not->toBeNull()
->and((int) $assigned->owner_user_id)->toBe((int) $owner->getKey())
->and((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey())
->and($assigned->sla_days)->toBe(14)
->and($assigned->due_at)->not->toBeNull()
->and($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS)
->and($inProgress->in_progress_at)->not->toBeNull()
->and($resolved->status)->toBe(Finding::STATUS_RESOLVED)
->and($resolved->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
->and($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK);
expect($this->latestFindingAudit($triaged, AuditActionId::FindingTriaged))->not->toBeNull()
->and($this->latestFindingAudit($assigned, AuditActionId::FindingAssigned))->not->toBeNull()
->and($this->latestFindingAudit($inProgress, AuditActionId::FindingInProgress))->not->toBeNull()
->and($this->latestFindingAudit($resolved, AuditActionId::FindingResolved))->not->toBeNull()
->and($this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted))->not->toBeNull();
});

View File

@ -101,8 +101,10 @@ function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
'assignee_user_id' => (int) $otherAssignee->getKey(),
]);
$acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
$acknowledged = Finding::factory()->for($tenantA)->create([
'workspace_id' => (int) $tenantA->workspace_id,
'status' => 'acknowledged',
'acknowledged_at' => now(),
'assignee_user_id' => null,
'subject_external_id' => 'acknowledged',
]);

View File

@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationalControlActivation;
use App\Models\OperationRun;
use App\Support\Audit\AuditActionId;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('keeps the findings backfill action visible but blocks execution when a control is active', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'findings.lifecycle.backfill',
'workspace_id' => (int) $tenant->workspace_id,
'reason_text' => 'Workspace-specific pause.',
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle')
->callAction('backfill_lifecycle')
->assertNotified('Findings lifecycle backfill paused');
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
$audit = AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and($audit?->status)->toBe('blocked')
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id);
});
it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void {
Queue::fake();
[$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner');
[$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => (int) $allowedTenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'findings.lifecycle.backfill',
'workspace_id' => (int) $blockedTenant->workspace_id,
'reason_text' => 'Paused only for the blocked workspace.',
]);
$this->actingAs($allowedUser);
Filament::setTenant($allowedTenant, true);
Livewire::test(ListFindings::class)
->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle')
->callAction('backfill_lifecycle');
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->where('tenant_id', (int) $allowedTenant->getKey())
->latest('id')
->first();
expect($run)->not->toBeNull();
Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool {
return $job->tenantId === (int) $allowedTenant->getKey()
&& $job->workspaceId === (int) $allowedTenant->workspace_id;
});
expect(AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->where('tenant_id', (int) $allowedTenant->getKey())
->exists())->toBeFalse();
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Services\Findings\FindingWorkflowService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('rejects triage from the removed acknowledged status', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'status' => 'acknowledged',
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
expect(fn () => app(FindingWorkflowService::class)->triage($finding, $tenant, $user))
->toThrow(\InvalidArgumentException::class, 'Finding cannot be triaged from the current status.');
});
it('rejects start progress from the removed acknowledged status', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'status' => 'acknowledged',
'triaged_at' => now()->subMinute(),
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
expect(fn () => app(FindingWorkflowService::class)->startProgress($finding, $tenant, $user))
->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.');
});

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Models\Finding;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('removes the tenant findings lifecycle backfill header action without removing canonical workflow actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertActionDoesNotExist('backfill_lifecycle')
->assertActionExists('triage_all_matching')
->assertTableActionVisible('triage', $finding)
->assertTableActionVisible('assign', $finding)
->assertTableActionVisible('resolve', $finding)
->assertTableActionVisible('request_exception', $finding);
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->exists())->toBeFalse();
});

View File

@ -12,7 +12,6 @@ function livewireTrustedStateFirstSliceFixtures(): array
return [
TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php',
TrustedStatePolicy::SYSTEM_RUNBOOKS => 'app/Filament/System/Pages/Ops/Runbooks.php',
];
}

View File

@ -59,15 +59,18 @@
]);
});
it('supports legacy model helper compatibility for acknowledge', function (): void {
it('keeps stale acknowledged metadata as passive data only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
$finding = Finding::factory()->for($tenant)->permissionPosture()->create([
'status' => 'acknowledged',
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
$finding->acknowledge($user);
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED)
expect($finding->status)->toBe('acknowledged')
->and($finding->acknowledged_at)->not->toBeNull()
->and($finding->acknowledged_by_user_id)->toBe($user->getKey());
->and($finding->acknowledged_by_user_id)->toBe($user->getKey())
->and($finding->hasOpenStatus())->toBeFalse();
});
it('exposes v2 open and terminal status helpers', function (): void {
@ -84,31 +87,26 @@
Finding::STATUS_RISK_ACCEPTED,
]);
expect(Finding::openStatusesForQuery())->toContain(Finding::STATUS_ACKNOWLEDGED);
expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses());
});
it('maps legacy acknowledged status to triaged in v2 helpers', function (): void {
expect(Finding::canonicalizeStatus(Finding::STATUS_ACKNOWLEDGED))
->toBe(Finding::STATUS_TRIAGED);
it('does not treat acknowledged as canonical in v2 helpers', function (): void {
expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged');
expect(Finding::isOpenStatus(Finding::STATUS_ACKNOWLEDGED))->toBeTrue();
expect(Finding::isTerminalStatus(Finding::STATUS_ACKNOWLEDGED))->toBeFalse();
expect(Finding::isOpenStatus('acknowledged'))->toBeFalse();
expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse();
});
it('preserves acknowledged metadata when resolving an acknowledged finding', function (): void {
it('rejects resolving a stale acknowledged finding', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->permissionPosture()->acknowledged()->create([
$finding = Finding::factory()->for($tenant)->permissionPosture()->create([
'status' => 'acknowledged',
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
->and($finding->acknowledged_at)->not->toBeNull()
->and($finding->acknowledged_by_user_id)->toBe($user->getKey())
->and($finding->resolved_at)->not->toBeNull();
expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED))
->toThrow(\InvalidArgumentException::class, 'Only open findings can be resolved.');
});
it('has STATUS_RESOLVED constant', function (): void {

View File

@ -14,6 +14,7 @@
use App\Support\Audit\AuditOutcome;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunType;
it('derives summary-first audit semantics for baseline capture workflow events', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -36,7 +37,7 @@
$operationRunService = app(OperationRunService::class);
$run = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -97,7 +98,7 @@
$operationRunService = app(OperationRunService::class);
$run = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -26,7 +26,7 @@
->get('/admin/operations')
->assertOk()
->assertSee($workspaceName ?? 'Select workspace')
->assertSee('Search tenants…')
->assertSee(__('localization.shell.search_tenants'))
->assertSee('Switch workspace')
->assertSee('admin/select-tenant')
->assertSee('Clear tenant scope')
@ -66,7 +66,7 @@
->get('/admin/workspaces')
->assertOk()
->assertSee('Choose a workspace first.')
->assertDontSee('Search tenants…');
->assertDontSee(__('localization.shell.search_tenants'));
});
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {

View File

@ -10,12 +10,12 @@
$checks = [
[
'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php',
'required' => [
'FindingsLifecycleBackfillRunbookService',
'OperationalControlBlockedException',
'FindingsLifecycleBackfillScope::singleTenant(',
],
'required' => [],
'forbidden' => [
'FindingsLifecycleBackfillRunbookService',
'FindingsLifecycleBackfillScope',
'Backfill findings lifecycle',
'backfill_lifecycle',
"config('tenantpilot.allow_admin_maintenance_actions'",
'allow_admin_maintenance_actions',
'OperationalControlActivation::',
@ -23,12 +23,12 @@
],
[
'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php',
'required' => [
'FindingsLifecycleBackfillRunbookService',
'OperationalControlBlockedException',
'$runbookService->start(',
],
'required' => [],
'forbidden' => [
'FindingsLifecycleBackfillRunbookService',
'FindingsLifecycleBackfillScope',
'findings.lifecycle.backfill',
'Rebuild Findings Lifecycle',
'OperationalControlActivation::',
"config('tenantpilot.allow_admin_maintenance_actions'",
],
@ -66,4 +66,16 @@
expect($source)->not->toContain($needle);
}
}
})->group('surface-guard');
foreach ([
$root.'/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php',
$root.'/app/Console/Commands/TenantpilotRunDeployRunbooks.php',
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php',
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillScope.php',
$root.'/app/Jobs/BackfillFindingLifecycleJob.php',
$root.'/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php',
$root.'/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php',
] as $removedPath) {
expect(file_exists($removedPath))->toBeFalse("Removed findings lifecycle backfill artifact still exists: {$removedPath}");
}
})->group('surface-guard');

View File

@ -93,7 +93,7 @@
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
'value' => 'inventory.sync',
],
],
]));

View File

@ -6,6 +6,7 @@
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Support\OperationRunType;
it('completes backup retention runs without persisting terminal notifications for system runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
@ -62,7 +63,7 @@
$retentionRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'backup_schedule_retention')
->where('type', OperationRunType::BackupScheduleRetention->value)
->latest('id')
->first();

View File

@ -104,19 +104,17 @@ function errorPermission(string $key, array $features = []): array
->and($finding->resolved_reason)->toBe('permission_granted');
});
// (3) Auto-resolves acknowledged finding preserving metadata
it('auto-resolves acknowledged finding preserving acknowledged metadata', function (): void {
// (3) Auto-resolves triaged finding preserving triaged metadata
it('auto-resolves triaged finding preserving triaged metadata', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = app(PermissionPostureFindingGenerator::class);
$generator->generate($tenant, buildComparison([missingPermission('Perm.A')]));
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
$ackUser = User::factory()->create();
$finding->forceFill([
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $ackUser->getKey(),
'status' => Finding::STATUS_TRIAGED,
'triaged_at' => now(),
])->save();
$result = $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted'));
@ -124,8 +122,7 @@ function errorPermission(string $key, array $features = []): array
$finding->refresh();
expect($result->findingsResolved)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_RESOLVED)
->and($finding->acknowledged_at)->not->toBeNull()
->and($finding->acknowledged_by_user_id)->toBe($ackUser->getKey());
->and($finding->triaged_at)->not->toBeNull();
});
// (4) No duplicates on idempotent run

View File

@ -11,7 +11,7 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue();

View File

@ -11,7 +11,7 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue();

View File

@ -11,7 +11,7 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue();

View File

@ -16,7 +16,7 @@
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse();

View File

@ -24,10 +24,10 @@
->and($spec->color)->toBe('warning');
});
it('still renders acknowledged status badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED);
it('renders unknown for removed acknowledged status badges', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged');
expect($spec->label)->toBe('Triaged')
expect($spec->label)->toBe('Unknown')
->and($spec->color)->toBe('gray');
});

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationalControls\OperationalControlCatalog;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('does not expose findings lifecycle backfill in operational controls or the control catalog', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
$this->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertDontSee('Findings lifecycle backfill')
->assertDontSee("mountAction('pause_findings_lifecycle_backfill')", escape: false)
->assertDontSee("mountAction('resume_findings_lifecycle_backfill')", escape: false)
->assertDontSee("mountAction('view_history_findings_lifecycle_backfill')", escape: false);
$catalog = app(OperationalControlCatalog::class);
expect($catalog->keys())->not->toContain('findings.lifecycle.backfill')
->and(fn (): array => $catalog->definition('findings.lifecycle.backfill'))
->toThrow(InvalidArgumentException::class);
});

View File

@ -1,87 +0,0 @@
<?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

@ -1,111 +0,0 @@
<?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

@ -1,78 +0,0 @@
<?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

@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
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('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);
});
it('accepts an allowed single-tenant selection during preflight', 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,
]);
$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(\App\Filament\System\Pages\Ops\Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertHasNoActionErrors()
->assertSet('findingsTenantId', (int) $tenant->getKey())
->assertSet('preflight.affected_count', 1);
});
it('rejects platform tenant selection during preflight', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$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');
assertScopedSelectorRejected(
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class),
'preflight',
[
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $platformTenant->getKey(),
],
);
});
it('resets to an all-tenant trusted scope even when stale single-tenant selector state remains on the page', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenantA = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
'name' => 'Scope Tenant A',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
'name' => 'Scope Tenant B',
]);
Finding::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'due_at' => null,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenantB->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(\App\Filament\System\Pages\Ops\Runbooks::class)
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('findingsTenantId', (int) $tenantA->getKey())
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('tenantId', (int) $tenantA->getKey())
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
'tenant_id' => (int) $tenantA->getKey(),
])
->assertHasNoActionErrors()
->assertSet('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->assertSet('findingsTenantId', null)
->assertSet('scopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->assertSet('tenantId', null)
->assertSet('preflight.estimated_tenants', 2)
->assertSet('preflight.affected_count', 2);
});

View File

@ -1,253 +0,0 @@
<?php
declare(strict_types=1);
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',
]);
});
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']);
});
it('rejects forged single-tenant selector state on run and records no run or start audit', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$allowedTenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $allowedTenant->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_SINGLE_TENANT,
'tenant_id' => (int) $allowedTenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('findingsTenantId', (int) $platformTenant->getKey())
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('tenantId', (int) $platformTenant->getKey())
->callAction('run', data: [])
->assertHasActionErrors();
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
});
it('records a start audit with the canonical single-tenant scope when an allowed run is queued', 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_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertHasNoActionErrors()
->assertNotified('Findings lifecycle backfill queued');
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->latest('id')
->first();
expect($run)->not->toBeNull();
$audit = AuditLog::query()
->where('action', 'platform.ops.runbooks.start')
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_id)->toBe((string) $run?->getKey())
->and($audit?->metadata['scope'] ?? null)->toBe(FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->and($audit?->metadata['target_tenant_id'] ?? null)->toBe((int) $tenant->getKey())
->and($audit?->metadata['operation_run_id'] ?? null)->toBe((int) $run?->getKey());
});
it('returns 403 for runbook execution when the platform user is in scope but lacks run capability', 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,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertForbidden();
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
});

View File

@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationalControlActivation;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Support\Audit\AuditActionId;
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 (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('blocks all-tenant findings lifecycle runbooks when the control is globally paused', function (): void {
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,
]);
OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'findings.lifecycle.backfill',
'reason_text' => 'Paused during incident response.',
]);
$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' => 'all_tenants',
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [
'typed_confirmation' => 'BACKFILL',
'reason_code' => 'DATA_REPAIR',
'reason_text' => 'Attempt blocked by control',
])
->assertNotified('Findings lifecycle backfill paused');
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
$audit = AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBeNull()
->and($audit?->tenant_id)->toBeNull()
->and($audit?->status)->toBe('blocked')
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
->and($audit?->metadata['requested_scope'] ?? null)->toBe('all_tenants');
});

View File

@ -1,89 +0,0 @@
<?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('Operation #'.(int) $run?->getKey());
});

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('keeps the system runbooks page accessible without findings lifecycle backfill launch surfaces', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
$this->get(Runbooks::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee('No supported runbooks')
->assertDontSee('Rebuild Findings Lifecycle')
->assertDontSee('Backfills legacy findings lifecycle fields')
->assertDontSee('Preflight')
->assertDontSee('preflight')
->assertDontSee('Run: Rebuild Findings Lifecycle')
->assertDontSee('BACKFILL');
Livewire::test(Runbooks::class)
->assertActionDoesNotExist('preflight')
->assertActionDoesNotExist('run');
});
it('preserves runbooks view authorization semantics after removing the backfill runbook', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Runbooks::getUrl(panel: 'system'))
->assertForbidden();
});

View File

@ -6,6 +6,7 @@
use App\Services\Providers\ProviderOperationStartResult;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
@ -36,7 +37,7 @@
$run = $result->run;
expect($run->type)->toBe('directory_role_definitions.sync');
expect($run->type)->toBe(OperationRunType::DirectoryRoleDefinitionsSync->value);
expect($run->context['provider_connection_id'] ?? null)->toBeInt();
$url = OperationRunLinks::tenantlessView($run);

View File

@ -2,6 +2,8 @@
declare(strict_types=1);
use App\Models\Finding;
it('passes shared canonical control references through tenant review composition', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 1);
@ -14,5 +16,30 @@
->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1)
->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($openRisks->summary_payload['canonical_controls'])->toBe([]);
->and($openRisks->summary_payload['canonical_controls'][0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance');
});
it('excludes removed acknowledged findings from open risk highlights', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => 'acknowledged',
'subject_external_id' => 'legacy-acknowledged',
]);
$triagedFinding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => 'canonical-triaged',
]);
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$openRisks = $review->sections->firstWhere('section_key', 'open_risks');
$entries = $openRisks->render_payload['entries'] ?? [];
expect($entries)->toHaveCount(1)
->and($entries[0]['id'] ?? null)->toBe((int) $triagedFinding->getKey())
->and(collect($entries)->pluck('status')->all())->not->toContain('acknowledged');
});

View File

@ -23,7 +23,7 @@
->assertSee('Tenant Panel Entry')
->assertSee('Switch tenant')
->assertSee('Clear tenant scope')
->assertDontSee('Search tenants…')
->assertDontSee(__('localization.shell.search_tenants'))
->assertDontSee('admin/select-tenant');
});

View File

@ -33,7 +33,7 @@
expect($triaged->color)->toBe('gray');
$legacyAcknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged');
expect($legacyAcknowledged->label)->toBe('Triaged');
expect($legacyAcknowledged->label)->toBe('Unknown');
expect($legacyAcknowledged->color)->toBe('gray');
$inProgress = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'in_progress');

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
it('exposes only canonical open statuses for findings', function (): void {
expect(Finding::openStatuses())->toBe([
Finding::STATUS_NEW,
Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS,
Finding::STATUS_REOPENED,
]);
expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses());
});
it('does not treat acknowledged as a canonical open or terminal status', function (): void {
expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged');
expect(Finding::isOpenStatus('acknowledged'))->toBeFalse();
expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse();
});

View File

@ -9,15 +9,14 @@
$types = app(OperationLifecyclePolicy::class)->coveredTypeNames();
expect($types)->toBe([
'baseline_capture',
'baseline_compare',
'inventory_sync',
'baseline.capture',
'baseline.compare',
'inventory.sync',
'policy.sync',
'policy.sync_one',
'entra_group_sync',
'directory_role_definitions.sync',
'directory.groups.sync',
'directory.role_definitions.sync',
'backup_set.update',
'backup_schedule_run',
'backup.schedule.execute',
'restore.execute',
'tenant.review_pack.generate',
'tenant.review.compose',
@ -28,19 +27,19 @@
it('requires direct failed-job bridges for lifecycle policy entries that declare them', function (): void {
$validator = app(OperationLifecyclePolicyValidator::class);
expect($validator->jobUsesDirectFailedBridge('baseline_capture'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('baseline_compare'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('inventory_sync'))->toBeTrue()
expect($validator->jobUsesDirectFailedBridge('baseline.capture'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('baseline.compare'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('inventory.sync'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('policy.sync'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('tenant.review.compose'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('backup_schedule_run'))->toBeFalse();
->and($validator->jobUsesDirectFailedBridge('backup.schedule.execute'))->toBeFalse();
});
it('requires explicit timeout and fail-on-timeout declarations for covered jobs', function (): void {
$validator = app(OperationLifecyclePolicyValidator::class);
expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300)
->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue()
expect($validator->jobTimeoutSeconds('baseline.capture'))->toBe(300)
->and($validator->jobFailsOnTimeout('baseline.capture'))->toBeTrue()
->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240)
->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue()
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use App\Support\Auth\PlatformCapabilities;
it('removes the platform capability and seeder grant for findings lifecycle backfill', function (): void {
expect(defined(PlatformCapabilities::class.'::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL'))->toBeFalse()
->and(PlatformCapabilities::all())->not->toContain('platform.runbooks.findings.lifecycle_backfill');
expect((string) file_get_contents(database_path('seeders/PlatformUserSeeder.php')))
->not->toContain('RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL')
->not->toContain('platform.runbooks.findings.lifecycle_backfill');
});

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Support\Filament\FilterOptionCatalog;
it('exposes only canonical finding statuses in the shared filter catalog', function (): void {
expect(FilterOptionCatalog::findingStatuses())->toBe([
Finding::STATUS_NEW => 'New',
Finding::STATUS_TRIAGED => 'Triaged',
Finding::STATUS_IN_PROGRESS => 'In progress',
Finding::STATUS_REOPENED => 'Reopened',
Finding::STATUS_RESOLVED => 'Resolved',
Finding::STATUS_CLOSED => 'Closed',
Finding::STATUS_RISK_ACCEPTED => 'Risk accepted',
]);
});
it('does not offer acknowledged as a legacy findings filter option', function (): void {
expect(FilterOptionCatalog::findingStatuses())->not->toHaveKey('acknowledged');
});

View File

@ -15,6 +15,7 @@
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -64,6 +65,7 @@
OperationRun::factory()
->forTenant($bravoTenant)
->create([
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(6),

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use App\Support\OperationCatalog;
it('removes findings lifecycle backfill from the supported operation catalog', function (): void {
expect(OperationCatalog::canonicalInventory())->not->toHaveKey('findings.lifecycle.backfill')
->and(OperationCatalog::aliasInventory())->not->toHaveKey('findings.lifecycle.backfill')
->and(OperationCatalog::rawValuesForCanonical('findings.lifecycle.backfill'))->toBe([])
->and(OperationCatalog::label('findings.lifecycle.backfill'))->toBe('Unknown operation');
});

View File

@ -53,6 +53,29 @@
]);
});
it('records a tenant-owned usage event for an archived tenant', function () {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->archived()->create();
$user = User::factory()->create();
$event = app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant_onboarding_session',
subjectId: 99,
metadata: [
'checkpoint_key' => 'verify_access',
'lifecycle_state' => 'draft',
],
);
expect($event->workspace_id)->toBe((int) $workspace->getKey())
->and($event->tenant_id)->toBe((int) $tenant->getKey())
->and($event->feature_area)->toBe('onboarding');
});
it('rejects unknown event names before writing telemetry rows', function () {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->create();

View File

@ -473,7 +473,6 @@ ### Deployment: Dokploy (staging → production)
### Platform runbooks
- `FindingsLifecycleBackfillRunbookService` ([app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php](app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php)) — safe backfill of findings lifecycle fields
- Accessible at `/system/ops/runbooks` with platform capabilities
---

View File

@ -0,0 +1,48 @@
# Specification Quality Checklist: Remove Findings Lifecycle Backfill Runtime Surfaces
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-28
**Feature**: specs/253-remove-findings-backfill-runtime-surfaces/spec.md
## Content Quality
- [x] No language/framework/API design leakage; concrete repo surfaces, commands, and labels are named only because this cleanup deletes those exact shipped traces.
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No unintended implementation design leakage remains beyond the explicit cleanup special-case for named repo-visible traces
## Test Governance Review
- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, plus one retained `heavy-governance` guard in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` so operational-control bypass residue cannot survive the cleanup silently.
- [x] No new browser or heavy-governance family is introduced; the retained guard stays explicit, bounded, and tied to operational-control source-trace removal only.
- [x] Suite-cost outcome is net-negative: backfill-only tests, lane traces, and helper residue are removed in the same slice instead of widening shared defaults.
## Review Outcome
- [x] Review outcome class: `acceptable-special-case`
- [x] Workflow outcome: `keep`
- [x] Review-note location is explicit: the heavy-governance retention note lives in `spec.md`, `plan.md`, `tasks.md`, and the final preparation report.
## Notes
- The spec intentionally names concrete routes, commands, labels, and catalog keys because the product value of this slice is the removal of those specific repo-visible runtime surfaces.
- The slice stays small by deleting visible repair tooling only; acknowledged-status cleanup and creation-time invariant hardening remain explicit follow-up candidates.
- Validation pass complete: no clarification markers remain, LEAN-001 cleanup posture is explicit, and tenant-owned findings continue to treat `workspace_id` plus `tenant_id` as required anchors.

View File

@ -0,0 +1,108 @@
version: 1
kind: findings-backfill-runtime-surface-removal
scope:
goal: remove findings lifecycle backfill runtime and product surfaces only
non_goals:
- acknowledged-status semantics cleanup
- creation-time finding invariant hardening
- replacement repair surface
- historical data migration
- compatibility alias or no-op command preservation
removed_entry_points:
system_runbooks:
route: /system/ops/runbooks
removed_labels:
- Rebuild Findings Lifecycle
- Preflight
- Run…
owner_files:
- apps/platform/app/Filament/System/Pages/Ops/Runbooks.php
- apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php
tenant_findings:
route: /admin/t/{tenant}/findings
removed_labels:
- Backfill findings lifecycle
- Open operation
owner_files:
- apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php
- apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php
console:
removed_commands:
- tenantpilot:findings:backfill-lifecycle
- tenantpilot:run-deploy-runbooks
owner_files:
- apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
- apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php
deploy_runtime:
requirement:
- no deploy hook, runtime hook, schedule, or bootstrap path may queue or start findings lifecycle backfill after cleanup
removed_runtime_cluster:
service:
- apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php
jobs:
- apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
- apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php
- apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php
operation_type:
- findings.lifecycle.backfill
removed_registry_traces:
capabilities:
- platform.runbooks.findings.lifecycle_backfill
seeders:
- apps/platform/database/seeders/PlatformUserSeeder.php
operation_catalog:
canonical_types:
- findings.lifecycle.backfill
aliases:
- findings.lifecycle.backfill
system_console_triage:
retryable_types:
- findings.lifecycle.backfill
cancelable_types:
- findings.lifecycle.backfill
operational_controls:
notes:
- the active operational-control catalog already rejects findings.lifecycle.backfill
- remaining blocked-start branches and tests for that key must be removed rather than normalized
retained_behavior:
findings_workflow_actions:
- triage
- start_progress
- assign
- resolve
- close
- request_exception
- reopen
guarantees:
- findings workflow status, ownership, SLA, due-date, and reviewable behavior remain unchanged
- no replacement repair or maintenance surface is introduced
legacy_data_posture:
operation_runs:
historical rows may remain stored without new canonical alias, retry, cancel, or operator-UX guarantee
audit_logs:
historical start, blocked, completed, or failed rows may remain stored without migration or compatibility layer
validation_expectations:
no_new_side_effects:
- no supported surface may create a new OperationRun of type findings.lifecycle.backfill
- no supported surface may dispatch the deleted backfill jobs
absence_proof:
- system runbooks page exposes no findings lifecycle backfill card or action
- tenant findings page exposes no findings lifecycle backfill header action
- supported command catalog exposes no findings lifecycle backfill command entry
- operational-control and operation-label surfaces expose no live findings lifecycle backfill trace
regression_proof:
- representative canonical findings workflow actions still succeed unchanged
lane_classification:
required:
- fast-feedback
- confidence
- heavy-governance
excluded:
- browser

View File

@ -0,0 +1,121 @@
# Data Model — Remove Findings Lifecycle Backfill Runtime Surfaces
**Spec**: [spec.md](spec.md)
This feature is subtractive. It introduces no new persisted truth and no migration. The data-model impact is the removal of one obsolete runtime family and the reaffirmation of the canonical findings workflow as the only supported path.
## Existing Canonical Entities Reused
### Finding (`findings`)
**Purpose**: Tenant-owned findings workflow truth.
**Key fields (existing)**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `triaged_at`
- `first_seen_at`
- `last_seen_at`
- `times_seen`
- `sla_days`
- `due_at`
**Feature use**:
- Remains the canonical workflow truth for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable behavior.
- Continues to require both `workspace_id` and `tenant_id` as non-null ownership anchors.
- Is in scope only for regression protection, not for lifecycle redesign.
### OperationRun (`operation_runs`)
**Purpose**: Existing canonical execution truth for supported long-running operations.
**Key fields (existing)**:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `context`
**Feature use**:
- After cleanup, no supported system, tenant, CLI, or deploy/runtime path may create a new `OperationRun` with `type = findings.lifecycle.backfill`.
- Historical rows may remain stored as legacy data, but the feature does not preserve special retry, cancel, label, or alias handling for them.
### AuditLog (`audit_logs`)
**Purpose**: Existing audit truth for prior lifecycle-backfill starts, blocked starts, and completions.
**Feature use**:
- No new audit action family is introduced.
- Historical rows may remain stored without new cleanup migration or compatibility layer.
- Canonical findings workflow audit behavior remains unchanged and is protected through regression testing.
### OperationalControlActivation (`operational_control_activations`)
**Purpose**: Existing runtime-safety truth for live operational controls.
**Feature use**:
- The cleanup should not add or preserve a `findings.lifecycle.backfill` control key.
- Existing backfill-specific blocked-start branches and tests should be removed because the active control catalog already rejects the key.
## Removed Runtime Families
### FindingsLifecycleBackfillSurface (derived, non-persisted)
**Purpose**: Describes each currently productized entry point that must disappear in the cleanup.
**Runtime fields**:
- `surface_id` — unique identifier such as `system.ops.runbooks`, `tenant.findings.list`, `console.tenantpilot.findings.backfill-lifecycle`, or `console.tenantpilot.run-deploy-runbooks`
- `entry_type``runbook`, `header_action`, `command`, `deploy_hook`, `operation_label`, `capability_trace`, or `test_trace`
- `operator_label` — current visible product label such as `Rebuild Findings Lifecycle` or `Backfill findings lifecycle`
- `owner_path` — current source file that makes the surface real
- `start_seam` — shared service or registry seam that currently powers the entry point
**Feature use**:
- Drives removal planning so the cleanup deletes the source of truth for each surface instead of only hiding one page affordance.
### FindingsLifecycleBackfillExecutionCluster (derived, non-persisted)
**Purpose**: The dedicated runtime chain that currently starts, queues, and finalizes lifecycle backfill.
**Current members**:
- `FindingsLifecycleBackfillRunbookService`
- `TenantpilotBackfillFindingLifecycle`
- `TenantpilotRunDeployRunbooks`
- `BackfillFindingLifecycleJob`
- `BackfillFindingLifecycleWorkspaceJob`
- `BackfillFindingLifecycleTenantIntoWorkspaceRunJob`
**Lifecycle rule**:
- The cluster is deleted in the same slice. No dormant flag, replacement command, or service shim is retained.
### FindingsLifecycleBackfillTrace (derived, non-persisted)
**Purpose**: Registry, catalog, seed, test, and doc references that still advertise lifecycle backfill as supported behavior.
**Trace fields**:
- `trace_type``capability`, `seeder`, `operation_type`, `operation_alias`, `triage_support`, `control_branch`, `test`, `guard`, or `doc`
- `identifier` — exact key such as `platform.runbooks.findings.lifecycle_backfill` or `findings.lifecycle.backfill`
- `owner_path` — file that currently carries the trace
- `removal_reason` — why the trace must disappear with the runtime surface
**Feature use**:
- Ensures cleanup removes registry and test ballast in the same slice instead of leaving the repo to advertise deleted behavior indirectly.
## Data Ownership Notes
- No new tables, settings, or persisted aliases are introduced.
- No migration, historical data rewrite, or archival compatibility layer is planned.
- Historical `OperationRun` and `AuditLog` rows are tolerated legacy data and do not justify preserving the removed runtime path.
- Findings remain tenant-owned and continue to require both `workspace_id` and `tenant_id` as canonical ownership anchors.
- Operational-control truth remains bounded to currently supported controls only; this slice should not keep a removed backfill control key alive through hidden test fixtures or service branches.
## Removal Invariants
- No supported path may create a new `OperationRun` with `type = findings.lifecycle.backfill`.
- No supported page, command catalog, or deploy/runtime hook may advertise lifecycle backfill as an available operator action.
- No compatibility shim, no-op command shell, or fallback alias may remain for the removed path.
- Canonical findings workflow behavior remains unchanged and continues to operate on the existing `Finding` truth.

View File

@ -0,0 +1,237 @@
# Implementation Plan: Remove Findings Lifecycle Backfill Runtime Surfaces
**Branch**: `253-remove-findings-backfill-runtime-surfaces` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Remove all shipped findings lifecycle backfill runtime and product surfaces by deleting the owning backfill service, jobs, commands, and registry traces instead of hiding labels locally.
- Preserve the normal findings workflow exactly as-is for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable finding behavior, with regression proof kept explicit.
- Keep the slice narrow: acknowledged-status cleanup and creation-time lifecycle invariant hardening remain separate follow-up specs, and OperationRun semantics are touched only by removing one obsolete runbook path rather than inventing a new run UX abstraction.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
**Storage**: PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned
**Testing**: Pest feature tests plus narrow unit and guard coverage
**Validation Lanes**: fast-feedback, confidence, heavy-governance
**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` surfaces, platform `/system` surfaces, and Artisan command/runtime entry points
**Project Type**: web
**Performance Goals**: after cleanup, no supported surface may enqueue or start `findings.lifecycle.backfill`; surviving findings workflows retain their current performance profile; the slice should reduce runtime and test surface rather than add new overhead
**Constraints**: LEAN-001 replacement over compatibility shims; no replacement repair surface; no findings semantics redesign; no data migration; preserve current `404` vs `403` isolation semantics; no new Filament panel/provider work; no new assets or `filament:assets` deploy changes
**Scale/Scope**: 1 cleanup slice touching 3 operator-facing surfaces, 2 console entry points, 1 shared runbook service cluster, 3 dedicated jobs, capability/operation/triage traces, and the backfill-specific test and docs footprint
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament plus existing shared action and run UX primitives
- **Shared-family relevance**: header actions, runbook launch cards, operation labeling, operational-control and paused-state messaging, command/runtime entry points
- **State layers in scope**: page, action/modal, detail/read-model
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first / diagnostics-second / support-raw-third
- **Raw/support gating plan**: existing capability-gated diagnostics only; no new raw or support surface is introduced
- **One-primary-action / duplicate-truth control**: remove the maintenance CTA so tenant findings pages keep only canonical findings workflow actions and system pages keep only supported runbooks and supported controls
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none; source-trace removal is mandatory wherever shared registries or helper paths still emit findings backfill semantics
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `App\Filament\System\Pages\Ops\Runbooks`, `App\Filament\Resources\FindingResource\Pages\ListFindings`, `App\Console\Commands\TenantpilotBackfillFindingLifecycle`, `App\Console\Commands\TenantpilotRunDeployRunbooks`, `App\Services\Runbooks\FindingsLifecycleBackfillRunbookService`, `App\Services\Runbooks\FindingsLifecycleBackfillScope`, `App\Jobs\BackfillFindingLifecycleJob`, `App\Jobs\BackfillFindingLifecycleWorkspaceJob`, `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob`, `App\Support\OperationCatalog`, `App\Support\Auth\PlatformCapabilities`, `App\Services\SystemConsole\OperationRunTriageService`, `App\Support\Livewire\TrustedState\TrustedStatePolicy`, `App\Support\Ui\ActionSurface\ActionSurfaceExemptions`, `Database\Seeders\PlatformUserSeeder`, and the related findings/runbook/console/control tests
- **Shared abstractions reused**: `UiEnforcement`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `OperationRunLinks`, `SystemOperationRunLinks`, `OperationRunService`, `OperationCatalog`, `AuditRecorder`, and `WorkspaceAuditLogger`
- **New abstraction introduced? why?**: none; this is a subtractive cleanup that removes one bounded operation family and its traces
- **Why the existing abstraction was sufficient or insufficient**: the existing shared abstractions are sufficient for surviving findings workflows and surviving operations. The cleanup must converge those shared families back to supported product truth instead of layering a replacement backfill path or compatibility wrapper on top.
- **Bounded deviation / spread control**: none; where a shared catalog, capability registry, triage list, or test helper still names `findings.lifecycle.backfill`, the source trace itself must be removed instead of locally masked
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes
- **Central contract reused**: existing shared OperationRun UX layer for surviving operation types only
- **Delegated UX behaviors**: `N/A` for the removed findings lifecycle backfill path after cleanup; queued toast, `View run` or `Open operation` links, dedupe messaging, browser events, and terminal notifications remain unchanged for every other operation type
- **Surface-owned behavior kept local**: none for the removed path
- **Queued DB-notification policy**: `N/A` for the removed path
- **Terminal notification path**: existing central lifecycle mechanism for surviving operations remains unchanged
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: `N/A`
- **Neutral platform terms / contracts preserved**: `N/A`
- **Retained provider-specific semantics and why**: none
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Read/write separation: PASS - the slice removes shipped write entry points and introduces no new mutating path
- Inventory-first / snapshots-second: PASS - inventory, backups, and snapshots are untouched
- Graph contract path: PASS - no Microsoft Graph surface changes are involved
- Deterministic capabilities: PASS - one platform capability constant and its seeder grant are removed rather than expanded; no new capability namespace is introduced
- RBAC-UX: PASS - `/system` remains platform-only, `/admin/t/{tenant}` remains tenant-scoped, non-members stay `404`, in-scope users missing capability on surviving actions stay `403`, and cleanup must not widen or hide unrelated authorization semantics
- Workspace isolation / tenant isolation: PASS - findings remain tenant-owned with required `workspace_id` and `tenant_id` anchors; no new cross-tenant surface or leakage path is introduced
- OperationRun observability / Ops-UX: PASS - the feature removes one `OperationRun` start surface only; no new run type, no new feedback surface, and no local start UX dialect are introduced
- OperationRun lifecycle ownership: PASS - no new lifecycle transition path is introduced; surviving operations remain service-owned
- Automation / locking: PASS - queued backfill runtime paths are deleted rather than extended
- Data minimization: PASS - no new persisted data or audit payload family is introduced
- Test governance (`TEST-GOV-001`): PASS - proof remains in narrow feature plus unit lanes, with one retained heavy-governance source-scanning guard kept explicit because operational-control bypass residue must still be blocked after the control key and runbook service are removed
- Proportionality (`PROP-001`) and no premature abstraction (`ABSTR-001`): PASS - the feature removes an obsolete runtime family and adds no new abstraction layer
- Persisted truth (`PERSIST-001`): PASS - no new table, entity, artifact, or alias layer is introduced
- Behavioral state (`STATE-001`): PASS - no new status or reason family is added; acknowledged cleanup remains a separate follow-up
- UI semantics (`UI-SEM-001`): PASS - no new presentation taxonomy is added; labels disappear together with the runtime path
- Shared pattern first (`XCUT-001`): PASS - the cleanup converges shared runbook, action, audit, and operation-label families by deletion instead of creating a parallel exception path
- Provider boundary (`PROV-001`): PASS - no provider/platform seam changes are introduced
- V1 explicitness / few layers (`V1-EXP-001`, `LAYER-001`): PASS - the narrowest solution is direct replacement and deletion, not shims or wrappers
- Bloat check (`SPEC-DISC-001`, `BLOAT-001`): PASS - the slice is explicitly subtractive and keeps broader semantics work separate
- Filament-native UI (`UI-FIL-001`): PASS - touched surfaces stay native Filament pages and actions; no custom UI framework or asset registration is needed
- Global search rule: PASS - no new searchable resource is added, and this cleanup only removes a header action from the existing findings resource rather than changing its search contract
- Panel/provider registration: PASS - Filament v5 remains on Livewire v4, and no panel or provider registration change is planned; Laravel 12 provider registration remains in `bootstrap/providers.php` if ever needed later
- Asset strategy: PASS - no new panel or shared assets are planned, so no additional `filament:assets` deploy work is introduced
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature for system runbook removal, tenant findings action removal, console-entry removal, and findings workflow regression; Unit or guard coverage for operation catalog, capability, triage-list, and source-scanning trace cleanup
- **Affected validation lanes**: fast-feedback, confidence, heavy-governance
- **Why this lane mix is the narrowest sufficient proof**: the business truth is server-side surface removal plus unchanged canonical findings workflow behavior. Fast-feedback and confidence cover the runtime behavior directly. One retained heavy-governance source-scanning guard is still needed because the cleanup removes an operational-control key and runbook-service entry point that the existing guard already protects from ad-hoc bypass drift.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php`
- **Fixture / helper / factory / seed / context cost risks**: remove or collapse backfill-only fixtures, control activations, and command test setup; keep findings workflow fixtures opt-in and local to regression tests
- **Expensive defaults or shared helper growth introduced?**: no; expected net-negative because a dedicated backfill family and its source-scanning guard expectations should shrink
- **Heavy-family additions, promotions, or visibility changes**: no new heavy family is added, but one existing heavy-governance guard remains explicit in the plan because the cleanup still depends on `tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
- **Surface-class relief / special coverage rule**: standard-native-filament and monitoring-state-page relief are sufficient; assert absence and no side effects rather than browser-only choreography
- **Closing validation and reviewer handoff**: reviewers should rerun the targeted commands, including the retained heavy-governance bypass guard, verify that no UI, CLI, deploy, control, catalog, capability, triage, trusted-state, or action-surface trace remains for `findings.lifecycle.backfill`, and confirm representative triage, assignment, progress, resolve, risk acceptance, ownership, SLA, and due-date findings flows still behave unchanged
- **Budget / baseline / trend follow-up**: expected net decrease in focused feature and guard surface
- **Review-stop questions**: did implementation leave a no-op compatibility shell, keep a hidden operation alias, preserve a dead blocked-state branch after the control key was already removed, or widen into acknowledged-status cleanup or lifecycle invariant redesign?
- **Escalation path**: reject-or-split
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the cleanup is a bounded subtractive slice; deeper findings semantics and creation-time invariant work already have explicit follow-up candidates instead of hidden spillover
## Project Structure
### Documentation (this feature)
```text
specs/253-remove-findings-backfill-runtime-surfaces/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── checklists/
│ └── requirements.md
├── contracts/
│ └── findings-backfill-runtime-surface-removal.contract.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Console/Commands/
│ │ ├── TenantpilotBackfillFindingLifecycle.php
│ │ └── TenantpilotRunDeployRunbooks.php
│ ├── Filament/
│ │ ├── Resources/FindingResource/Pages/ListFindings.php
│ │ └── System/Pages/Ops/Runbooks.php
│ ├── Jobs/
│ │ ├── BackfillFindingLifecycleJob.php
│ │ ├── BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php
│ │ └── BackfillFindingLifecycleWorkspaceJob.php
│ ├── Services/
│ │ ├── Runbooks/FindingsLifecycleBackfillRunbookService.php
│ │ ├── Runbooks/FindingsLifecycleBackfillScope.php
│ │ └── SystemConsole/OperationRunTriageService.php
│ └── Support/
│ ├── Auth/PlatformCapabilities.php
│ ├── Livewire/TrustedState/TrustedStatePolicy.php
│ ├── OperationCatalog.php
│ └── Ui/ActionSurface/ActionSurfaceExemptions.php
├── database/seeders/PlatformUserSeeder.php
└── tests/
├── Feature/
│ ├── Console/Spec113/DeployRunbooksCommandTest.php
│ ├── Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php
│ ├── Findings/OperationalControlFindingsBackfillGateTest.php
│ ├── OperationalControls/NoAdHocOperationalControlBypassTest.php
│ ├── System/OpsControls/OperationalControlManagementTest.php
│ └── System/OpsRunbooks/
│ ├── FindingsLifecycleBackfillPreflightTest.php
│ ├── FindingsLifecycleBackfillStartTest.php
│ ├── OpsUxStartSurfaceContractTest.php
│ └── OperationalControlRunbookGateTest.php
└── Unit/Support/OperationalControls/OperationalControlCatalogTest.php
```
**Structure Decision**: Single Laravel web application. The implementation slice is subtractive and should stay inside the existing system page, tenant findings page, console command, shared runbook service, registry, and test directories instead of creating a new namespace or framework.
## Complexity Tracking
No constitution violations are expected. This feature should reduce permanent complexity by deleting a productized repair path, its queue jobs, and its trace surface.
## Proportionality Review
- **Current operator problem**: the repo still productizes a findings lifecycle repair path through runbooks, tenant findings actions, commands, operation labels, and tests even though current finding generators already write the required lifecycle fields directly
- **Existing structure is insufficient because**: a local hide or feature flag would leave the service, jobs, commands, operation labels, triage support, and backfill-only tests alive, so the product would keep advertising deleted behavior through other surfaces
- **Narrowest correct implementation**: delete the owning backfill service and job cluster, remove the UI and command entry points, remove capability and operation traces, and keep canonical findings workflows unchanged with targeted regression proof
- **Ownership cost created**: negative; maintenance burden, suite cost, and operator confusion should all decrease
- **Alternative intentionally rejected**: compatibility shims, no-op deploy command shells, historical alias preservation, or folding acknowledged-status cleanup and lifecycle invariant hardening into the same slice
- **Release truth**: current-release truth
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/research.md`
Goals:
- Confirm the narrowest deletion boundary across system UI, tenant UI, CLI, deploy/runtime hooks, jobs, registry traces, and test artifacts.
- Confirm that LEAN-001 requires removal over compatibility shims for the backfill service, commands, operation aliases, and historical run UX support.
- Record the partial operational-control cleanup already present in the repo so implementation removes remaining dead branches instead of reintroducing the control key.
- Keep acknowledged-status cleanup and creation-time lifecycle invariants explicitly deferred while documenting the regression contract for normal findings workflows.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`
Design focus:
- Remove the `Rebuild Findings Lifecycle` system runbook card, preflight, run modal, and related `OperationRun` launch UX from `Runbooks.php`.
- Remove the tenant findings header action `Backfill findings lifecycle` and its queued, paused, and `Open operation` messaging from `ListFindings.php` while leaving canonical findings workflow actions untouched.
- Delete `TenantpilotBackfillFindingLifecycle`, and delete `TenantpilotRunDeployRunbooks` if its only shipped responsibility remains lifecycle backfill.
- Delete `FindingsLifecycleBackfillRunbookService` and the dedicated workspace plus tenant jobs that exist only to support the removed runtime path.
- Remove `findings.lifecycle.backfill` traces from `PlatformCapabilities`, `PlatformUserSeeder`, `OperationCatalog`, `OperationRunTriageService`, test guards, docs, and backfill-specific feature tests.
- Remove `FindingsLifecycleBackfillScope.php`, the backfill-specific trusted-state markers in `TrustedStatePolicy.php`, and the backfill-specific action-surface evidence in `ActionSurfaceExemptions.php` so no hidden surface or helper residue still implies support.
- Treat current operational-control residue as cleanup input: the control catalog already rejects `findings.lifecycle.backfill`, so remaining blocked-start branches and tests should be removed rather than normalized.
- Keep historical `OperationRun` and `AuditLog` rows as tolerated legacy data without adding alias layers, migrations, or new UI promises.
## Phase 1 — Agent Context Update
After Phase 1 artifacts are generated, update Copilot context from the plan:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
- Remove the system runbook entry points and the tenant findings header action for lifecycle backfill.
- Delete the dedicated CLI and deploy/runtime command entry points for lifecycle backfill.
- Delete the shared runbook service and the dedicated workspace or tenant backfill jobs.
- Remove capability, seeder, operation-catalog, and system-console triage traces for `findings.lifecycle.backfill`.
- Rewrite or delete backfill-only tests and docs, then add narrow absence plus regression coverage that proves the path stays gone while canonical findings workflows still work.
- Verify no compatibility shim, no-op command shell, or replacement repair path survives the cleanup.
## Constitution Check (Post-Design)
Re-check target: PASS. The post-design shape must still be net-negative complexity, contain no new persistence or abstraction, preserve Livewire v4 plus Filament v5 conventions, leave provider registration unchanged in `bootstrap/providers.php`, keep global search behavior unchanged, and keep the validation burden inside fast-feedback and confidence plus one explicit retained heavy-governance bypass guard only.

View File

@ -0,0 +1,36 @@
# Quickstart — Remove Findings Lifecycle Backfill Runtime Surfaces
## Prereqs
- Docker running
- Laravel Sail dependencies installed
- Existing platform-operator and tenant-user factories available for targeted tests
- Existing findings workflow fixtures available for regression coverage
## Run locally
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
- No schema change is expected, but use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction`
- Run targeted tests after implementation:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php`
- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Manual smoke after implementation
1. Sign in to `/system` as a platform operator and confirm `/system/ops/runbooks` no longer shows `Rebuild Findings Lifecycle`, its preflight action, or its run modal.
2. Sign in to `/admin/t/{tenant}/findings` as an entitled tenant operator and confirm there is no `Backfill findings lifecycle` header action while canonical findings workflow actions still render according to current capability rules.
3. Open `/system/ops/controls` and confirm there is no findings lifecycle backfill control row, action, or history affordance.
4. Check the supported Artisan command catalog and confirm `tenantpilot:findings:backfill-lifecycle` is gone, and `tenantpilot:run-deploy-runbooks` is also gone if backfill was its only remaining shipped responsibility.
5. Exercise representative findings actions such as `Triage`, `Start progress`, `Assign`, `Resolve`, and `Risk accept` and confirm the existing workflow behavior is unchanged.
6. Open Monitoring or Operations and confirm no supported surface can create a new `findings.lifecycle.backfill` run; historical rows, if any remain in local data, must not receive new special retry or cancel affordances.
## Notes
- Filament v5 remains on Livewire v4.0+ in this repo; the cleanup stays inside existing native Filament pages and actions.
- No panel or provider registration changes are planned; `bootstrap/providers.php` remains the authoritative location if any provider work is ever needed later.
- No new global-search resource, searchable surface, or global-search contract change is involved.
- No new asset pipeline work is expected, so there is no added `filament:assets` deployment step.
- LEAN-001 applies directly: the cleanup should delete obsolete runtime surfaces rather than keeping aliases, no-op command shells, or compatibility branches for historical data.

Some files were not shown because too many files have changed in this diff Show More