Compare commits
6 Commits
254-remove
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 905b595880 | |||
| 7b394918ce | |||
| 4b36d2c64f | |||
| ab9c36f21e | |||
| 54fb65a63a | |||
| 29ad8852ca |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -105,14 +105,26 @@ public function mount(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
$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')
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
||||
];
|
||||
->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());
|
||||
|
||||
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)
|
||||
|
||||
@ -97,14 +97,26 @@ public function mount(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
$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')
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
||||
];
|
||||
->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());
|
||||
|
||||
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();
|
||||
|
||||
@ -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(),
|
||||
@ -491,4 +517,4 @@ private function tenantFilterAloneExcludesRows(): bool
|
||||
|
||||
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
->icon('heroicon-o-x-mark')
|
||||
$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')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(function (): void {
|
||||
$this->clearWorkspaceFilters();
|
||||
}),
|
||||
];
|
||||
->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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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++;
|
||||
|
||||
|
||||
@ -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', [
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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>
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
@ -885,4 +1072,4 @@ private function appendQuery(string $url, array $query): string
|
||||
|
||||
return $url.$separator.http_build_query($query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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 !== '');
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
14
apps/platform/config/support_desk.php
Normal file
14
apps/platform/config/support_desk.php
Normal 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),
|
||||
],
|
||||
];
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -161,4 +161,4 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -1,13 +1,3 @@
|
||||
@php
|
||||
$findingsLastRun = $this->findingsLastRun();
|
||||
$findingsLastRunStatusSpec = $findingsLastRun
|
||||
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status)
|
||||
: null;
|
||||
$findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed'
|
||||
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome)
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
@ -17,7 +7,7 @@
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
|
||||
Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -25,100 +15,17 @@
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Rebuild Findings Lifecycle
|
||||
No supported runbooks
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
|
||||
Supported platform runbooks will appear here when they are part of current product truth.
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="afterHeader">
|
||||
<x-filament::badge color="info" size="sm">
|
||||
{{ $this->findingsScopeLabel() }}
|
||||
</x-filament::badge>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($findingsLastRun)
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
|
||||
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }}
|
||||
</span>
|
||||
|
||||
@if ($findingsLastRunStatusSpec)
|
||||
<x-filament::badge
|
||||
:color="$findingsLastRunStatusSpec->color"
|
||||
:icon="$findingsLastRunStatusSpec->icon"
|
||||
size="sm"
|
||||
>
|
||||
{{ $findingsLastRunStatusSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($findingsLastRunOutcomeSpec)
|
||||
<x-filament::badge
|
||||
:color="$findingsLastRunOutcomeSpec->color"
|
||||
:icon="$findingsLastRunOutcomeSpec->icon"
|
||||
size="sm"
|
||||
>
|
||||
{{ $findingsLastRunOutcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($findingsLastRun->initiator_name)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
by {{ $findingsLastRun->initiator_name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (is_array($this->findingsPreflight))
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
@if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
||||
Nothing to do for the current scope.
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
|
||||
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
||||
There are no operator-run repair runbooks exposed on this surface.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -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"]', '');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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',
|
||||
]);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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.');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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 {
|
||||
@ -140,4 +165,48 @@
|
||||
->assertSee('Alert delivery failures')
|
||||
->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');
|
||||
});
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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());
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
@ -10,12 +10,12 @@
|
||||
$checks = [
|
||||
[
|
||||
'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php',
|
||||
'required' => [
|
||||
'FindingsLifecycleBackfillRunbookService',
|
||||
'OperationalControlBlockedException',
|
||||
'FindingsLifecycleBackfillScope::singleTenant(',
|
||||
],
|
||||
'required' => [],
|
||||
'forbidden' => [
|
||||
'FindingsLifecycleBackfillRunbookService',
|
||||
'FindingsLifecycleBackfillScope',
|
||||
'Backfill findings lifecycle',
|
||||
'backfill_lifecycle',
|
||||
"config('tenantpilot.allow_admin_maintenance_actions'",
|
||||
'allow_admin_maintenance_actions',
|
||||
'OperationalControlActivation::',
|
||||
@ -23,12 +23,12 @@
|
||||
],
|
||||
[
|
||||
'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php',
|
||||
'required' => [
|
||||
'FindingsLifecycleBackfillRunbookService',
|
||||
'OperationalControlBlockedException',
|
||||
'$runbookService->start(',
|
||||
],
|
||||
'required' => [],
|
||||
'forbidden' => [
|
||||
'FindingsLifecycleBackfillRunbookService',
|
||||
'FindingsLifecycleBackfillScope',
|
||||
'findings.lifecycle.backfill',
|
||||
'Rebuild Findings Lifecycle',
|
||||
'OperationalControlActivation::',
|
||||
"config('tenantpilot.allow_admin_maintenance_actions'",
|
||||
],
|
||||
@ -66,4 +66,16 @@
|
||||
expect($source)->not->toContain($needle);
|
||||
}
|
||||
}
|
||||
})->group('surface-guard');
|
||||
|
||||
foreach ([
|
||||
$root.'/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php',
|
||||
$root.'/app/Console/Commands/TenantpilotRunDeployRunbooks.php',
|
||||
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php',
|
||||
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillScope.php',
|
||||
$root.'/app/Jobs/BackfillFindingLifecycleJob.php',
|
||||
$root.'/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php',
|
||||
$root.'/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php',
|
||||
] as $removedPath) {
|
||||
expect(file_exists($removedPath))->toBeFalse("Removed findings lifecycle backfill artifact still exists: {$removedPath}");
|
||||
}
|
||||
})->group('surface-guard');
|
||||
|
||||
@ -93,7 +93,7 @@
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'tableFilters' => [
|
||||
'type' => [
|
||||
'value' => 'inventory_sync',
|
||||
'value' => 'inventory.sync',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user