Compare commits

...

6 Commits

Author SHA1 Message Date
905b595880 chore(sync): platform-dev → dev (#306)
Some checks failed
Main Confidence / confidence (push) Failing after 55s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
Automatisch erstellter PR: Synchronisiere `platform-dev` nach `dev`.

Enthält alle Änderungen, die aktuell in `platform-dev` vorhanden sind. Bitte Review und Merge gegen `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #306
2026-04-29 22:44:27 +00:00
7b394918ce chore(platform): merge platform-dev into dev (#302)
Some checks failed
Main Confidence / confidence (push) Failing after 1m48s
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m43s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.

This PR was created by agent on user request; do not merge automatically.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #302
2026-04-29 20:53:36 +00:00
4b36d2c64f Automated PR: platform-dev → dev (#300)
Some checks failed
Main Confidence / confidence (push) Failing after 1m0s
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m50s
Automated PR created by Copilot. Commit: 4b0dc2a62e

This PR merges branch `platform-dev` into `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #300
2026-04-29 13:01:43 +00:00
ab9c36f21e Automatische PR: platform-dev → dev (#299)
Some checks failed
Main Confidence / confidence (push) Failing after 59s
Automatisch erstellt: Merge `platform-dev` into `dev` (via MCP)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #299
2026-04-29 12:37:48 +00:00
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
170 changed files with 9721 additions and 4277 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

@ -59,6 +59,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_DESK_ENABLED=false
SUPPORT_DESK_NAME="External support desk"
SUPPORT_DESK_CREATE_URL=
SUPPORT_DESK_API_TOKEN=
SUPPORT_DESK_TICKET_URL_TEMPLATE=
SUPPORT_DESK_TIMEOUT_SECONDS=5
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1

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

@ -105,14 +105,26 @@ public function mount(): void
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
$actions = [];
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
->action(fn (): mixed => $this->clearTenantFilter());
return $actions;
}
public function table(Table $table): Table
@ -698,6 +710,15 @@ private function navigationContext(): CanonicalNavigationContext
);
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(array $overrides = []): string
{
$resolvedTenant = array_key_exists('tenant', $overrides)

View File

@ -97,14 +97,26 @@ public function mount(): void
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
$actions = [];
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
->action(fn (): mixed => $this->clearTenantFilter());
return $actions;
}
public function table(Table $table): Table
@ -640,6 +652,15 @@ private function navigationContext(): CanonicalNavigationContext
);
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(): string
{
$tenant = $this->filteredTenant();

View File

@ -75,6 +75,8 @@ class GovernanceInbox extends Page
private ?bool $visibleAlertsFamily = null;
private ?bool $visibleFindingExceptionsFamily = null;
public ?int $tenantId = null;
public ?string $family = null;
@ -189,12 +191,11 @@ public function pageUrl(array $overrides = []): string
public function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
return CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $this->pageUrl(),
familyKey: $this->family,
);
}
@ -223,6 +224,7 @@ private function ensureAtLeastOneVisibleFamily(): void
if (
$this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== []
|| $this->hasVisibleFindingExceptionsFamily()
|| $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily()
) {
@ -266,6 +268,27 @@ private function hasVisibleAlertsFamily(): bool
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
}
private function hasVisibleFindingExceptionsFamily(): bool
{
if (is_bool($this->visibleFindingExceptionsFamily)) {
return $this->visibleFindingExceptionsFamily;
}
if ($this->authorizedTenants() === []) {
return $this->visibleFindingExceptionsFamily = false;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->visibleFindingExceptionsFamily = false;
}
return $this->visibleFindingExceptionsFamily = app(WorkspaceCapabilityResolver::class)
->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
/**
* @return array<int, Tenant>
*/
@ -375,6 +398,7 @@ private function resolveRequestedFamily(): ?string
return in_array($family, [
'assigned_findings',
'intake_findings',
'finding_exceptions',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
@ -424,6 +448,7 @@ private function inboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family,
navigationContext: $this->navigationContext(),
@ -458,6 +483,7 @@ private function unfilteredInboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: null,
selectedFamily: null,
navigationContext: $this->navigationContext(),

View File

@ -208,6 +208,16 @@ protected function getHeaderActions(): array
returnActionName: 'operate_hub_return_finding_exceptions',
);
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string
return null;
}
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
}
public function selectedFindingUrl(): ?string
@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
return $this->appendQuery(
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
}
public function clearSelectedException(): void
@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext
return CanonicalNavigationContext::fromRequest(request());
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function normalizeSelectedFindingExceptionId(): void
{
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string
return 'danger';
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}

View File

@ -31,6 +31,7 @@
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
@ -49,6 +50,7 @@
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Support\Htmlable;
@ -267,42 +269,73 @@ public function authorizeOperationRunSupportRequest(): void
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label('Request support')
->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane')
->record($this->run)
->slideOver()
->stickyModalHeader()
->modalHeading('Request support')
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.')
->modalSubmitActionLabel('Submit support request')
->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription(__('localization.dashboard.support_request_run_description'))
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([
Placeholder::make('primary_context')
->label('Primary context')
->label(__('localization.dashboard.primary_context'))
->content(fn (): string => OperationRunLinks::identifier($this->run))
->columnSpanFull(),
Placeholder::make('included_context')
->label('Included context')
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity')
->label('Severity')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label('Summary')
->label(__('localization.dashboard.summary'))
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label('Reproduction notes')
->label(__('localization.dashboard.reproduction_notes'))
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label('Contact name')
->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveViewerActor()->name),
TextInput::make('contact_email')
->label('Contact email')
->label(__('localization.dashboard.contact_email'))
->email()
->default(fn (): ?string => $this->resolveViewerActor()->email),
])
@ -312,9 +345,21 @@ private function requestSupportAction(): Action
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
Notification::make()
->title('Support request submitted')
->body('Reference '.$supportRequest->internal_reference)
->success()
->title(__('localization.dashboard.support_request_submitted'))
->body($this->supportRequestNotificationBody($supportRequest))
->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send();
});
@ -414,6 +459,98 @@ private function operationSupportRequestAttachmentSummary(): string
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
}
private function operationLatestSupportRequestHandoffSummary(): string
{
$user = $this->resolveViewerActor();
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
/**
* @param array<string, mixed> $bundle
*/

View File

@ -15,7 +15,13 @@
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
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 +63,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');
@ -97,16 +113,28 @@ public function mount(): void
protected function getHeaderActions(): array
{
return [
Action::make('clear_filters')
$actions = [];
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_filters')
->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(function (): void {
$this->clearWorkspaceFilters();
}),
];
});
return $actions;
}
public function table(Table $table): Table
@ -333,9 +361,13 @@ private function latestReviewUrl(Tenant $tenant): ?string
return null;
}
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
self::DETAIL_CONTEXT_QUERY_KEY => 1,
]);
return $this->appendQuery(
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
array_replace(
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
$this->navigationContext()?->toQuery() ?? [],
),
);
}
private function latestReviewPack(Tenant $tenant): ?ReviewPack
@ -512,4 +544,30 @@ private function reviewPackAvailability(Tenant $tenant): string
return __('localization.review.available');
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}

View File

@ -21,6 +21,7 @@
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@ -30,6 +31,7 @@
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Dashboard;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Contracts\View\View;
@ -108,6 +110,37 @@ private function requestSupportAction(): Action
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
@ -138,8 +171,20 @@ private function requestSupportAction(): Action
Notification::make()
->title(__('localization.dashboard.support_request_submitted'))
->body('Reference '.$supportRequest->internal_reference)
->success()
->body($this->supportRequestNotificationBody($supportRequest))
->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send();
});
@ -281,4 +326,97 @@ private function tenantSupportRequestAttachmentSummary(): string
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
}
private function tenantLatestSupportRequestHandoffSummary(): string
{
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
$user = $this->resolveDashboardActor();
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
}

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

@ -1871,8 +1871,11 @@ private function upsertFindings(
} else {
$this->observeFinding(
finding: $finding,
tenant: $tenant,
observedAt: $observedAt,
currentOperationRunId: (int) $this->operationRun->getKey(),
severity: (string) $driftItem['severity'],
slaPolicy: $slaPolicy,
);
}
@ -1947,12 +1950,21 @@ private function upsertFindings(
];
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
private function observeFinding(
Finding $finding,
Tenant $tenant,
CarbonImmutable $observedAt,
int $currentOperationRunId,
string $severity,
FindingSlaPolicy $slaPolicy,
): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
$finding->last_seen_at = $observedAt;
}
@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
}
/**

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

@ -32,6 +32,20 @@ class SupportRequest extends Model
public const string SEVERITY_BLOCKING = 'blocking';
public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only';
public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket';
public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket';
public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked';
public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed';
protected $guarded = [];
/**
@ -65,6 +79,53 @@ public static function severityValues(): array
return array_keys(self::severityOptions());
}
/**
* @return array<string, string>
*/
public static function externalHandoffModeOptions(): array
{
return [
self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only',
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket',
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket',
];
}
/**
* @return list<string>
*/
public static function externalHandoffModeValues(): array
{
return array_keys(self::externalHandoffModeOptions());
}
public function hasExternalTicket(): bool
{
return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== '';
}
public function hasExternalHandoffFailure(): bool
{
return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== '';
}
public function externalHandoffOutcome(): string
{
if ($this->hasExternalHandoffFailure()) {
return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED;
}
if (! $this->hasExternalTicket()) {
return self::HANDOFF_OUTCOME_INTERNAL_ONLY;
}
return match ($this->external_handoff_mode) {
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED,
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED,
default => self::HANDOFF_OUTCOME_INTERNAL_ONLY,
};
}
/**
* @return list<string>
*/

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

@ -173,4 +173,87 @@ public function logSupportRequestCreated(
tenant: $tenant,
);
}
public function logSupportRequestExternalTicketCreated(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketCreated,
status: 'success',
summaryPrefix: 'External ticket created for support request ',
);
}
public function logSupportRequestExternalTicketLinked(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketLinked,
status: 'success',
summaryPrefix: 'External ticket linked for support request ',
);
}
public function logSupportRequestExternalHandoffFailed(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalHandoffFailed,
status: 'failed',
summaryPrefix: 'External handoff failed for support request ',
);
}
private function logSupportRequestExternalHandoff(
SupportRequest $supportRequest,
User|PlatformUser|null $actor,
AuditActionId $action,
string $status,
string $summaryPrefix,
): \App\Models\AuditLog {
$supportRequest->loadMissing(['tenant.workspace']);
$tenant = $supportRequest->tenant;
if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Support requests must belong to a tenant.');
}
$metadata = [
'internal_reference' => $supportRequest->internal_reference,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (string) $supportRequest->operation_run_id
: (string) $tenant->getKey(),
'external_handoff_mode' => $supportRequest->external_handoff_mode,
'external_ticket_reference' => $supportRequest->external_ticket_reference,
];
if ($supportRequest->external_handoff_failure_summary !== null) {
$metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary;
}
return $this->log(
workspace: $tenant->workspace,
action: $action,
context: $metadata,
actor: $actor,
status: $status,
resourceType: 'support_request',
resourceId: (string) $supportRequest->getKey(),
targetLabel: $supportRequest->internal_reference,
summary: $summaryPrefix.$supportRequest->internal_reference,
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
tenant: $tenant,
);
}
}

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

@ -163,7 +163,7 @@ private function upsertFinding(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([
'severity' => $severity,
@ -253,7 +253,7 @@ private function handleGaAggregate(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);
$existing->forceFill([
'severity' => Finding::SEVERITY_HIGH,
@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy
return $this->slaPolicy ?? app(FindingSlaPolicy::class);
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1;
return;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($timesSeen < 1) {
$finding->times_seen = 1;
$slaPolicy = $this->resolveSlaPolicy();
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
}

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

@ -140,7 +140,7 @@ private function handleMissingPermission(
->first();
if ($finding instanceof Finding) {
$this->observeFinding($finding, $observedAt);
$this->observeFinding($finding, $tenant, $observedAt, $severity);
$finding->forceFill([
'severity' => $severity,
@ -216,7 +216,7 @@ private function handleErrorPermission(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([
'severity' => $severity,
@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu
return CarbonImmutable::now();
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1;
return;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($timesSeen < 1) {
$finding->times_seen = 1;
if ($finding->sla_days === null) {
$finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
}

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

@ -103,6 +103,9 @@ enum AuditActionId: string
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case SupportRequestCreated = 'support_request.created';
case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created';
case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked';
case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed';
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated';
@ -248,6 +251,9 @@ private static function labels(): array
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
@ -338,6 +344,9 @@ private static function summaries(): array
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',

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

@ -6,14 +6,15 @@
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
@ -21,14 +22,12 @@
use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder
@ -41,6 +40,7 @@
private const FAMILY_ORDER = [
'assigned_findings',
'intake_findings',
'finding_exceptions',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
@ -71,6 +71,7 @@ public function build(
array $visibleFindingTenants,
array $reviewTenants,
bool $canViewAlerts,
bool $canViewFindingExceptions = false,
?Tenant $selectedTenant = null,
?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null,
@ -113,6 +114,22 @@ public function build(
}
if ($authorizedTenantsById !== []) {
if ($canViewFindingExceptions) {
$findingExceptionsSection = $this->findingExceptionsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$findingExceptionsSection['key']] = $findingExceptionsSection;
$availableFamilies[] = [
'key' => $findingExceptionsSection['key'],
'label' => $findingExceptionsSection['label'],
'count' => $findingExceptionsSection['count'],
];
$familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count'];
}
$operationsSection = $this->operationsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
@ -191,6 +208,59 @@ public function build(
];
}
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function findingExceptionsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$pendingCount = (clone $baseQuery)
->where('status', FindingException::STATUS_PENDING)
->count();
$expiringCount = (clone $baseQuery)
->where('current_validity_state', FindingException::VALIDITY_EXPIRING)
->count();
$lapsedCount = (clone $baseQuery)
->where('status', '!=', FindingException::STATUS_PENDING)
->whereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
])
->count();
$entries = $this->orderedFindingExceptionsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext))
->all();
return [
'key' => 'finding_exceptions',
'label' => 'Finding exceptions',
'count' => $count,
'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount),
'dominant_action_label' => 'Open finding exceptions',
'dominant_action_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No finding exceptions match this tenant filter right now.'
: 'No finding exceptions need review right now.',
];
}
/**
* @param array<int, Tenant> $tenants
* @return array<int, Tenant>
@ -477,28 +547,10 @@ private function reviewFollowUpSection(
'label' => 'Review follow-up',
'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up',
'dominant_action_label' => 'Open customer review workspace',
'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
: $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.'
@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te
});
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($authorizedTenants);
return FindingException::query()
->with([
'tenant',
'requester:id,name',
'owner:id,name',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
])
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where(function ($query): void {
$query
->where('status', FindingException::STATUS_PENDING)
->orWhereIn('status', [
FindingException::STATUS_EXPIRING,
FindingException::STATUS_EXPIRED,
])
->orWhereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
]);
});
}
private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
"case
when status = ? then 0
when current_validity_state = ? then 1
when current_validity_state = ? then 2
when current_validity_state = ? then 3
else 4
end asc",
[
FindingException::STATUS_PENDING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
FindingException::VALIDITY_EXPIRING,
],
)
->orderByRaw('case when review_due_at is null then 1 else 0 end asc')
->orderBy('review_due_at')
->orderByDesc('id');
}
/**
* @return array<string, mixed>
*/
@ -727,6 +835,52 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext
];
}
/**
* @return array<string, mixed>
*/
private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array
{
$findingLabel = $exception->finding?->resolvedSubjectDisplayName()
?? 'Finding #'.$exception->finding_id;
$sublineParts = array_values(array_filter([
$exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null,
FindingExceptionResource::relativeTimeDescription($exception->review_due_at)
?? FindingExceptionResource::relativeTimeDescription($exception->expires_at),
is_string($exception->request_reason) && $exception->request_reason !== ''
? $exception->request_reason
: null,
]));
return [
'family_key' => 'finding_exceptions',
'source_model' => FindingException::class,
'source_key' => (string) $exception->getKey(),
'tenant_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null,
'tenant_label' => $exception->tenant?->name,
'headline' => $findingLabel,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => match (true) {
(string) $exception->status === FindingException::STATUS_PENDING => 0,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1,
(string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3,
default => 4,
},
'status_label' => $this->findingExceptionStatusLabel($exception),
'destination_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $exception->tenant?->external_id,
'exception' => (int) $exception->getKey(),
], static fn (mixed $value): bool => $value !== null && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
@ -855,6 +1009,39 @@ private function alertsSummary(int $count): string
);
}
private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string
{
if ($count === 0) {
return 'No finding exceptions need review in the current scope.';
}
return sprintf(
'%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.',
$count,
$count === 1 ? '' : 's',
$pendingCount,
$expiringCount,
$lapsedCount,
);
}
private function findingExceptionStatusLabel(FindingException $exception): string
{
if ((string) $exception->status === FindingException::STATUS_PENDING) {
return 'Pending';
}
if (in_array((string) $exception->current_validity_state, [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
], true)) {
return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value();
}
return Str::of((string) $exception->status)->replace('_', ' ')->title()->value();
}
private function reviewSummary(int $followUpCount, int $changedCount): string
{
$total = $followUpCount + $changedCount;

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

@ -18,6 +18,7 @@ public function __construct(
public string $sourceSurface,
public string $canonicalRouteName,
public ?int $tenantId = null,
public ?string $familyKey = null,
public ?string $backLinkLabel = null,
public ?string $backLinkUrl = null,
public array $filterPayload = [],
@ -56,12 +57,31 @@ public static function fromRequest(Request $request): ?self
sourceSurface: $sourceSurface,
canonicalRouteName: $canonicalRouteName,
tenantId: is_numeric($tenantId) ? (int) $tenantId : null,
familyKey: is_string($payload['family_key'] ?? null) && (string) $payload['family_key'] !== ''
? (string) $payload['family_key']
: null,
backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null,
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
filterPayload: [],
);
}
public static function forGovernanceInbox(
string $canonicalRouteName,
?int $tenantId,
?string $familyKey,
string $backLinkUrl,
): self {
return new self(
sourceSurface: 'governance.inbox',
canonicalRouteName: $canonicalRouteName,
tenantId: $tenantId,
familyKey: $familyKey,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $backLinkUrl,
);
}
/**
* @return array<string, mixed>
*/
@ -117,6 +137,7 @@ private function navPayload(): array
'source_surface' => $this->sourceSurface,
'canonical_route_name' => $this->canonicalRouteName,
'tenant_id' => $this->tenantId,
'family_key' => $this->familyKey,
'back_label' => $this->backLinkLabel,
'back_url' => $this->backLinkUrl,
], static fn (mixed $value): bool => $value !== null && $value !== '');

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

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\SupportRequest;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
final class ExternalSupportDeskHandoffService
{
private const int MAX_TIMEOUT_SECONDS = 5;
/**
* @return array{
* successful: bool,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* failure_summary: ?string
* }
*/
public function createTicket(SupportRequest $supportRequest): array
{
if (! $this->targetIsConfigured()) {
return $this->failed('External support desk target is not configured.');
}
try {
$response = Http::timeout($this->timeoutSeconds())
->acceptJson()
->asJson()
->withHeaders($this->headers())
->post($this->createUrl(), $this->payloadFor($supportRequest));
} catch (ConnectionException) {
return $this->failed('External support desk did not respond before the configured timeout.');
} catch (RequestException $exception) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$exception->response->status().').');
}
if (! $response->successful()) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$response->status().').');
}
$responsePayload = $response->json();
$responsePayload = is_array($responsePayload) ? $responsePayload : [];
$reference = $this->normalizeReference(
data_get($responsePayload, 'ticket_reference')
?? data_get($responsePayload, 'external_ticket_reference')
?? data_get($responsePayload, 'reference')
?? data_get($responsePayload, 'key')
?? data_get($responsePayload, 'id'),
throwOnInvalid: false,
);
if ($reference === null) {
return $this->failed('External support desk did not return a ticket reference.');
}
$url = $this->normalizeUrl(
data_get($responsePayload, 'ticket_url')
?? data_get($responsePayload, 'external_ticket_url')
?? data_get($responsePayload, 'url')
?? data_get($responsePayload, 'web_url')
?? data_get($responsePayload, 'html_url'),
throwOnInvalid: false,
) ?? $this->urlFromTemplate($reference);
return [
'successful' => true,
'external_ticket_reference' => $reference,
'external_ticket_url' => $url,
'failure_summary' => null,
];
}
/**
* @return array{external_ticket_reference: string, external_ticket_url: ?string}
*/
public function normalizeLinkedTicket(mixed $reference, mixed $url): array
{
$normalizedReference = $this->normalizeReference($reference, throwOnInvalid: true);
if ($normalizedReference === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
return [
'external_ticket_reference' => $normalizedReference,
'external_ticket_url' => $this->normalizeUrl($url, throwOnInvalid: true) ?? $this->urlFromTemplate($normalizedReference),
];
}
public function targetIsConfigured(): bool
{
return (bool) config('support_desk.target.enabled', false)
&& $this->createUrl() !== null;
}
public function targetName(): string
{
$name = config('support_desk.target.name', 'External support desk');
return is_string($name) && trim($name) !== ''
? trim($name)
: 'External support desk';
}
public function timeoutSeconds(): int
{
$configured = config('support_desk.target.timeout_seconds', self::MAX_TIMEOUT_SECONDS);
$seconds = is_numeric($configured) ? (int) $configured : self::MAX_TIMEOUT_SECONDS;
return max(1, min($seconds, self::MAX_TIMEOUT_SECONDS));
}
/**
* @return array{successful: false, external_ticket_reference: null, external_ticket_url: null, failure_summary: string}
*/
private function failed(string $summary): array
{
return [
'successful' => false,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'failure_summary' => $this->boundedFailureSummary($summary),
];
}
private function createUrl(): ?string
{
return $this->normalizeUrl(config('support_desk.target.create_url'), throwOnInvalid: false);
}
/**
* @return array<string, string>
*/
private function headers(): array
{
$headers = [];
$token = config('support_desk.target.api_token');
if (is_string($token) && trim($token) !== '') {
$headers['Authorization'] = 'Bearer '.trim($token);
}
return $headers;
}
/**
* @return array<string, mixed>
*/
private function payloadFor(SupportRequest $supportRequest): array
{
return [
'support_request' => [
'internal_reference' => $supportRequest->internal_reference,
'severity' => $supportRequest->severity,
'summary' => $supportRequest->summary,
'reproduction_notes' => $supportRequest->reproduction_notes,
'contact_name' => $supportRequest->contact_name,
'contact_email' => $supportRequest->contact_email,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? $supportRequest->operation_run_id
: $supportRequest->tenant_id,
'workspace_id' => $supportRequest->workspace_id,
'tenant_id' => $supportRequest->tenant_id,
'operation_run_id' => $supportRequest->operation_run_id,
],
'context_envelope' => $supportRequest->context_envelope,
];
}
private function normalizeReference(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value) && ! is_numeric($value)) {
return null;
}
$reference = trim((string) $value);
if ($reference === '') {
return null;
}
if (mb_strlen($reference) > 128 || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/', $reference) !== 1) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference format is invalid.',
]);
}
return null;
}
return $reference;
}
private function normalizeUrl(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value)) {
return null;
}
$url = trim($value);
if ($url === '') {
return null;
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($scheme, ['http', 'https'], true) || filter_var($url, FILTER_VALIDATE_URL) === false) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_url' => 'The external ticket URL must be a valid HTTP or HTTPS URL.',
]);
}
return null;
}
return $url;
}
private function urlFromTemplate(string $reference): ?string
{
$template = config('support_desk.target.ticket_url_template');
if (! is_string($template) || trim($template) === '') {
return null;
}
$url = str_replace(
['{reference}', '{ticket}'],
rawurlencode($reference),
trim($template),
);
return $this->normalizeUrl($url, throwOnInvalid: false);
}
private function boundedFailureSummary(string $summary): string
{
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
return mb_substr($summary, 0, 500);
}
}

View File

@ -20,6 +20,7 @@ public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
@ -95,7 +96,7 @@ private function submit(
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
$connection = SupportRequest::query()->getModel()->getConnection();
return $connection->transaction(function () use (
$supportRequest = $connection->transaction(function () use (
$actor,
$contactEmail,
$contactName,
@ -127,6 +128,181 @@ private function submit(
return $supportRequest;
});
return $this->finalizeExternalHandoff($supportRequest, $actor, $validated);
}
/**
* @param array{
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string
* } $validated
*/
private function finalizeExternalHandoff(SupportRequest $supportRequest, User $actor, array $validated): SupportRequest
{
$mode = $validated['external_handoff_mode'];
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
])->save();
return $supportRequest->refresh();
}
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$linkedTicket = $this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => $linkedTicket['external_ticket_reference'],
'external_ticket_url' => $linkedTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketLinked($supportRequest, $actor);
return $supportRequest;
}
$createdTicket = $this->externalSupportDeskHandoffService->createTicket($supportRequest);
if ($createdTicket['successful']) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => $createdTicket['external_ticket_reference'],
'external_ticket_url' => $createdTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketCreated($supportRequest, $actor);
return $supportRequest;
}
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => $createdTicket['failure_summary'],
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalHandoffFailed($supportRequest, $actor);
return $supportRequest;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestTenantHandoffSummary(Tenant $tenant, User $actor): ?array
{
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_TENANT)
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestOperationRunHandoffSummary(OperationRun $run, User $actor): ?array
{
$run->loadMissing('tenant.workspace');
$tenant = $run->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $run->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->where('operation_run_id', (int) $run->getKey())
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }
*/
private function summaryFor(SupportRequest $supportRequest): array
{
return [
'internal_reference' => (string) $supportRequest->internal_reference,
'primary_context_type' => (string) $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (is_numeric($supportRequest->operation_run_id) ? (int) $supportRequest->operation_run_id : null)
: (is_numeric($supportRequest->tenant_id) ? (int) $supportRequest->tenant_id : null),
'submitted_at' => $supportRequest->created_at?->toIso8601String(),
'external_handoff_mode' => (string) ($supportRequest->external_handoff_mode ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY),
'external_ticket_reference' => $this->normalizeNullableString($supportRequest->external_ticket_reference),
'external_ticket_url' => $this->normalizeNullableString($supportRequest->external_ticket_url),
'external_handoff_failure_summary' => $this->normalizeNullableString($supportRequest->external_handoff_failure_summary),
'has_external_link' => $supportRequest->hasExternalTicket(),
'has_failure' => $supportRequest->hasExternalHandoffFailure(),
];
}
/**
@ -137,10 +313,20 @@ private function submit(
* reproduction_notes: ?string,
* contact_name: ?string,
* contact_email: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* }
*/
private function validate(array $data): array
{
$requestedHandoffMode = $this->normalizeNullableString($data['external_handoff_mode'] ?? null)
?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
if (! $this->externalSupportDeskHandoffService->targetIsConfigured()) {
$requestedHandoffMode = SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
}
$validated = validator(
[
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
@ -148,6 +334,9 @@ private function validate(array $data): array
'reproduction_notes' => $data['reproduction_notes'] ?? null,
'contact_name' => $data['contact_name'] ?? null,
'contact_email' => $data['contact_email'] ?? null,
'external_handoff_mode' => $requestedHandoffMode,
'external_ticket_reference' => $data['external_ticket_reference'] ?? null,
'external_ticket_url' => $data['external_ticket_url'] ?? null,
],
[
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
@ -155,6 +344,9 @@ private function validate(array $data): array
'reproduction_notes' => ['nullable', 'string'],
'contact_name' => ['nullable', 'string'],
'contact_email' => ['nullable', 'email'],
'external_handoff_mode' => ['required', 'string', Rule::in(SupportRequest::externalHandoffModeValues())],
'external_ticket_reference' => ['nullable', 'string', 'max:255'],
'external_ticket_url' => ['nullable', 'url', 'max:2048'],
],
)->validate();
@ -169,6 +361,27 @@ private function validate(array $data): array
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
$validated['external_ticket_reference'] = $this->normalizeNullableString($validated['external_ticket_reference'] ?? null);
$validated['external_ticket_url'] = $this->normalizeNullableString($validated['external_ticket_url'] ?? null);
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET
&& $validated['external_ticket_reference'] === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
}
if ($validated['external_handoff_mode'] !== SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$validated['external_ticket_reference'] = null;
$validated['external_ticket_url'] = null;
}
return $validated;
}

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

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'target' => [
'enabled' => (bool) env('SUPPORT_DESK_ENABLED', false),
'name' => env('SUPPORT_DESK_NAME', 'External support desk'),
'create_url' => env('SUPPORT_DESK_CREATE_URL'),
'api_token' => env('SUPPORT_DESK_API_TOKEN'),
'ticket_url_template' => env('SUPPORT_DESK_TICKET_URL_TEMPLATE'),
'timeout_seconds' => (int) env('SUPPORT_DESK_TIMEOUT_SECONDS', 5),
],
];

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

@ -51,6 +51,10 @@ public function definition(): array
],
'omissions' => [],
],
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
];
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\SupportRequest;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->string('external_handoff_mode')
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->after('context_envelope');
$table->string('external_ticket_reference')->nullable()->after('external_handoff_mode');
$table->text('external_ticket_url')->nullable()->after('external_ticket_reference');
$table->text('external_handoff_failure_summary')->nullable()->after('external_ticket_url');
});
}
public function down(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->dropColumn([
'external_handoff_mode',
'external_ticket_reference',
'external_ticket_url',
'external_handoff_failure_summary',
]);
});
}
};

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

@ -80,14 +80,40 @@
'request_support' => 'Support anfragen',
'support_request_heading' => 'Support anfragen',
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
'submit_request' => 'Anfrage senden',
'support_request_run_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus dem aktuellen Lauf hinzu.',
'submit_request' => 'Supportanfrage senden',
'primary_context' => 'Primärer Kontext',
'included_context' => 'Enthaltener Kontext',
'latest_external_handoff' => 'Letzte externe Übergabe',
'latest_external_handoff_none' => 'Für diesen Kontext wurde noch keine Supportanfrage gesendet.',
'latest_external_handoff_internal_only' => 'Die letzte Supportreferenz :reference ist nur in TenantPilot erfasst. Es ist noch kein externes Ticket verknüpft.',
'latest_external_handoff_linked' => 'Die letzte Supportreferenz :reference ist mit externem Ticket :external verknüpft.',
'latest_external_handoff_failed' => 'Die letzte Supportreferenz :reference hat kein externes Ticket, weil die Übergabe fehlgeschlagen ist: :failure',
'external_handoff_mode' => 'Externe Übergabe',
'handoff_mode_internal_only' => 'Nur TenantPilot',
'handoff_mode_create_external_ticket' => 'Externes Ticket erstellen',
'handoff_mode_link_existing_ticket' => 'Bestehendes Ticket verknüpfen',
'external_handoff_mode_helper_available' => 'Wählen Sie, ob diese Anfrage intern bleibt, ein externes Ticket erstellt oder ein bestehendes Ticket verknüpft.',
'external_handoff_mode_helper_unavailable' => 'Es ist kein externes Support-Desk-Ziel konfiguriert. Diese Anfrage bleibt intern.',
'handoff_mutation_scope' => 'Änderungsumfang',
'mutation_scope_internal_only' => 'Nur TenantPilot. Es wird kein externes Support-Desk-Ticket erstellt oder verknüpft.',
'mutation_scope_external_create' => 'TenantPilot + externes Support Desk. TenantPilot erstellt zuerst die interne Supportanfrage und danach genau ein externes Ticket.',
'mutation_scope_external_link' => 'TenantPilot + externes Support Desk. TenantPilot speichert die angegebene externe Ticketreferenz und erstellt kein Duplikat.',
'external_ticket_reference' => 'Externe Ticketreferenz',
'external_ticket_reference_helper' => 'Verwenden Sie den bestehenden Desk-Ticketschlüssel, zum Beispiel PSA-12345.',
'external_ticket_url' => 'Externe Ticket-URL',
'external_ticket_url_helper' => 'Optionaler HTTP- oder HTTPS-Link zum bestehenden externen Ticket.',
'severity' => 'Schweregrad',
'summary' => 'Zusammenfassung',
'reproduction_notes' => 'Reproduktionshinweise',
'contact_name' => 'Kontaktname',
'contact_email' => 'Kontakt-E-Mail',
'support_request_submitted' => 'Supportanfrage gesendet',
'support_request_submitted_internal_only' => 'Supportreferenz :reference wurde erstellt. Es ist noch kein externes Ticket verknüpft.',
'support_request_submitted_created' => 'Supportreferenz :reference wurde erstellt und externes Ticket :external wurde angelegt.',
'support_request_submitted_linked' => 'Supportreferenz :reference wurde erstellt und mit externem Ticket :external verknüpft.',
'support_request_submitted_failed' => 'Supportreferenz :reference wurde erstellt, aber die externe Übergabe ist fehlgeschlagen: :failure',
'open_external_ticket' => 'Externes Ticket öffnen',
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
'support_diagnostics' => 'Supportdiagnosen',
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',

View File

@ -80,14 +80,40 @@
'request_support' => 'Request support',
'support_request_heading' => 'Request support',
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
'submit_request' => 'Submit request',
'support_request_run_description' => 'Share a concise summary and TenantAtlas will attach redacted context from the current run.',
'submit_request' => 'Submit support request',
'primary_context' => 'Primary context',
'included_context' => 'Included context',
'latest_external_handoff' => 'Latest external handoff',
'latest_external_handoff_none' => 'No support request has been submitted for this context yet.',
'latest_external_handoff_internal_only' => 'Latest support reference :reference is TenantPilot only. No external ticket is linked yet.',
'latest_external_handoff_linked' => 'Latest support reference :reference is linked to external ticket :external.',
'latest_external_handoff_failed' => 'Latest support reference :reference has no external ticket because handoff failed: :failure',
'external_handoff_mode' => 'External handoff',
'handoff_mode_internal_only' => 'TenantPilot only',
'handoff_mode_create_external_ticket' => 'Create external ticket',
'handoff_mode_link_existing_ticket' => 'Link existing ticket',
'external_handoff_mode_helper_available' => 'Choose whether this request stays internal, creates an external ticket, or links an existing one.',
'external_handoff_mode_helper_unavailable' => 'No external support desk target is configured. This request will stay internal.',
'handoff_mutation_scope' => 'Mutation scope',
'mutation_scope_internal_only' => 'TenantPilot only. No external support desk ticket will be created or linked.',
'mutation_scope_external_create' => 'TenantPilot + external support desk. TenantPilot creates the internal support request first, then creates one external ticket.',
'mutation_scope_external_link' => 'TenantPilot + external support desk. TenantPilot stores the external ticket reference you provide and does not create a duplicate ticket.',
'external_ticket_reference' => 'External ticket reference',
'external_ticket_reference_helper' => 'Use the existing desk ticket key, for example PSA-12345.',
'external_ticket_url' => 'External ticket URL',
'external_ticket_url_helper' => 'Optional HTTP or HTTPS link to the existing external ticket.',
'severity' => 'Severity',
'summary' => 'Summary',
'reproduction_notes' => 'Reproduction notes',
'contact_name' => 'Contact name',
'contact_email' => 'Contact email',
'support_request_submitted' => 'Support request submitted',
'support_request_submitted_internal_only' => 'Support reference :reference was created. No external ticket is linked yet.',
'support_request_submitted_created' => 'Support reference :reference was created and external ticket :external was created.',
'support_request_submitted_linked' => 'Support reference :reference was created and linked to external ticket :external.',
'support_request_submitted_failed' => 'Support reference :reference was created, but external handoff failed: :failure',
'open_external_ticket' => 'Open external ticket',
'open_support_diagnostics' => 'Open support diagnostics',
'support_diagnostics' => 'Support diagnostics',
'support_diagnostics_description' => 'Redacted tenant context from existing records.',

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

@ -18,7 +18,7 @@
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state.
This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.
</p>
</div>

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

@ -528,6 +528,133 @@
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1);
});
it('repairs missing due-state fields on an existing open drift finding without extending the original due date', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $workspaceSafeExternalId,
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => $displayName],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
'display_name' => $displayName,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run1))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->sole();
$expectedSlaDays = (int) $finding->sla_days;
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($expectedSlaDays)->toBeGreaterThan(0)
->and($expectedDueAt)->not->toBeNull();
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run2))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$finding->refresh();
expect($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe($expectedSlaDays)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
\Carbon\CarbonImmutable::setTestNow();
});
it('does not create new finding identities when a new snapshot is captured', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');

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

@ -195,6 +195,54 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00');
});
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
[$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$generator->generate($tenant, buildPayload(
[gaRoleDef()],
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
'2026-02-24T10:00:00Z',
));
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->firstOrFail();
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($finding->sla_days)->toBe(3)
->and($expectedDueAt)->toBe('2026-02-27T10:00:00+00:00');
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$result = $generator->generate($tenant, buildPayload(
[gaRoleDef()],
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
'2026-02-24T11:00:00Z',
));
$finding->refresh();
expect($result->created)->toBe(0)
->and($result->unchanged)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_NEW)
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe(3)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
CarbonImmutable::setTestNow();
});
it('auto-resolves when assignment is removed', function (): void {
[$user, $tenant] = createMinimalUserWithTenant();
@ -444,7 +492,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 +504,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

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps findings intake secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()
->for($tenant)
->create([
'workspace_id' => (int) $tenant->workspace_id,
'assignee_user_id' => null,
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'intake-from-governance',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'intake_findings',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'intake_findings',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'view' => 'needs_triage',
]))
->actingAs($user)
->test(FindingsIntakeQueue::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([$finding])
->assertSee('Shared unassigned work')
->assertDontSee('This workspace decision surface routes you');
});

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

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps my findings secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'assigned-from-governance',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'assigned_findings',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'assigned_findings',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
]))
->actingAs($user)
->test(MyFindingsInbox::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([Finding::query()->where('subject_external_id', 'assigned-from-governance')->firstOrFail()])
->assertSee('Assigned to me only')
->assertDontSee('This workspace decision surface routes you');
});

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

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
it('launches the finding exceptions lane with tenant and family return context', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'governance-exception-lane',
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Governance convergence request',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions');
$response->assertOk()
->assertSee('Finding exceptions')
->assertSee('Open finding exceptions')
->assertSee('Governance convergence request')
->assertSee('nav%5Bfamily_key%5D=finding_exceptions', false)
->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false)
->assertSee('exception='.(string) $exception->getKey(), false)
->assertDontSee('Open my findings')
->assertDontSee('Open findings intake');
});

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
@ -46,6 +47,28 @@
->reopened()
->create();
$exceptionFinding = Finding::factory()
->for($alphaTenant)
->riskAccepted()
->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'subject_external_id' => 'exception-governance-home',
]);
FindingException::query()->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'tenant_id' => (int) $alphaTenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Governance home exception review',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
OperationRun::factory()
->forTenant($alphaTenant)
->create([
@ -87,13 +110,15 @@
->assertOk()
->assertSee('Assigned findings')
->assertSee('Findings intake')
->assertSee('Finding exceptions')
->assertSee('Operations follow-up')
->assertSee('Alert delivery failures')
->assertSee('Review follow-up')
->assertSee('Open my findings')
->assertSee('Open finding exceptions')
->assertSee('Open terminal follow-up')
->assertSee('Open alert deliveries')
->assertSee('Open review follow-up');
->assertSee('Open customer review workspace');
});
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
@ -141,3 +166,47 @@
->assertSee('No failed alert deliveries match this tenant filter right now.')
->assertDontSee('Open my findings');
});
it('omits the finding exceptions lane when the workspace capability is not visible', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exceptionFinding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Hidden exception lane',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Assigned findings')
->assertDontSee('Finding exceptions')
->assertDontSee('Hidden exception lane');
});

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

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps the finding exceptions queue secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'exception-secondary-finding',
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Exception queue return context',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'finding_exceptions',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'finding_exceptions',
]),
);
$component = Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'exception' => (int) $exception->getKey(),
]))
->actingAs($user)
->test(FindingExceptionsQueue::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertActionVisible('open_selected_exception')
->assertActionVisible('open_selected_finding')
->assertSee('Exception queue return context')
->assertSee('Focused review lane')
->assertDontSee('This workspace decision surface routes you');
expect($component->instance()->selectedExceptionUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey());
expect($component->instance()->selectedFindingUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Btenant_id%5D='.(string) $tenant->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);
}
}
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
@ -152,6 +149,45 @@ function errorPermission(string $key, array $features = []): array
CarbonImmutable::setTestNow();
});
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = app(PermissionPostureFindingGenerator::class);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$generator->generate($tenant, buildComparison([
missingPermission('Perm.A', ['policy-sync', 'backup']),
]));
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->firstOrFail();
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($finding->sla_days)->toBe(7)
->and($expectedDueAt)->toBe('2026-03-03T10:00:00+00:00');
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$result = $generator->generate($tenant, buildComparison([
missingPermission('Perm.A', ['policy-sync', 'backup']),
]));
$finding->refresh();
expect($result->findingsCreated)->toBe(0)
->and($result->findingsUnchanged)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_NEW)
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe(7)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
CarbonImmutable::setTestNow();
});
// (5) Re-opens resolved finding when permission revoked again
it('re-opens resolved finding when permission is revoked again', function (): void {
[$user, $tenant] = createUserWithTenant();

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

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