feat(115): baseline operability + alerts (#140)

Implements Spec 115 (Baseline Operability & Alert Integration).

Key changes
- Baseline compare: safe auto-close of stale baseline findings (gated on successful/complete compares)
- Baseline alerts: `baseline_high_drift` + `baseline_compare_failed` with dedupe/cooldown semantics
- Workspace settings: baseline severity mapping + minimum severity threshold + auto-close toggle
- Baseline Compare UX: shared stats layer + landing/widget consistency

Notes
- Livewire v4 / Filament v5 compatible.
- Destructive-like actions require confirmation (no new destructive actions added here).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/Baselines/ tests/Feature/Alerts/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #140
This commit is contained in:
ahmido 2026-03-01 02:26:47 +00:00
parent 0cf612826f
commit fdfb781144
33 changed files with 3588 additions and 203 deletions

View File

@ -5,14 +5,13 @@
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -58,6 +57,10 @@ class BaselineCompareLanding extends Page
public ?string $lastComparedAt = null;
public ?string $lastComparedIso = null;
public ?string $failureReason = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -79,101 +82,24 @@ public static function canAccess(): bool
public function mount(): void
{
$tenant = Tenant::current();
$this->refreshStats();
}
if (! $tenant instanceof Tenant) {
$this->state = 'no_tenant';
$this->message = 'No tenant selected.';
public function refreshStats(): void
{
$stats = BaselineCompareStats::forTenant(Tenant::current());
return;
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
$this->state = 'no_assignment';
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
return;
}
$profile = $assignment->baselineProfile;
if ($profile === null) {
$this->state = 'no_assignment';
$this->message = 'The assigned baseline profile no longer exists.';
return;
}
$this->profileName = (string) $profile->name;
$this->profileId = (int) $profile->getKey();
$this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
if ($this->snapshotId === null) {
$this->state = 'no_snapshot';
$this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
return;
}
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->latest('id')
->first();
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
$this->state = 'comparing';
$this->operationRunId = (int) $latestRun->getKey();
$this->message = 'A baseline comparison is currently in progress.';
return;
}
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
$this->lastComparedAt = $latestRun->finished_at->diffForHumans();
}
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findingsQuery = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey);
$totalFindings = (int) (clone $findingsQuery)->count();
if ($totalFindings > 0) {
$this->state = 'ready';
$this->findingsCount = $totalFindings;
$this->severityCounts = [
'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
];
if ($latestRun instanceof OperationRun) {
$this->operationRunId = (int) $latestRun->getKey();
}
return;
}
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
$this->state = 'ready';
$this->findingsCount = 0;
$this->operationRunId = (int) $latestRun->getKey();
$this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
return;
}
$this->state = 'idle';
$this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
$this->state = $stats->state;
$this->message = $stats->message;
$this->profileName = $stats->profileName;
$this->profileId = $stats->profileId;
$this->snapshotId = $stats->snapshotId;
$this->operationRunId = $stats->operationRunId;
$this->findingsCount = $stats->findingsCount;
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
$this->lastComparedAt = $stats->lastComparedHuman;
$this->lastComparedIso = $stats->lastComparedIso;
$this->failureReason = $stats->failureReason;
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
@ -206,7 +132,7 @@ private function compareNowAction(): Action
->modalHeading('Start baseline comparison')
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
->visible(fn (): bool => $this->canCompare())
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
->action(function (): void {
$user = auth()->user();

View File

@ -20,6 +20,7 @@
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -47,12 +48,15 @@ class WorkspaceSettings extends Page
protected static ?int $navigationSort = 20;
/**
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
*/
private const SETTING_FIELDS = [
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
'baseline_severity_mapping' => ['domain' => 'baseline', 'key' => 'severity_mapping', 'type' => 'json'],
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
@ -79,6 +83,17 @@ class WorkspaceSettings extends Page
'findings_sla_low' => 'low',
];
/**
* Baseline severity mapping is edited as explicit fields per change type.
*
* @var array<string, string>
*/
private const BASELINE_MAPPING_SUB_FIELDS = [
'baseline_severity_missing_policy' => 'missing_policy',
'baseline_severity_different_version' => 'different_version',
'baseline_severity_unexpected_policy' => 'unexpected_policy',
];
public Workspace $workspace;
/**
@ -206,6 +221,52 @@ public function content(Schema $schema): Schema
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
->hintAction($this->makeResetAction('drift_severity_mapping')),
]),
Section::make('Baseline settings')
->key('baseline_section')
->description($this->sectionDescription('baseline', 'Tune baseline drift severity mapping, alert threshold, and stale-finding auto-close behavior.'))
->columns(2)
->afterHeader([
$this->makeResetAction('baseline_severity_mapping')->label('Reset mapping')->size('sm'),
$this->makeResetAction('baseline_alert_min_severity')->label('Reset threshold')->size('sm'),
$this->makeResetAction('baseline_auto_close_enabled')->label('Reset auto-close')->size('sm'),
])
->schema([
Select::make('baseline_severity_missing_policy')
->label('Missing policy severity')
->options(self::severityOptions())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->baselineSeverityFieldHelperText('missing_policy')),
Select::make('baseline_severity_different_version')
->label('Different version severity')
->options(self::severityOptions())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->baselineSeverityFieldHelperText('different_version')),
Select::make('baseline_severity_unexpected_policy')
->label('Unexpected policy severity')
->options(self::severityOptions())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->baselineSeverityFieldHelperText('unexpected_policy')),
Select::make('baseline_alert_min_severity')
->label('Minimum alert severity')
->options(self::severityOptions())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('baseline_alert_min_severity')),
Select::make('baseline_auto_close_enabled')
->label('Auto-close stale drift')
->options(self::booleanOptions())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('baseline_auto_close_enabled')),
]),
Section::make('Findings settings')
->key('findings_section')
->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.'))
@ -302,6 +363,7 @@ public function save(): void
$this->resetValidation();
$this->composeBaselineSeveritySubFieldsIntoData();
$this->composeSlaSubFieldsIntoData();
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
@ -422,6 +484,7 @@ private function loadFormState(): void
: $this->formatValueForInput($field, $workspaceValue);
}
$this->decomposeBaselineSeveritySubFields($data, $workspaceOverrides);
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
$this->data = $data;
@ -560,6 +623,36 @@ private function slaFieldHelperText(string $severity): string
return sprintf('Effective: %d days.', $effectiveValue);
}
private function baselineSeverityFieldHelperText(string $changeType): string
{
$resolved = $this->resolvedSettings['baseline_severity_mapping'] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveValue = is_array($resolved['value'] ?? null)
? (string) ($resolved['value'][$changeType] ?? '')
: '';
$systemDefault = is_array($resolved['system_default'] ?? null)
? (string) ($resolved['system_default'][$changeType] ?? '')
: '';
if (! $this->hasWorkspaceOverride('baseline_severity_mapping')) {
return sprintf('Default: %s.', $systemDefault);
}
if (
is_array($this->workspaceOverrideForField('baseline_severity_mapping'))
&& array_key_exists($changeType, $this->workspaceOverrideForField('baseline_severity_mapping'))
) {
return sprintf('Effective: %s.', $effectiveValue);
}
return sprintf('Unset. Effective value: %s (system default).', $effectiveValue);
}
/**
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
*/
@ -612,6 +705,16 @@ private function normalizedInputValues(): array
continue;
}
if ($field === 'baseline_severity_mapping') {
foreach (array_keys(self::BASELINE_MAPPING_SUB_FIELDS) as $subField) {
$validationErrors['data.'.$subField] = $messages !== []
? $messages
: ['Invalid value.'];
}
continue;
}
$validationErrors['data.'.$field] = $messages !== []
? $messages
: ['Invalid value.'];
@ -834,6 +937,18 @@ private function formatValueForInput(string $field, mixed $value): mixed
return is_string($encoded) ? $encoded : null;
}
if ($setting['type'] === 'string') {
return is_string($value) ? $value : null;
}
if ($setting['type'] === 'bool') {
if (is_bool($value)) {
return $value ? '1' : '0';
}
return is_numeric($value) ? (string) (int) $value : null;
}
return is_numeric($value) ? (int) $value : null;
}
@ -851,6 +966,18 @@ private function formatValueForDisplay(string $field, mixed $value): string
return is_string($encoded) ? $encoded : '{}';
}
if ($setting['type'] === 'string') {
return is_string($value) && $value !== '' ? $value : 'null';
}
if ($setting['type'] === 'bool') {
if (is_bool($value)) {
return $value ? 'enabled' : 'disabled';
}
return 'null';
}
return is_numeric($value) ? (string) (int) $value : 'null';
}
@ -864,7 +991,7 @@ private function sourceLabel(string $source): string
}
/**
* @return array{domain: string, key: string, type: 'int'|'json'}
* @return array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}
*/
private function settingForField(string $field): array
{
@ -894,6 +1021,23 @@ private function workspaceOverrideForField(string $field): mixed
return $this->workspaceOverrides[$field] ?? null;
}
/**
* Decompose the baseline severity mapping JSON setting into explicit change-type sub-fields.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $workspaceOverrides
*/
private function decomposeBaselineSeveritySubFields(array &$data, array &$workspaceOverrides): void
{
$mappingOverride = $workspaceOverrides['baseline_severity_mapping'] ?? null;
foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) {
$data[$subField] = is_array($mappingOverride) && isset($mappingOverride[$changeType])
? (string) $mappingOverride[$changeType]
: null;
}
}
/**
* Decompose the findings_sla_days JSON setting into individual SLA sub-fields.
*
@ -913,6 +1057,26 @@ private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides,
}
}
/**
* Re-compose baseline severity mapping sub-fields back into the baseline_severity_mapping data key before save.
*/
private function composeBaselineSeveritySubFieldsIntoData(): void
{
$values = [];
foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) {
$value = $this->data[$subField] ?? null;
if (! is_string($value) || trim($value) === '') {
continue;
}
$values[$changeType] = strtolower(trim($value));
}
$this->data['baseline_severity_mapping'] = $values !== [] ? $values : null;
}
/**
* Re-compose individual SLA sub-fields back into the findings_sla_days data key before save.
*/
@ -933,6 +1097,30 @@ private function composeSlaSubFieldsIntoData(): void
$this->data['findings_sla_days'] = $hasAnyValue ? $values : null;
}
/**
* @return array<string, string>
*/
private static function severityOptions(): array
{
return [
'low' => 'Low',
'medium' => 'Medium',
'high' => 'High',
'critical' => 'Critical',
];
}
/**
* @return array<string, string>
*/
private static function booleanOptions(): array
{
return [
'1' => 'Enabled',
'0' => 'Disabled',
];
}
private function currentUserCanManage(): bool
{
$user = auth()->user();

View File

@ -378,6 +378,8 @@ public static function eventTypeOptions(): array
return [
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
AlertRule::EVENT_BASELINE_HIGH_DRIFT => 'Baseline drift',
AlertRule::EVENT_BASELINE_COMPARE_FAILED => 'Baseline compare failed',
AlertRule::EVENT_SLA_DUE => 'SLA due',
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',

View File

@ -42,6 +42,8 @@
class BaselineProfileResource extends Resource
{
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $model = BaselineProfile::class;

View File

@ -4,10 +4,8 @@
namespace App\Filament\Widgets\Dashboard;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareStats;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
@ -39,52 +37,20 @@ protected function getViewData(): array
return $empty;
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->with('baselineProfile')
->first();
$stats = BaselineCompareStats::forWidget($tenant);
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
return $empty;
}
$profile = $assignment->baselineProfile;
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findingsQuery = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->where('status', Finding::STATUS_NEW);
$findingsCount = (int) (clone $findingsQuery)->count();
$highCount = (int) (clone $findingsQuery)
->where('severity', Finding::SEVERITY_HIGH)
->count();
$mediumCount = (int) (clone $findingsQuery)
->where('severity', Finding::SEVERITY_MEDIUM)
->count();
$lowCount = (int) (clone $findingsQuery)
->where('severity', Finding::SEVERITY_LOW)
->count();
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->where('context->baseline_profile_id', (string) $profile->getKey())
->whereNotNull('completed_at')
->latest('completed_at')
->first();
return [
'hasAssignment' => true,
'profileName' => (string) $profile->name,
'findingsCount' => $findingsCount,
'highCount' => $highCount,
'mediumCount' => $mediumCount,
'lowCount' => $lowCount,
'lastComparedAt' => $latestRun?->finished_at?->diffForHumans(),
'profileName' => $stats->profileName,
'findingsCount' => $stats->findingsCount ?? 0,
'highCount' => $stats->severityCounts['high'] ?? 0,
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
'lowCount' => $stats->severityCounts['low'] ?? 0,
'lastComparedAt' => $stats->lastComparedHuman,
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
];
}

View File

@ -10,14 +10,17 @@
use App\Models\Workspace;
use App\Services\Alerts\AlertDispatchService;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
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 Throwable;
class EvaluateAlertsJob implements ShouldQueue
@ -58,8 +61,10 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
try {
$events = [
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
...$this->baselineHighDriftEvents((int) $workspace->getKey(), $windowStart),
...$this->slaDueEvents((int) $workspace->getKey(), $windowStart),
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
...$this->baselineCompareFailedEvents((int) $workspace->getKey(), $windowStart),
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
];
@ -206,6 +211,84 @@ private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart)
return $events;
}
/**
* @return array<int, array<string, mixed>>
*/
private function baselineHighDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$minimumSeverity = $this->baselineAlertMinimumSeverity($workspaceId);
$findings = Finding::query()
->where('workspace_id', $workspaceId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
->where(function ($query) use ($windowStart): void {
$query
->where(function ($statusQuery) use ($windowStart): void {
$statusQuery
->where('status', Finding::STATUS_NEW)
->where('created_at', '>', $windowStart);
})
->orWhere(function ($statusQuery) use ($windowStart): void {
$statusQuery
->where('status', Finding::STATUS_REOPENED)
->where(function ($reopenedQuery) use ($windowStart): void {
$reopenedQuery
->where('reopened_at', '>', $windowStart)
->orWhere(function ($fallbackQuery) use ($windowStart): void {
$fallbackQuery
->whereNull('reopened_at')
->where('updated_at', '>', $windowStart);
});
});
});
})
->orderBy('id')
->get();
$events = [];
foreach ($findings as $finding) {
$severity = strtolower(trim((string) $finding->severity));
if (! $this->meetsMinimumSeverity($severity, $minimumSeverity)) {
continue;
}
$fingerprint = trim((string) $finding->fingerprint);
$changeType = strtolower(trim((string) Arr::get($finding->evidence_jsonb, 'change_type', '')));
if ($fingerprint === '' || ! in_array($changeType, [
'missing_policy',
'different_version',
'unexpected_policy',
], true)) {
continue;
}
$events[] = [
'event_type' => AlertRule::EVENT_BASELINE_HIGH_DRIFT,
'tenant_id' => (int) $finding->tenant_id,
'severity' => $severity,
'fingerprint_key' => 'finding_fingerprint:'.$fingerprint,
'title' => 'Baseline drift detected',
'body' => sprintf(
'Baseline finding %d requires attention (%s).',
(int) $finding->getKey(),
str_replace('_', ' ', $changeType),
),
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'finding_fingerprint' => $fingerprint,
'change_type' => $changeType,
],
];
}
return $events;
}
/**
* @return array<int, array<string, mixed>>
*/
@ -236,7 +319,51 @@ private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowSt
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
'title' => 'Drift compare failed',
'body' => $this->firstFailureMessage($failedRun),
'body' => $this->firstFailureMessage($failedRun, 'A drift compare operation run failed.'),
'metadata' => [
'operation_run_id' => (int) $failedRun->getKey(),
],
];
}
return $events;
}
/**
* @return array<int, array<string, mixed>>
*/
private function baselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$failedRuns = OperationRun::query()
->where('workspace_id', $workspaceId)
->whereNotNull('tenant_id')
->where('type', OperationRunType::BaselineCompare->value)
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Failed->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->whereNotNull('completed_at')
->where('completed_at', '>', $windowStart)
->orderBy('id')
->get();
$events = [];
foreach ($failedRuns as $failedRun) {
$tenantId = (int) ($failedRun->tenant_id ?? 0);
if ($tenantId <= 0) {
continue;
}
$events[] = [
'event_type' => AlertRule::EVENT_BASELINE_COMPARE_FAILED,
'tenant_id' => $tenantId,
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
'title' => 'Baseline compare failed',
'body' => $this->firstFailureMessage($failedRun, 'A baseline compare operation run failed.'),
'metadata' => [
'operation_run_id' => (int) $failedRun->getKey(),
],
@ -370,7 +497,7 @@ private function slaDueEvents(int $workspaceId, CarbonImmutable $windowStart): a
return $events;
}
private function firstFailureMessage(OperationRun $run): string
private function firstFailureMessage(OperationRun $run, string $defaultMessage): string
{
$failures = is_array($run->failure_summary) ? $run->failure_summary : [];
@ -386,7 +513,52 @@ private function firstFailureMessage(OperationRun $run): string
}
}
return 'A drift compare operation run failed.';
return $defaultMessage;
}
private function baselineAlertMinimumSeverity(int $workspaceId): string
{
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return Finding::SEVERITY_HIGH;
}
try {
$value = app(SettingsResolver::class)->resolveValue(
workspace: $workspace,
domain: 'baseline',
key: 'alert_min_severity',
);
} catch (\InvalidArgumentException) {
return Finding::SEVERITY_HIGH;
}
$severity = strtolower(trim((string) $value));
return array_key_exists($severity, $this->severityRank())
? $severity
: Finding::SEVERITY_HIGH;
}
private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool
{
$rank = $this->severityRank();
return ($rank[$eventSeverity] ?? 0) >= ($rank[$minimumSeverity] ?? 0);
}
/**
* @return array<string, int>
*/
private function severityRank(): array
{
return [
Finding::SEVERITY_LOW => 1,
Finding::SEVERITY_MEDIUM => 2,
Finding::SEVERITY_HIGH => 3,
Finding::SEVERITY_CRITICAL => 4,
];
}
private function sanitizeErrorMessage(Throwable $exception): string

View File

@ -12,13 +12,18 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -51,7 +56,12 @@ public function handle(
BaselineSnapshotIdentity $snapshotIdentity,
AuditLogger $auditLogger,
OperationRunService $operationRunService,
?SettingsResolver $settingsResolver = null,
?BaselineAutoCloseService $baselineAutoCloseService = null,
): void {
$settingsResolver ??= app(SettingsResolver::class);
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
if (! $this->operationRun instanceof OperationRun) {
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
@ -74,21 +84,32 @@ public function handle(
throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found.");
}
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
if (! $workspace instanceof Workspace) {
throw new RuntimeException("Workspace #{$tenant->workspace_id} not found.");
}
$initiator = $this->operationRun->user_id
? User::query()->find($this->operationRun->user_id)
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$scopeKey = 'baseline_profile:' . $profile->getKey();
$scopeKey = 'baseline_profile:'.$profile->getKey();
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
$baselineItems = $this->loadBaselineItems($snapshotId);
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity);
$baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope);
$latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant);
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId);
$driftResults = $this->computeDrift($baselineItems, $currentItems);
$driftResults = $this->computeDrift(
$baselineItems,
$currentItems,
$this->resolveSeverityMapping($workspace, $settingsResolver),
);
$upsertedCount = $this->upsertFindings(
$upsertResult = $this->upsertFindings(
$driftHasher,
$tenant,
$profile,
@ -101,11 +122,14 @@ public function handle(
$summaryCounts = [
'total' => count($driftResults),
'processed' => count($driftResults),
'succeeded' => $upsertedCount,
'failed' => count($driftResults) - $upsertedCount,
'succeeded' => (int) $upsertResult['processed_count'],
'failed' => 0,
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
'findings_created' => (int) $upsertResult['created_count'],
'findings_reopened' => (int) $upsertResult['reopened_count'],
'findings_unchanged' => (int) $upsertResult['unchanged_count'],
];
$operationRunService->updateRun(
@ -115,10 +139,31 @@ public function handle(
summaryCounts: $summaryCounts,
);
$resolvedCount = 0;
if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) {
$resolvedCount = $baselineAutoCloseService->resolveStaleFindings(
tenant: $tenant,
baselineProfileId: (int) $profile->getKey(),
seenFingerprints: $upsertResult['seen_fingerprints'],
currentOperationRunId: (int) $this->operationRun->getKey(),
);
$summaryCounts['findings_resolved'] = $resolvedCount;
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: $summaryCounts,
);
}
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['result'] = [
'findings_total' => count($driftResults),
'findings_upserted' => $upsertedCount,
'findings_upserted' => (int) $upsertResult['processed_count'],
'findings_resolved' => $resolvedCount,
'severity_breakdown' => $severityBreakdown,
];
$this->operationRun->update(['context' => $updatedContext]);
@ -131,16 +176,22 @@ public function handle(
*
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
private function loadBaselineItems(int $snapshotId): array
private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array
{
$items = [];
BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshotId)
$query = BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshotId);
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
}
$query
->orderBy('id')
->chunk(500, function ($snapshotItems) use (&$items): void {
foreach ($snapshotItems as $item) {
$key = $item->policy_type . '|' . $item->subject_external_id;
$key = $item->policy_type.'|'.$item->subject_external_id;
$items[$key] = [
'subject_type' => (string) $item->subject_type,
'subject_external_id' => (string) $item->subject_external_id,
@ -163,10 +214,15 @@ private function loadCurrentInventory(
Tenant $tenant,
BaselineScope $scope,
BaselineSnapshotIdentity $snapshotIdentity,
?int $latestInventorySyncRunId = null,
): array {
$query = InventoryItem::query()
->where('tenant_id', $tenant->getKey());
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
}
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
}
@ -180,7 +236,7 @@ private function loadCurrentInventory(
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
$key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id;
$key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id;
$items[$key] = [
'subject_external_id' => (string) $inventoryItem->external_id,
'policy_type' => (string) $inventoryItem->policy_type,
@ -197,14 +253,30 @@ private function loadCurrentInventory(
return $items;
}
private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
{
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::InventorySync->value)
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first(['id']);
$runId = $run?->getKey();
return is_numeric($runId) ? (int) $runId : null;
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
* @param array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @param array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, string> $severityMapping
* @return array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>
*/
private function computeDrift(array $baselineItems, array $currentItems): array
private function computeDrift(array $baselineItems, array $currentItems, array $severityMapping): array
{
$drift = [];
@ -212,7 +284,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
if (! array_key_exists($key, $currentItems)) {
$drift[] = [
'change_type' => 'missing_policy',
'severity' => Finding::SEVERITY_HIGH,
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'policy_type' => $baselineItem['policy_type'],
@ -233,7 +305,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
$drift[] = [
'change_type' => 'different_version',
'severity' => Finding::SEVERITY_MEDIUM,
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'policy_type' => $baselineItem['policy_type'],
@ -254,7 +326,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
if (! array_key_exists($key, $baselineItems)) {
$drift[] = [
'change_type' => 'unexpected_policy',
'severity' => Finding::SEVERITY_LOW,
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
'subject_type' => 'policy',
'subject_external_id' => $currentItem['subject_external_id'],
'policy_type' => $currentItem['policy_type'],
@ -276,6 +348,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
* Upsert drift findings using stable fingerprints.
*
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
*/
private function upsertFindings(
DriftHasher $driftHasher,
@ -283,9 +356,14 @@ private function upsertFindings(
BaselineProfile $profile,
string $scopeKey,
array $driftResults,
): int {
$upsertedCount = 0;
): array {
$tenantId = (int) $tenant->getKey();
$observedAt = CarbonImmutable::now();
$processedCount = 0;
$createdCount = 0;
$reopenedCount = 0;
$unchangedCount = 0;
$seenFingerprints = [];
foreach ($driftResults as $driftItem) {
$fingerprint = $driftHasher->fingerprint(
@ -298,29 +376,99 @@ private function upsertFindings(
currentHash: $driftItem['current_hash'],
);
Finding::query()->updateOrCreate(
[
'tenant_id' => $tenantId,
'fingerprint' => $fingerprint,
],
[
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'subject_type' => $driftItem['subject_type'],
'subject_external_id' => $driftItem['subject_external_id'],
'severity' => $driftItem['severity'],
'status' => Finding::STATUS_NEW,
'evidence_jsonb' => $driftItem['evidence'],
'baseline_operation_run_id' => null,
'current_operation_run_id' => (int) $this->operationRun->getKey(),
],
);
$seenFingerprints[] = $fingerprint;
$upsertedCount++;
$finding = Finding::query()
->where('tenant_id', $tenantId)
->where('fingerprint', $fingerprint)
->first();
$isNewFinding = ! $finding instanceof Finding;
if ($isNewFinding) {
$finding = new Finding;
} else {
$this->observeFinding(
finding: $finding,
observedAt: $observedAt,
currentOperationRunId: (int) $this->operationRun->getKey(),
);
}
$finding->forceFill([
'tenant_id' => $tenantId,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'subject_type' => $driftItem['subject_type'],
'subject_external_id' => $driftItem['subject_external_id'],
'severity' => $driftItem['severity'],
'fingerprint' => $fingerprint,
'evidence_jsonb' => $driftItem['evidence'],
'baseline_operation_run_id' => null,
'current_operation_run_id' => (int) $this->operationRun->getKey(),
]);
if ($isNewFinding) {
$finding->forceFill([
'status' => Finding::STATUS_NEW,
'reopened_at' => null,
'resolved_at' => null,
'resolved_reason' => null,
'acknowledged_at' => null,
'acknowledged_by_user_id' => null,
'first_seen_at' => $observedAt,
'last_seen_at' => $observedAt,
'times_seen' => 1,
]);
$createdCount++;
} elseif (Finding::isTerminalStatus($finding->status)) {
$finding->forceFill([
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now(),
'resolved_at' => null,
'resolved_reason' => null,
'closed_at' => null,
'closed_reason' => null,
'closed_by_user_id' => null,
]);
$reopenedCount++;
} else {
$unchangedCount++;
}
$finding->save();
$processedCount++;
}
return $upsertedCount;
return [
'processed_count' => $processedCount,
'created_count' => $createdCount,
'reopened_count' => $reopenedCount,
'unchanged_count' => $unchangedCount,
'seen_fingerprints' => array_values(array_unique($seenFingerprints)),
];
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
$finding->last_seen_at = $observedAt;
}
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) {
$finding->times_seen = max(0, $timesSeen) + 1;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
}
/**
@ -339,6 +487,44 @@ private function countBySeverity(array $driftResults): array
return $counts;
}
/**
* @return array<string, string>
*/
private function resolveSeverityMapping(Workspace $workspace, SettingsResolver $settingsResolver): array
{
try {
$mapping = $settingsResolver->resolveValue(
workspace: $workspace,
domain: 'baseline',
key: 'severity_mapping',
);
} catch (\InvalidArgumentException) {
// Settings keys are registry-backed; if this key is missing (e.g. during rollout),
// fall back to built-in defaults rather than failing the entire compare run.
return [];
}
return is_array($mapping) ? $mapping : [];
}
/**
* @param array<string, string> $severityMapping
*/
private function severityForChangeType(array $severityMapping, string $changeType): string
{
$severity = $severityMapping[$changeType] ?? null;
if (! is_string($severity) || $severity === '') {
return match ($changeType) {
'missing_policy' => Finding::SEVERITY_HIGH,
'different_version' => Finding::SEVERITY_MEDIUM,
default => Finding::SEVERITY_LOW,
};
}
return $severity;
}
private function auditStarted(
AuditLogger $auditLogger,
Tenant $tenant,

View File

@ -18,6 +18,10 @@ class AlertRule extends Model
public const string EVENT_COMPARE_FAILED = 'compare_failed';
public const string EVENT_BASELINE_HIGH_DRIFT = 'baseline_high_drift';
public const string EVENT_BASELINE_COMPARE_FAILED = 'baseline_compare_failed';
public const string EVENT_SLA_DUE = 'sla_due';
public const string EVENT_PERMISSION_MISSING = 'permission_missing';

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
final class BaselineAutoCloseService
{
public function __construct(
private readonly SettingsResolver $settingsResolver,
) {}
public function shouldAutoClose(Tenant $tenant, OperationRun $run): bool
{
if ($run->status !== OperationRunStatus::Completed->value) {
return false;
}
if ($run->outcome !== OperationRunOutcome::Succeeded->value) {
return false;
}
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
if (
! array_key_exists('total', $summaryCounts)
|| ! array_key_exists('processed', $summaryCounts)
|| ! array_key_exists('failed', $summaryCounts)
) {
return false;
}
$total = (int) $summaryCounts['total'];
$processed = (int) $summaryCounts['processed'];
$failed = (int) $summaryCounts['failed'];
if ($processed !== $total || $failed !== 0) {
return false;
}
$workspace = $this->resolveWorkspace($tenant);
if (! $workspace instanceof Workspace) {
return false;
}
try {
return (bool) $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: 'baseline',
key: 'auto_close_enabled',
);
} catch (\InvalidArgumentException) {
return false;
}
}
/**
* @param array<int, string> $seenFingerprints
*/
public function resolveStaleFindings(
Tenant $tenant,
int $baselineProfileId,
array $seenFingerprints,
int $currentOperationRunId,
): int {
$scopeKey = 'baseline_profile:'.$baselineProfileId;
$resolvedAt = now();
$resolvedCount = 0;
$query = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->whereIn('status', Finding::openStatusesForQuery())
->orderBy('id');
if ($seenFingerprints !== []) {
$query->whereNotIn('fingerprint', array_values(array_unique($seenFingerprints)));
}
$query->chunkById(100, function ($findings) use (&$resolvedCount, $resolvedAt, $currentOperationRunId): void {
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
$finding->forceFill([
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => $resolvedAt,
'resolved_reason' => 'no_longer_drifting',
'current_operation_run_id' => $currentOperationRunId,
])->save();
$resolvedCount++;
}
});
return $resolvedCount;
}
private function resolveWorkspace(Tenant $tenant): ?Workspace
{
$workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
}

View File

@ -12,6 +12,7 @@
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunType;
final class BaselineCaptureService
{
@ -45,7 +46,7 @@ public function startCapture(
$run = $this->runs->ensureRunWithIdentity(
tenant: $sourceTenant,
type: 'baseline_capture',
type: OperationRunType::BaselineCapture->value,
identityInputs: [
'baseline_profile_id' => (int) $profile->getKey(),
],

View File

@ -13,6 +13,7 @@
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunType;
final class BaselineCompareService
{
@ -67,7 +68,7 @@ public function startCompare(
$run = $this->runs->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
type: OperationRunType::BaselineCompare->value,
identityInputs: [
'baseline_profile_id' => (int) $profile->getKey(),
],

View File

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
final class BaselineCompareStats
{
/**
* @param array<string, int> $severityCounts
*/
private function __construct(
public readonly string $state,
public readonly ?string $message,
public readonly ?string $profileName,
public readonly ?int $profileId,
public readonly ?int $snapshotId,
public readonly ?int $operationRunId,
public readonly ?int $findingsCount,
public readonly array $severityCounts,
public readonly ?string $lastComparedHuman,
public readonly ?string $lastComparedIso,
public readonly ?string $failureReason,
) {}
public static function forTenant(?Tenant $tenant): self
{
if (! $tenant instanceof Tenant) {
return self::empty('no_tenant', 'No tenant selected.');
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
return self::empty(
'no_assignment',
'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.',
);
}
$profile = $assignment->baselineProfile;
if (! $profile instanceof BaselineProfile) {
return self::empty(
'no_assignment',
'The assigned baseline profile no longer exists.',
);
}
$profileName = (string) $profile->name;
$profileId = (int) $profile->getKey();
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
if ($snapshotId === null) {
return self::empty(
'no_snapshot',
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
profileName: $profileName,
profileId: $profileId,
);
}
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->latest('id')
->first();
// Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
return new self(
state: 'comparing',
message: 'A baseline comparison is currently in progress.',
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
);
}
// Failed run — explicit error state
if ($latestRun instanceof OperationRun && $latestRun->outcome === 'failed') {
$failureSummary = is_array($latestRun->failure_summary) ? $latestRun->failure_summary : [];
$failureReason = $failureSummary['message']
?? $failureSummary['reason']
?? 'The comparison job failed. Check the run details for more information.';
return new self(
state: 'failed',
message: (string) $failureReason,
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
failureReason: (string) $failureReason,
);
}
$lastComparedHuman = null;
$lastComparedIso = null;
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
$lastComparedHuman = $latestRun->finished_at->diffForHumans();
$lastComparedIso = $latestRun->finished_at->toIso8601String();
}
$scopeKey = 'baseline_profile:'.$profile->getKey();
// Single grouped query instead of 4 separate COUNT queries
$severityRows = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->whereIn('status', Finding::openStatusesForQuery())
->selectRaw('severity, count(*) as cnt')
->groupBy('severity')
->pluck('cnt', 'severity');
$totalFindings = (int) $severityRows->sum();
$severityCounts = [
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
];
if ($totalFindings > 0) {
return new self(
state: 'ready',
message: null,
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
);
}
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
return new self(
state: 'ready',
message: 'No open drift findings for this baseline comparison. The tenant matches the baseline.',
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: (int) $latestRun->getKey(),
findingsCount: 0,
severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
);
}
return new self(
state: 'idle',
message: 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.',
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: null,
findingsCount: null,
severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
);
}
/**
* Create a DTO for widget consumption (only open/new findings).
*/
public static function forWidget(?Tenant $tenant): self
{
if (! $tenant instanceof Tenant) {
return self::empty('no_tenant', null);
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->with('baselineProfile')
->first();
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
return self::empty('no_assignment', null);
}
$profile = $assignment->baselineProfile;
$scopeKey = 'baseline_profile:'.$profile->getKey();
$severityRows = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->where('status', Finding::STATUS_NEW)
->selectRaw('severity, count(*) as cnt')
->groupBy('severity')
->pluck('cnt', 'severity');
$totalFindings = (int) $severityRows->sum();
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->where('context->baseline_profile_id', (string) $profile->getKey())
->whereNotNull('completed_at')
->latest('completed_at')
->first();
return new self(
state: $totalFindings > 0 ? 'ready' : 'idle',
message: null,
profileName: (string) $profile->name,
profileId: (int) $profile->getKey(),
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: [
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
],
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
failureReason: null,
);
}
private static function empty(
string $state,
?string $message,
?string $profileName = null,
?int $profileId = null,
): self {
return new self(
state: $state,
message: $message,
profileName: $profileName,
profileId: $profileId,
snapshotId: null,
operationRunId: null,
findingsCount: null,
severityCounts: [],
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
);
}
}

View File

@ -4,6 +4,8 @@
enum OperationRunType: string
{
case BaselineCapture = 'baseline_capture';
case BaselineCompare = 'baseline_compare';
case InventorySync = 'inventory_sync';
case PolicySync = 'policy.sync';
case PolicySyncOne = 'policy.sync_one';

View File

@ -80,6 +80,74 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value),
));
$this->register(new SettingDefinition(
domain: 'baseline',
key: 'severity_mapping',
type: 'json',
systemDefault: self::defaultBaselineSeverityMapping(),
rules: [
'required',
'array',
static function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_array($value)) {
$fail('The baseline severity mapping must be a JSON object.');
return;
}
$supportedKeys = array_fill_keys(self::supportedBaselineChangeTypes(), true);
foreach ($value as $changeType => $severity) {
if (! is_string($changeType) || ! isset($supportedKeys[$changeType])) {
$fail(sprintf(
'Baseline severity mapping keys must be one of: %s.',
implode(', ', self::supportedBaselineChangeTypes()),
));
return;
}
if (! is_string($severity)) {
$fail(sprintf('Severity for "%s" must be a string.', $changeType));
return;
}
$normalizedSeverity = strtolower($severity);
if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) {
$fail(sprintf(
'Severity for "%s" must be one of: %s.',
$changeType,
implode(', ', self::supportedFindingSeverities()),
));
return;
}
}
},
],
normalizer: static fn (mixed $value): array => self::normalizeBaselineSeverityMapping($value),
));
$this->register(new SettingDefinition(
domain: 'baseline',
key: 'alert_min_severity',
type: 'string',
systemDefault: Finding::SEVERITY_HIGH,
rules: ['required', 'string', 'in:low,medium,high,critical'],
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
));
$this->register(new SettingDefinition(
domain: 'baseline',
key: 'auto_close_enabled',
type: 'bool',
systemDefault: true,
rules: ['required', 'boolean'],
normalizer: static fn (mixed $value): bool => filter_var($value, FILTER_VALIDATE_BOOL),
));
$this->register(new SettingDefinition(
domain: 'findings',
key: 'sla_days',
@ -223,6 +291,61 @@ private static function normalizeSeverityMapping(mixed $value): array
return $normalized;
}
/**
* @return array<int, string>
*/
private static function supportedBaselineChangeTypes(): array
{
return [
'different_version',
'missing_policy',
'unexpected_policy',
];
}
/**
* @return array<string, string>
*/
private static function defaultBaselineSeverityMapping(): array
{
return [
'different_version' => Finding::SEVERITY_MEDIUM,
'missing_policy' => Finding::SEVERITY_HIGH,
'unexpected_policy' => Finding::SEVERITY_LOW,
];
}
/**
* @return array<string, string>
*/
private static function normalizeBaselineSeverityMapping(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
$supportedKeys = array_fill_keys(self::supportedBaselineChangeTypes(), true);
foreach ($value as $changeType => $severity) {
if (! is_string($changeType) || ! isset($supportedKeys[$changeType]) || ! is_string($severity)) {
continue;
}
$normalized[$changeType] = strtolower($severity);
}
$ordered = [];
foreach (self::defaultBaselineSeverityMapping() as $changeType => $_severity) {
if (array_key_exists($changeType, $normalized)) {
$ordered[$changeType] = $normalized[$changeType];
}
}
return $ordered;
}
/**
* @return array<string, int>
*/

View File

@ -1,6 +1,11 @@
<x-filament::page>
{{-- Auto-refresh while comparison is running --}}
@if ($state === 'comparing')
<div wire:poll.5s="refreshStats"></div>
@endif
{{-- Row 1: Stats Overview --}}
@if (in_array($state, ['ready', 'idle', 'comparing']))
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{{-- Stat: Assigned Baseline --}}
<x-filament::section>
@ -19,9 +24,13 @@
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div>
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
{{ $findingsCount ?? 0 }}
</div>
@if ($state === 'failed')
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
@else
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
{{ $findingsCount ?? 0 }}
</div>
@endif
@if ($state === 'comparing')
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
<x-filament::loading-indicator class="h-3 w-3" />
@ -37,7 +46,7 @@
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Compared</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">
<div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif>
{{ $lastComparedAt ?? 'Never' }}
</div>
@if ($this->getRunUrl())
@ -50,6 +59,37 @@
</div>
@endif
{{-- Failed run banner --}}
@if ($state === 'failed')
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
<div class="flex items-start gap-3">
<x-heroicon-s-x-circle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
<div class="flex flex-col gap-1">
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
Comparison Failed
</div>
<div class="text-sm text-danger-700 dark:text-danger-300">
{{ $failureReason ?? 'The last baseline comparison failed. Review the run details or retry.' }}
</div>
<div class="mt-2 flex items-center gap-3">
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="danger"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
View failed run
</x-filament::button>
@endif
</div>
</div>
</div>
</div>
@endif
{{-- Critical drift banner --}}
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Baseline Operability & Alert Integration
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-28
**Feature**: [specs/115-baseline-operability-alerts/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation complete.
- The spec intentionally references internal platform concepts (capability registry, operation runs, admin UI action surfaces) because they are required by the repositorys constitution; it does not prescribe languages, external APIs, or framework implementation steps.

View File

@ -0,0 +1,70 @@
openapi: 3.1.0
info:
title: TenantPilot Baseline Alert Events
version: 1.0.0
description: |
Domain contract for baseline-related alert events produced during alerts evaluation.
This repo dispatches events internally (not a public HTTP API).
paths: {}
components:
schemas:
AlertEventBase:
type: object
required: [event_type, tenant_id, severity, fingerprint_key, title, body, metadata]
properties:
event_type:
type: string
tenant_id:
type: integer
minimum: 1
severity:
type: string
enum: [low, medium, high, critical]
fingerprint_key:
type: string
minLength: 1
title:
type: string
body:
type: string
metadata:
type: object
additionalProperties: true
BaselineHighDriftEvent:
allOf:
- $ref: '#/components/schemas/AlertEventBase'
- type: object
properties:
event_type:
const: baseline_high_drift
metadata:
type: object
required: [finding_id, finding_fingerprint, change_type]
properties:
finding_id:
type: integer
minimum: 1
finding_fingerprint:
type: string
minLength: 1
change_type:
type: string
enum: [missing_policy, different_version, unexpected_policy]
BaselineCompareFailedEvent:
allOf:
- $ref: '#/components/schemas/AlertEventBase'
- type: object
properties:
event_type:
const: baseline_compare_failed
severity:
const: high
metadata:
type: object
required: [operation_run_id]
properties:
operation_run_id:
type: integer
minimum: 1

View File

@ -0,0 +1,63 @@
# Data Model — Baseline Operability & Alert Integration (Spec 115)
This spec extends existing models and introduces no new tables.
## Entities
### 1) Finding (existing: `App\Models\Finding`)
Baseline compare findings are a subset of drift findings.
Key fields used/extended by this feature:
- `workspace_id` (derived from tenant)
- `tenant_id`
- `finding_type` = `drift`
- `source` = `baseline.compare` (stable contract)
- `scope_key` = `baseline_profile:{baseline_profile_id}` (stable grouping)
- `fingerprint` (stable identifier; used for idempotent upsert + alert dedupe)
- `status` (lifecycle): `new`, `reopened`, other open states, and terminal states
- `reopened_at`, `resolved_at`, `resolved_reason`
- `severity` (`low|medium|high|critical`)
- `evidence_jsonb` (must include at least `change_type`)
- `current_operation_run_id` (the compare run that most recently observed the finding)
Lifecycle rules for baseline compare findings:
- New fingerprint → create finding with `status=new`.
- Existing finding in terminal state (at least `resolved`) observed again → set `status=reopened`, `reopened_at=now`, clear resolved fields.
- Existing open finding observed again → do not override workflow status.
- Stale open findings (not observed in a fully successful compare) → set `status=resolved`, `resolved_reason=no_longer_drifting`, `resolved_at=now`.
### 2) OperationRun (existing: `App\Models\OperationRun`)
Baseline compare runs are represented as:
- `type = baseline_compare`
- `tenant_id` required (tenant-scoped operation)
- `status/outcome` managed exclusively via `OperationRunService`
- `summary_counts` used for:
- completeness: `processed == total`
- safety: `failed == 0`
Baseline capture runs are represented as:
- `type = baseline_capture`
### 3) WorkspaceSetting (existing: `App\Models\WorkspaceSetting`)
New workspace keys (domain `baseline`):
- `baseline.severity_mapping` (json object)
- Keys MUST be exactly: `missing_policy`, `different_version`, `unexpected_policy`
- Values MUST be one of: `low|medium|high|critical`
- `baseline.alert_min_severity` (string)
- Allowed: `low|medium|high|critical`
- Default: `high`
- `baseline.auto_close_enabled` (bool)
- Default: `true`
Effective value rules:
- Consumers read via `SettingsResolver`, which merges system defaults with workspace overrides.
## Derived/Computed Values
- Baseline finding severity is computed at creation time from `baseline.severity_mapping[change_type]`.
- Baseline alert eligibility is computed at alert-evaluation time from:
- finding `source` + `status` + timestamps vs `windowStart`
- finding `severity` vs `baseline.alert_min_severity`
## Invariants
- `Finding.source = baseline.compare` MUST be stable and queryable.
- Auto-close MUST only execute if the compare run is complete (`processed==total`) and safe (`failed==0`) and `baseline.auto_close_enabled` is true.

View File

@ -0,0 +1,166 @@
# Implementation Plan: Baseline Operability & Alert Integration (Spec 115)
**Branch**: `115-baseline-operability-alerts` | **Date**: 2026-02-28 | **Spec**: `specs/115-baseline-operability-alerts/spec.md`
**Input**: Feature specification from `specs/115-baseline-operability-alerts/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Implement safe baseline finding auto-close after fully successful baseline compares.
- Add baseline-specific alert events (`baseline_high_drift`, `baseline_compare_failed`) with precise new/reopened-only semantics and existing cooldown handling.
- Introduce workspace settings for baseline severity mapping, minimum alert severity, and an auto-close kill-switch.
- Normalize baseline run types via the canonical run type registry.
## Technical Context
**Language/Version**: PHP 8.4.x (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail
**Storage**: PostgreSQL (Sail) + JSONB
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
**Target Platform**: Web application
**Project Type**: Laravel monolith
**Performance Goals**: N/A (ops correctness + low-noise alerting)
**Constraints**: Strict Ops-UX + RBAC-UX compliance; no extra Graph calls; Monitoring render is DB-only
**Scale/Scope**: Workspace-scoped settings + tenant-scoped findings/runs
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS (baseline compare reads inventory as “last observed”, no snapshot semantics changed).
- Read/write separation: PASS (auto-close is internal lifecycle management; no Graph writes introduced).
- Graph contract path: PASS (no new Graph calls).
- Deterministic capabilities: PASS (uses existing capability registry for any UI settings mutations).
- RBAC-UX: PASS (workspace settings mutations already enforce membership 404 + capability 403 in `SettingsWriter`; tenant-context compare surfaces remain tenant-scoped).
- Workspace & tenant isolation: PASS (all findings and runs remain workspace+tenant scoped; alert dispatch validates tenant belongs to workspace).
- Run observability: PASS (baseline compare/capture already run via `OperationRunService`; alerts evaluation is an `OperationRun`).
- Ops-UX 3-surface feedback: PASS (no new notification surfaces; uses existing Ops UX patterns).
- Ops-UX lifecycle + summary counts + guards: PASS (all run transitions via `OperationRunService`; summary keys remain canonical).
- Filament Action Surface Contract / UX-001: PASS (only adds fields/actions to existing pages; no new resources required).
## Project Structure
### Documentation (this feature)
```text
specs/115-baseline-operability-alerts/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Settings/
│ │ └── WorkspaceSettings.php
│ └── Pages/
│ └── BaselineCompareLanding.php
├── Jobs/
│ ├── CompareBaselineToTenantJob.php
│ └── Alerts/
│ └── EvaluateAlertsJob.php
├── Models/
│ ├── Finding.php
│ ├── AlertRule.php
│ └── WorkspaceSetting.php
├── Services/
│ ├── Baselines/
│ │ ├── BaselineCompareService.php
│ │ └── BaselineCaptureService.php
│ ├── Alerts/
│ │ ├── AlertDispatchService.php
│ │ └── AlertFingerprintService.php
│ └── Settings/
│ ├── SettingsResolver.php
│ └── SettingsWriter.php
└── Support/
├── OperationRunType.php
└── Settings/
└── SettingsRegistry.php
tests/
└── Feature/
├── Alerts/
└── Baselines/
```
**Structure Decision**: Laravel monolith with Filament admin UI. This feature touches Jobs, Services, Settings infrastructure, and adds/updates Pest feature tests.
## Phase 0 — Outline & Research (Complete)
Outputs:
- `specs/115-baseline-operability-alerts/research.md`
Unknowns resolved:
- Which summary counters can be used for completeness gating (reuse `total/processed/failed`).
- How to implement reopen/resolve stale semantics without breaking workflow status.
- How alert event dedupe/cooldown works and what keys are used.
- How workspace settings are stored/validated and how effective values are resolved.
## Phase 1 — Design & Contracts (Complete)
Outputs:
- `specs/115-baseline-operability-alerts/data-model.md`
- `specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml`
- `specs/115-baseline-operability-alerts/quickstart.md`
Design highlights:
- Baseline findings are a filtered subset of drift findings (`finding_type=drift`, `source=baseline.compare`).
- Auto-close resolves stale baseline findings only when the compare run is complete and safe.
- Baseline alert events are produced only for new/reopened baseline findings within the evaluation window.
## Constitution Re-check (Post-Design)
Result: PASS. No Graph calls added, no new authorization planes, and all `OperationRun` transitions remain service-owned.
## Phase 2 — Implementation Plan
1) Settings registry + UI
- Add `baseline.severity_mapping`, `baseline.alert_min_severity`, `baseline.auto_close_enabled` to `SettingsRegistry` with strict validation.
- Extend `WorkspaceSettings` Filament page to render and persist these settings using the existing `SettingsWriter`.
2) Canonical run types
- Add `baseline_capture` and `baseline_compare` to `OperationRunType` enum and replace ad-hoc literals where touched in this feature.
3) Baseline compare finding lifecycle
- Update `CompareBaselineToTenantJob` to:
- apply baseline severity mapping by `change_type`.
- preserve existing open finding workflow status.
- mark previously resolved findings as `reopened` and set `reopened_at`.
4) Safe auto-close
- At the end of `CompareBaselineToTenantJob`, if:
- run outcome is `succeeded`, and
- `summary_counts.processed == summary_counts.total`, and
- `summary_counts.failed == 0`, and
- `baseline.auto_close_enabled == true`
then resolve stale open baseline findings (not in “seen set”) with reason `no_longer_drifting`.
5) Alerts integration
- Extend `EvaluateAlertsJob` to produce:
- `baseline_high_drift` (baseline findings only; new/reopened only; respects `baseline.alert_min_severity`).
- `baseline_compare_failed` (baseline compare runs failed/`partially_succeeded`; dedupe by run id; cooldown via existing rules).
- Register new event types in `AlertRule` and surface them in Filament `AlertRuleResource`.
6) Tests (Pest)
- Add/extend Feature tests to cover:
- auto-close executes only under the safe gate.
- auto-close does not run on `partially_succeeded`/failed/incomplete compares.
- reopened findings become `reopened` and trigger baseline drift alerts once.
- baseline drift alerts do not trigger repeatedly for the same open finding.
- baseline compare failed alerts trigger and are dedupe/cooldown compatible.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View File

@ -0,0 +1,42 @@
# Quickstart — Spec 115 (Baseline Operability & Alert Integration)
## Prereqs
- Run the app via Sail.
## Local setup
- Start containers: `vendor/bin/sail up -d`
## How to exercise the feature (manual)
### 1) Ensure baseline compare can run
- In Filament tenant-context, start a baseline compare (existing UI surface).
- Confirm an `OperationRun` of type `baseline_compare` appears in Monitoring → Operations.
### 2) Verify auto-close safety gate
- Create/open at least one baseline compare finding (`source = baseline.compare`) by running a compare with drift.
- Remediate drift (or modify baseline/current so it no longer appears).
- Run baseline compare again.
- Expected:
- If the run outcome is `succeeded` AND `summary_counts.processed == summary_counts.total` AND `summary_counts.failed == 0`, stale findings are resolved with `resolved_reason = no_longer_drifting`.
- If the run fails/partial/incomplete, no findings are auto-resolved.
### 3) Verify baseline drift alert events
- Ensure workspace settings are configured:
- `baseline.severity_mapping` has the three required keys.
- `baseline.alert_min_severity` is set (defaults to `high`).
- Run baseline compare to create new/reopened baseline findings.
- Trigger alerts evaluation:
- `vendor/bin/sail artisan tenantpilot:dispatch-alerts --once`
- Expected:
- `baseline_high_drift` events are produced only for findings that are new/reopened within the evaluation window.
- Repeat compares do not re-alert the same open finding.
### 4) Verify baseline compare failed alerts
- Force a baseline compare to fail (e.g., by making required preconditions fail or simulating a job failure).
- Run alerts evaluation again.
- Expected: `baseline_compare_failed` event is produced, subject to the existing per-rule cooldown and quiet-hours suppression.
## Tests (Pest)
- Run focused suite for this spec once implemented:
- `vendor/bin/sail artisan test --compact --filter=BaselineOperability`
- Or run specific test files under `tests/Feature/Alerts/` and `tests/Feature/Baselines/`.

View File

@ -0,0 +1,61 @@
# Research — Baseline Operability & Alert Integration (Spec 115)
This document resolves planning unknowns and records implementation decisions.
## Decisions
### 1) Completeness counters for safe auto-close
- Decision: Treat compare “completeness counters” as `OperationRun.summary_counts.total`, `processed`, and `failed`.
- Rationale: Ops-UX contracts already standardize these keys via `OperationSummaryKeys::all()`; theyre the metrics the UI understands for determinate progress.
- Alternatives considered:
- Add new keys like `total_count` / `processed_count` / `failed_item_count` → rejected because it would require expanding `OperationSummaryKeys::all()` and updating Ops-UX guard tests without a strong benefit.
### 2) Where auto-close runs
- Decision: Perform auto-close at the end of `CompareBaselineToTenantJob` (after findings upsert), using the runs computed “seen” fingerprint set.
- Rationale: The job already has the full drift result set for the tenant+profile; its the only place that can reliably know what was evaluated.
- Alternatives considered:
- Separate queued job for auto-close → rejected (extra run coordination and more complex observability for no benefit).
### 3) Baseline finding lifecycle semantics (new vs reopened vs existing open)
- Decision: Mirror the existing drift lifecycle behavior (as implemented in `DriftFindingGenerator`):
- New fingerprint → `status = new`.
- Previously terminal fingerprint (at least `resolved`) observed again → `status = reopened` and set `reopened_at`.
- Existing open finding → do not overwrite workflow status (avoid resetting `triaged`/`in_progress`).
- Rationale: This preserves operator workflow state and enables “alert only on new/reopened” logic.
- Alternatives considered:
- Always set `status = new` on every compare (current behavior) → rejected because it can overwrite workflow state.
### 4) Alert deduplication key for baseline drift
- Decision: Set `fingerprint_key` to a stable string derived from the finding fingerprint (e.g. `finding_fingerprint:{fingerprint}`) for baseline drift events.
- Rationale: Alert delivery dedupe uses `fingerprint_key` (or `idempotency_key`) via `AlertFingerprintService`.
- Alternatives considered:
- Use `finding:{id}` → rejected because it ties dedupe to a DB surrogate rather than the domain fingerprint.
### 5) Baseline-specific event types
- Decision: Add two new alert event types and produce them in `EvaluateAlertsJob`:
- `baseline_high_drift`: for baseline compare findings (`source = baseline.compare`) that are `new`/`reopened` in the evaluation window and meet severity threshold.
- `baseline_compare_failed`: for `OperationRun.type = baseline_compare` with `outcome in {failed, partially_succeeded}` in the evaluation window.
- Rationale: The spec requires strict separation from generic drift alerts and precise triggering rules.
- Alternatives considered:
- Reuse `high_drift` / `compare_failed` → rejected because it would mix baseline and non-baseline meaning.
### 6) Cooldown behavior for baseline_compare_failed
- Decision: Reuse the existing per-rule cooldown + quiet-hours suppression implemented in `AlertDispatchService` (no baseline-specific cooldown setting).
- Rationale: Matches spec clarification and existing patterns.
### 7) Workspace settings implementation approach
- Decision: Implement baseline settings using the existing `SettingsRegistry`/`SettingsResolver`/`SettingsWriter` system with new keys under a new `baseline` domain:
- `baseline.severity_mapping` (json map with restricted keys)
- `baseline.alert_min_severity` (string)
- `baseline.auto_close_enabled` (bool)
- Rationale: This matches existing settings infrastructure and ensures consistent “effective value” semantics.
### 8) Information architecture (IA) and planes
- Decision: Keep baseline profile CRUD as workspace-owned (non-tenant scoped) and baseline compare monitoring as tenant-context only.
- Rationale: Matches SCOPE-001 and spec FR-018.
## Notes / Repo Facts Used
- Ops-UX allowed summary keys are defined in `App\Support\OpsUx\OperationSummaryKeys`.
- Drift lifecycle patterns exist in `App\Services\Drift\DriftFindingGenerator` (reopen + resolve stale).
- Alert dispatch dedupe/cooldown/quiet-hours are centralized in `App\Services\Alerts\AlertDispatchService` and `AlertFingerprintService`.
- Workspace settings are handled by `App\Support\Settings\SettingsRegistry` + `SettingsResolver` + `SettingsWriter`.

View File

@ -0,0 +1,230 @@
# Feature Specification: Baseline Operability & Alert Integration (R1.1R1.4 Extension)
**Feature Branch**: `115-baseline-operability-alerts`
**Created**: 2026-02-28
**Status**: Ready (Design complete; implementation pending)
**Input**: User description: "115 — Baseline Operability & Alert Integration (R1.1R1.4 Extension)"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace (management) + tenant (monitoring)
- **Primary Routes**:
- Workspace (admin): Baselines management (Baseline Profiles) and Workspace Settings
- Tenant-context (admin): Baseline Compare monitoring and Baseline Compare “run now” surface
- **Data Ownership**:
- Workspace-owned: Baseline profiles, baseline-to-tenant assignments, workspace settings, alert rules
- Tenant-scoped (within a workspace): Findings produced by baseline compare; operation runs for tenant compares
- **RBAC**:
- Workspace Baselines (view/manage): workspace members must be granted the workspace baselines view/manage capabilities (from the canonical capability registry)
- Workspace Settings (view/manage): workspace members must be granted the workspace settings view/manage capabilities (from the canonical capability registry)
- Alerts (view/manage): workspace members must be granted the alerts view/manage capabilities (from the canonical capability registry)
- Tenant monitoring surfaces require tenant access (tenant view) in addition to workspace membership
For canonical-view specs: not applicable (this is not a canonical-view feature).
## Clarifications
### Session 2026-02-28
- Q: What should `baseline.severity_mapping` map from? → A: Baseline drift `change_type` only (keys: `missing_policy`, `different_version`, `unexpected_policy`).
- Q: What is the canonical “fully successful compare” gate for auto-close? → A: Outcome `succeeded` AND Ops-UX canonical `OperationRun.summary_counts` gate: `summary_counts.processed == summary_counts.total` AND `summary_counts.failed == 0`.
- Q: When a previously resolved baseline finding reappears, what status should it transition to? → A: `reopened`.
- Q: For `baseline_compare_failed` alerts, what cooldown behavior applies? → A: Use the existing dispatcher cooldown (no baseline-specific cooldown setting).
- Q: Should `baseline.auto_close_enabled` exist as a kill-switch? → A: Yes; keep it with default `true`.
### Definitions
- **Baseline finding**: A drift finding produced by comparing a tenant against a baseline.
- **Fingerprint**: A stable identifier for “the same underlying issue” across runs, used for idempotency and alert deduplication.
- **Fully successful compare**: A compare run that succeeded and is complete (no failed items and all expected items processed).
- Completeness is proven via Ops-UX canonical `OperationRun.summary_counts` counters: `summary_counts.processed == summary_counts.total` and `summary_counts.failed == 0`.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Safe auto-close removes stale baseline drift (Priority: P1)
As an MSP operator, I want baseline drift findings to automatically resolve when drift is no longer present, so the findings list remains actionable and doesnt accumulate noise.
**Why this priority**: This is the main “operability” gap; without auto-close, drift remediation cannot be reliably observed, and alerting becomes noisy.
**Independent Test**: Can be fully tested by running a baseline compare that produces a “seen set” of fingerprints and verifying that previously-open baseline findings not present in the seen set are resolved only when the compare is fully successful.
**Acceptance Scenarios**:
1. **Given** an open baseline finding with `source = baseline.compare`, **When** a baseline compare completes fully successfully and that findings fingerprint is not present in the current compare result, **Then** the finding becomes `resolved` with reason `no_longer_drifting`.
2. **Given** an open baseline finding with `source = baseline.compare`, **When** a baseline compare is `partially_succeeded` or failed (or incomplete), **Then** no baseline findings are auto-resolved.
---
### User Story 2 - Baseline alerts are precise and deduplicated (Priority: P1)
As an on-call operator, I want alerts for baseline drift and baseline compare failures to trigger only when there is new actionable work (new or reopened findings) or when a compare fails, so I dont get spammed on every run.
**Why this priority**: MSP sellability depends on trust in alerts; repeated “same problem” alerts make alerting unusable.
**Independent Test**: Can be fully tested by creating findings with controlled timestamps and statuses (new/reopened/open) and verifying that only new/reopened findings generate baseline drift alert events, while repeated compares do not.
**Acceptance Scenarios**:
1. **Given** a new high-severity baseline drift finding (deduped by fingerprint) created after the alert window start, **When** alerts are evaluated, **Then** a `baseline_high_drift` event is produced exactly once.
2. **Given** a resolved baseline drift finding that later reappears and transitions to `reopened`, **When** alerts are evaluated, **Then** a `baseline_high_drift` event is produced again exactly once.
3. **Given** a baseline compare run that completes with outcome failed or `partially_succeeded`, **When** alerts are evaluated, **Then** a `baseline_compare_failed` event is produced (deduped by run identity + cooldown).
---
### User Story 3 - Workspace-controlled severity mapping and alert threshold (Priority: P2)
As a workspace admin, I want to configure how baseline drift categories map to severity, and optionally the minimum severity that triggers baseline drift alerts, so the system matches the MSPs operational standards.
**Why this priority**: Settings are required for enterprise adoption; hardcoded severity and alert thresholds dont fit different environments.
**Independent Test**: Can be fully tested by setting workspace overrides and verifying that newly created baseline findings inherit the configured severity and that alert generation respects the configured minimum severity.
**Acceptance Scenarios**:
1. **Given** a workspace-level baseline severity mapping override, **When** a new baseline drift finding is created, **Then** it uses the mapped severity (and rejects invalid severity values).
2. **Given** a workspace-level baseline alert minimum severity override, **When** baseline findings are evaluated for alerts, **Then** only findings meeting or exceeding that threshold emit `baseline_high_drift` events.
### Edge Cases
- Compare completes but is not “fully successful” (e.g., `summary_counts.failed > 0`, incomplete processing where `summary_counts.processed != summary_counts.total`, or compare preconditions prevent the run from being created): auto-close MUST NOT occur.
- Compare does not evaluate all assigned items (e.g., missing baseline snapshot or assignment changes mid-run): auto-close MUST NOT resolve findings for items not evaluated.
- A baseline finding was resolved previously and reappears later: it must transition to an actionable open state (e.g., `reopened`) and be eligible for alerting once.
- Workspace settings payload is malformed (unknown drift categories or invalid severity values): save MUST be rejected and effective values MUST remain unchanged.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
### Functional Requirements
- **FR-001 (Finding source contract)**: Findings created by baseline compare MUST be identifiable as baseline compare findings via a stable source identifier (`source = baseline.compare`).
- **FR-002 (Fully successful guardrail)**: Auto-close MUST run only after a fully successful baseline compare run. “Fully successful” means all of the following:
- The compare run outcome is `succeeded`.
- The compare run emits Ops-UX canonical completeness counters in `OperationRun.summary_counts`, and they indicate:
- `summary_counts.failed == 0`
- `summary_counts.processed == summary_counts.total`
- Compare preconditions (e.g., missing active baseline snapshot, missing assignment) are enforced before enqueue and MUST prevent a compare run from being created; therefore auto-close cannot run when preconditions fail.
- **Implementation note (required invariant)**: precondition failures are returned as stable reason codes and MUST result in **no `OperationRun` being created**.
- **FR-003 (Safe auto-close behavior)**: After a fully successful compare, the system MUST resolve open baseline compare findings that are not present in the current runs seen set.
- **FR-004 (No partial resolution)**: The system MUST NOT resolve findings for any items that were not evaluated in the run.
- **FR-005 (Resolution reason)**: Auto-resolved findings MUST record resolution reason `no_longer_drifting`.
- **FR-006 (Reopen semantics)**: If a previously resolved baseline compare finding reappears in a later compare, it MUST transition to an actionable open state (e.g., `reopened`) and be treated as “new actionable work” for alerting.
- The required open state for reappearance is `reopened`.
- **FR-007 (Alert event type: baseline drift)**: The system MUST support an alert event type `baseline_high_drift` for baseline compare findings.
- **FR-008 (Alert producer: baseline drift)**: Baseline drift alert events MUST be produced only for baseline compare findings that are actionable (open states) AND are either newly created or newly reopened within the evaluation window.
- **FR-009 (Baseline drift deduplication)**: Baseline drift alert events MUST be deduplicated by a stable key derived from the finding fingerprint. The same open finding MUST NOT emit repeated events on subsequent compares.
- **FR-010 (Alert event type: compare failed)**: The system MUST support an alert event type `baseline_compare_failed`.
- **FR-011 (Alert producer: compare failed)**: A `baseline_compare_failed` event MUST be produced when a baseline compare run completes with outcome failed or `partially_succeeded`.
- **FR-012 (Compare-failed dedup + cooldown)**: Compare-failed events MUST be deduplicated per run identity and MUST respect existing cooldown/quiet-hours behavior.
- This feature MUST NOT introduce a baseline-specific cooldown interval; it reuses the existing dispatcher cooldown behavior.
- **FR-013 (Canonical run types)**: Baseline capture and baseline compare MUST use centrally defined canonical run types (`baseline_capture`, `baseline_compare`) and MUST NOT rely on ad-hoc string literals.
- **FR-014 (Workspace settings: severity mapping)**: The system MUST support a workspace setting `baseline.severity_mapping` that maps baseline drift categories to severity.
- **FR-015 (Workspace settings: validation)**: The severity mapping MUST:
- accept only the baseline drift `change_type` keys `missing_policy`, `different_version`, and `unexpected_policy` (no other keys),
- reject invalid severity values,
- and expose “effective value” behavior (system defaults + workspace overrides).
- **FR-016 (Workspace settings: alert threshold)**: The system MUST support a workspace setting `baseline.alert_min_severity` with allowed values low/medium/high/critical and default high.
- Severity threshold comparison MUST use the canonical severity ordering: `low < medium < high < critical` (inclusive).
- **FR-017 (Workspace settings: auto-close toggle)**: The system MUST support a workspace setting `baseline.auto_close_enabled` defaulting to true.
- When set to `false`, auto-close MUST be skipped even if the compare is fully successful.
- **FR-018 (Information architecture / ownership)**: Baseline Profile CRUD MUST remain workspace-owned. It MUST NOT appear tenant-scoped, must not show tenant scope banners, and must not be reachable from tenant-only navigation.
#### Assumptions & Dependencies
- Baseline compare already produces stable finding fingerprints and a per-run Ops-UX `summary_counts` payload that can express completeness (`processed` vs `total`) and failures (`failed`).
- Findings support lifecycle transitions including resolve with a reason and reopen semantics for a recurring fingerprint.
- Alert dispatch already supports deduplication, cooldown, and quiet hours; this feature reuses that behavior for new baseline-specific event types.
#### Constitution Alignment Notes (non-functional but mandatory)
- This feature adds no new Microsoft Graph calls.
- Baseline compare and alert evaluation are long-running operations; any new auto-close and alert integration MUST preserve tenant isolation and run observability.
- **Ops-UX (3-surface feedback)**: baseline compare/capture and alerts evaluation must continue to provide:
- an intent-only toast on start,
- progress surfaces (Operations pages),
- and terminal DB notifications where applicable.
- **Operation run ownership**: Operation status/outcome transitions are owned by the operations subsystem and must not be mutated directly by UI code.
- **Summary counts contract**: Any summary counters produced/updated by this feature MUST use the canonical summary key registry and numeric-only values.
- **Scheduled/system runs**: Runs initiated without a human initiator MUST not produce terminal DB notifications; monitoring remains via Operations/Alerts.
- **RBAC-UX**: Authorization planes involved:
- Workspace management plane (admin, workspace-owned baselines + workspace settings)
- Tenant-context plane (baseline compare monitoring)
Cross-plane access MUST be deny-as-not-found (404).
- Non-member or not entitled to the workspace scope or tenant scope → 404 (deny-as-not-found)
- Member but missing capability for the surface → 403
- **BADGE-001**: Any new baseline severity mapping must remain centralized (single mapping source) and covered by tests.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Resource | Admin → Governance → Baselines (workspace management) | Create baseline profile (capability-gated) | View action | Edit (capability-gated), Delete/Archive (if present) | None | Create baseline profile | Capture / Compare shortcuts (if present), Edit | Save + Cancel | Yes | Workspace-owned; MUST NOT show tenant scope banner; must not appear in tenant nav. |
| Page | Admin → Settings → Workspace settings | Save (capability-gated) | N/A | N/A | N/A | N/A | N/A | Save + Cancel (or equivalent) | Yes | Adds baseline settings fields; validation must reject malformed mapping. |
| Page | Admin → Tenant context → Governance → Baseline Compare | Compare now (capability-gated) | Link to findings / operation run details | N/A | N/A | N/A | N/A | N/A | Yes | Tenant-context monitoring surface; must not expose workspace management actions. |
### Key Entities *(include if feature involves data)*
- **Baseline Compare Finding**: A finding produced by a baseline compare run, identified by `source = baseline.compare` and a stable fingerprint.
- **Baseline Compare Run**: A run that evaluates tenant configuration against a baseline profile and produces a compare summary that can indicate completeness.
- **Alert Event**: A deduplicated, rule-dispatchable representation of actionable baseline drift or baseline compare failure.
- **Workspace Baseline Settings**: Workspace-specific overrides for severity mapping, alert threshold, and auto-close enablement.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Noise reduction)**: In a controlled test scenario where drift disappears, 100% of baseline drift findings created by baseline compare auto-resolve after the first fully successful compare.
- **SC-002 (Safety)**: In scenarios where compare is failed/`partially_succeeded`/incomplete, 0 baseline findings are auto-resolved.
- **SC-003 (Alert dedupe)**: The same open baseline drift finding does not generate more than 1 `baseline_high_drift` alert event per open/reopen cycle.
- **SC-004 (Timeliness)**: Baseline compare failures generate a `baseline_compare_failed` alert event within the next alert evaluation cycle.
- **SC-005 (Configurability)**: A workspace admin can change baseline severity mapping and minimum alert severity in under 2 minutes, and newly generated findings reflect the change.

View File

@ -0,0 +1,186 @@
---
description: "Task list for Spec 115 implementation"
---
# Tasks: Baseline Operability & Alert Integration (Spec 115)
**Input**: Design documents from `/specs/115-baseline-operability-alerts/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior (Jobs/Services/Settings/UI).
**Operations (Ops-UX)**: This feature reuses `OperationRun`. Tasks must preserve the 3-surface feedback contract, keep all status/outcome transitions service-owned (via `OperationRunService`), and keep `summary_counts` numeric-only with keys from `OperationSummaryKeys::all()`.
**RBAC**: Any new/changed UI mutations or operation-start surfaces must use the capability registry (no raw strings) and preserve 404 vs 403 semantics (non-member/tenant-mismatch → 404; member missing capability → 403).
**Filament**: Any destructive-like actions must use `->requiresConfirmation()` and remain workspace vs tenant-plane correct (FR-018).
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
## Phase 1: Setup (Confirm Inputs)
- [X] T001 Confirm scope + priorities in specs/115-baseline-operability-alerts/spec.md
- [X] T002 Confirm implementation sequencing and touched paths in specs/115-baseline-operability-alerts/plan.md
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared building blocks required by US1/US2/US3.
- [X] T003 Add baseline settings definitions + strict validation in app/Support/Settings/SettingsRegistry.php
- [X] T004 [P] Add canonical run types for baseline operations in app/Support/OperationRunType.php
- [X] T005 Update baseline run creation to use canonical run types in app/Services/Baselines/BaselineCompareService.php and app/Services/Baselines/BaselineCaptureService.php
- [X] T006 [P] Add baseline compare precondition regression coverage ensuring unmet preconditions return `ok=false` and no `OperationRun` is created in tests/Feature/Baselines/BaselineComparePreconditionsTest.php
**Checkpoint**: Baseline settings keys exist with correct defaults; baseline run types are canonical and referenced from code.
---
## Phase 3: User Story 1 — Safe auto-close removes stale baseline drift (Priority: P1) 🎯
**Goal**: Resolve stale baseline drift findings only after a fully successful, complete compare, and never on `partially_succeeded`/failed/incomplete runs.
**Independent Test**: A compare that produces an initial seen fingerprint set, followed by a fully successful compare where those fingerprints are absent, resolves only the stale baseline findings.
### Tests (write first)
- [X] T007 [P] [US1] Add auto-close safety gate coverage in tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php
- [X] T008 [P] [US1] Extend lifecycle coverage (new vs reopened vs preserve open status) in tests/Feature/Baselines/BaselineCompareFindingsTest.php
### Implementation
- [X] T009 [US1] Add safe auto-close implementation in app/Services/Baselines/BaselineAutoCloseService.php
- [X] T010 [US1] Preserve finding workflow state and implement reopen semantics in app/Jobs/CompareBaselineToTenantJob.php
- [X] T011 [US1] Apply baseline severity mapping by change_type when upserting findings in app/Jobs/CompareBaselineToTenantJob.php
- [X] T012 [US1] Wire auto-close into compare completion using the safe gate (outcome+safety+completeness+kill-switch) in app/Jobs/CompareBaselineToTenantJob.php and app/Services/Baselines/BaselineAutoCloseService.php
### Verification
- [X] T013 [US1] Run focused baseline tests: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php`
**Checkpoint**: Auto-close resolves only when safe; `partially_succeeded`/failed/incomplete compares never resolve baseline findings.
---
## Phase 4: User Story 2 — Baseline alerts are precise and deduplicated (Priority: P1)
**Goal**: Emit baseline alerts only for new/reopened baseline findings (within window), and for compare failures, with correct dedupe/cooldown.
**Independent Test**: Create baseline findings with controlled timestamps and statuses; evaluate alerts and ensure only new/reopened findings emit `baseline_high_drift` and only failed/`partially_succeeded` compares emit `baseline_compare_failed`.
### Tests (write first)
- [X] T014 [P] [US2] Add baseline drift alert event coverage in tests/Feature/Alerts/BaselineHighDriftAlertTest.php
- [X] T015 [P] [US2] Add baseline compare failed alert event coverage in tests/Feature/Alerts/BaselineCompareFailedAlertTest.php
### Implementation
- [X] T016 [US2] Register baseline alert event constants in app/Models/AlertRule.php
- [X] T017 [US2] Add baseline event types to the rule UI options/labels in app/Filament/Resources/AlertRuleResource.php
- [X] T018 [US2] Produce baseline_high_drift events (baseline-only, new/reopened-only, severity threshold) in app/Jobs/Alerts/EvaluateAlertsJob.php
- [X] T019 [US2] Produce baseline_compare_failed events for baseline compare runs with outcome failed/partially_succeeded in app/Jobs/Alerts/EvaluateAlertsJob.php
- [X] T020 [US2] Ensure baseline drift event dedupe uses finding fingerprint (not numeric ID) in app/Jobs/Alerts/EvaluateAlertsJob.php
### Verification
- [X] T021 [US2] Run focused alert tests: `vendor/bin/sail artisan test --compact tests/Feature/Alerts/BaselineHighDriftAlertTest.php`
- [X] T022 [US2] Run focused alert tests: `vendor/bin/sail artisan test --compact tests/Feature/Alerts/BaselineCompareFailedAlertTest.php`
**Checkpoint**: Alerts fire only on new/reopened baseline work; repeated compares do not re-alert the same open finding.
---
## Phase 5: User Story 3 — Workspace-controlled severity mapping and alert threshold (Priority: P2)
**Goal**: Workspace admins can configure baseline severity mapping, alert threshold, and auto-close enablement via Workspace Settings.
**Independent Test**: Saving workspace overrides updates effective values and affects newly created baseline findings and baseline alert eligibility.
### Tests (write first)
- [X] T023 [P] [US3] Extend manage flow assertions for baseline settings in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php
- [X] T024 [P] [US3] Extend read-only flow assertions for baseline settings in tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php
### Implementation
- [X] T025 [US3] Add baseline settings fields to the settings field map in app/Filament/Pages/Settings/WorkspaceSettings.php
- [X] T026 [US3] Render a "Baseline settings" section (mapping + minimum severity + auto-close toggle) in app/Filament/Pages/Settings/WorkspaceSettings.php
- [X] T027 [US3] Ensure save/reset uses SettingsWriter validation and records audit logs for baseline settings in app/Filament/Pages/Settings/WorkspaceSettings.php
### Verification
- [X] T028 [US3] Run focused settings tests: `vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`
**Checkpoint**: Workspace overrides are validated strictly and reflected as effective settings.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T029 [P] Validate implementation matches the baseline alert event contract in specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml and app/Jobs/Alerts/EvaluateAlertsJob.php
- [X] T030 Validate the manual workflow remains accurate in specs/115-baseline-operability-alerts/quickstart.md
- [X] T031 Run focused suites: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` and `vendor/bin/sail artisan test --compact tests/Feature/Alerts/`
- [X] T032 [P] Add FR-018 regression coverage ensuring Baseline Profile CRUD remains workspace-owned (not reachable via tenant-context URLs and not present in tenant navigation) in tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php
---
## Dependencies & Execution Order
### Dependency Graph
```mermaid
graph TD
Setup[Phase 1: Setup] --> Foundation[Phase 2: Foundational]
Foundation --> US1[US1: Safe auto-close]
Foundation --> US2[US2: Baseline alerts]
Foundation --> US3[US3: Workspace baseline settings]
US1 --> Polish[Phase 6: Polish]
US2 --> Polish
US3 --> Polish
```
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies.
- **Foundational (Phase 2)**: Blocks all user stories.
- **US1 + US2 (Phase 34)**: Can proceed in parallel after Phase 2.
- **US3 (Phase 5)**: Can proceed after Phase 2; does not block US1/US2 (defaults exist), but is required for workspace customization.
- **Polish (Phase 6)**: After desired user stories complete.
### User Story Dependencies
- **US1 (P1)** depends on: baseline settings defaults (T003) and compare job wiring.
- **US2 (P1)** depends on: baseline settings defaults (T003) and baseline compare run type (T004T005).
- **US3 (P2)** depends on: baseline settings definitions (T003) and Workspace Settings page wiring (T025T027).
---
## Parallel Execution Examples
### US1
- Run in parallel:
- T007 (auto-close tests) and T008 (lifecycle tests)
- T009 (auto-close service) and T010/T011 (compare job lifecycle + severity mapping)
### US2
- Run in parallel:
- T014 (baseline drift alert tests) and T015 (compare failed alert tests)
- T016/T017 (rule model/UI constants) while T018T020 (job event producers) are implemented
### US3
- Run in parallel:
- T023 (manage tests) and T024 (view-only tests)
- T025 (field map) and T026 (form section rendering)
---
## Implementation Strategy
- **MVP**: Complete Phase 2 + Phase 3 (US1). This delivers operability via safe auto-close with defaults.
- **Next**: Phase 4 (US2) to make alerting low-noise and actionable.
- **Then**: Phase 5 (US3) to let workspaces tailor severity mapping and alert thresholds.

View File

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
use App\Jobs\Alerts\EvaluateAlertsJob;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Alerts\AlertDispatchService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
/**
* @return array{0: AlertRule, 1: AlertDestination}
*/
function createBaselineCompareFailedRuleWithDestination(int $workspaceId, int $cooldownSeconds = 0): array
{
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'event_type' => 'baseline_compare_failed',
'minimum_severity' => 'low',
'is_enabled' => true,
'cooldown_seconds' => $cooldownSeconds,
]);
$rule->destinations()->attach($destination->getKey(), [
'workspace_id' => $workspaceId,
]);
return [$rule, $destination];
}
/**
* @return array<int, array<string, mixed>>
*/
function invokeBaselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$job = new EvaluateAlertsJob($workspaceId);
$reflection = new ReflectionMethod($job, 'baselineCompareFailedEvents');
/** @var array<int, array<string, mixed>> $events */
$events = $reflection->invoke($job, $workspaceId, $windowStart);
return $events;
}
it('produces baseline compare failed events for failed and partially succeeded baseline compare runs', function (): void {
$now = CarbonImmutable::parse('2026-02-28T12:00:00Z');
CarbonImmutable::setTestNow($now);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
$windowStart = $now->subHour();
$failedRun = OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => $now->subMinutes(10),
'failure_summary' => [
['message' => 'The baseline compare failed.'],
],
]);
$partialRun = OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => $now->subMinutes(5),
'failure_summary' => [
['message' => 'The baseline compare partially succeeded.'],
],
]);
OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => $now->subMinutes(5),
]);
OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'drift_generate_findings',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => $now->subMinutes(5),
]);
$events = invokeBaselineCompareFailedEvents($workspaceId, $windowStart);
expect($events)->toHaveCount(2);
$eventsByRunId = collect($events)->keyBy(static fn (array $event): int => (int) $event['metadata']['operation_run_id']);
expect($eventsByRunId[$failedRun->getKey()])
->toMatchArray([
'event_type' => 'baseline_compare_failed',
'tenant_id' => (int) $tenant->getKey(),
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.$failedRun->getKey(),
'metadata' => [
'operation_run_id' => (int) $failedRun->getKey(),
],
]);
expect($eventsByRunId[$partialRun->getKey()])
->toMatchArray([
'event_type' => 'baseline_compare_failed',
'tenant_id' => (int) $tenant->getKey(),
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.$partialRun->getKey(),
'metadata' => [
'operation_run_id' => (int) $partialRun->getKey(),
],
]);
});
it('keeps baseline compare failed events compatible with dispatcher cooldown dedupe', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
[$rule, $destination] = createBaselineCompareFailedRuleWithDestination($workspaceId, cooldownSeconds: 3600);
$run = OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now(),
]);
$event = [
'event_type' => 'baseline_compare_failed',
'tenant_id' => (int) $tenant->getKey(),
'severity' => 'high',
'fingerprint_key' => 'operation_run:'.$run->getKey(),
'title' => 'Baseline compare failed',
'body' => 'The baseline compare failed.',
'metadata' => [
'operation_run_id' => (int) $run->getKey(),
],
];
$workspace = Workspace::query()->findOrFail($workspaceId);
$dispatchService = app(AlertDispatchService::class);
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
$deliveries = AlertDelivery::query()
->where('workspace_id', $workspaceId)
->where('alert_rule_id', (int) $rule->getKey())
->where('alert_destination_id', (int) $destination->getKey())
->where('event_type', 'baseline_compare_failed')
->orderBy('id')
->get();
expect($deliveries)->toHaveCount(2);
expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED);
expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
});

View File

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
use App\Jobs\Alerts\EvaluateAlertsJob;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Alerts\AlertDispatchService;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
/**
* @return array{0: AlertRule, 1: AlertDestination}
*/
function createBaselineHighDriftRuleWithDestination(int $workspaceId, int $cooldownSeconds = 0): array
{
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'event_type' => 'baseline_high_drift',
'minimum_severity' => 'low',
'is_enabled' => true,
'cooldown_seconds' => $cooldownSeconds,
]);
$rule->destinations()->attach($destination->getKey(), [
'workspace_id' => $workspaceId,
]);
return [$rule, $destination];
}
/**
* @return array<int, array<string, mixed>>
*/
function invokeBaselineHighDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array
{
$job = new EvaluateAlertsJob($workspaceId);
$reflection = new ReflectionMethod($job, 'baselineHighDriftEvents');
/** @var array<int, array<string, mixed>> $events */
$events = $reflection->invoke($job, $workspaceId, $windowStart);
return $events;
}
it('produces baseline drift events only for new and reopened baseline findings that meet the workspace threshold', function (): void {
$now = CarbonImmutable::parse('2026-02-28T12:00:00Z');
CarbonImmutable::setTestNow($now);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
$windowStart = $now->subHour();
WorkspaceSetting::query()->create([
'workspace_id' => $workspaceId,
'domain' => 'baseline',
'key' => 'alert_min_severity',
'value' => Finding::SEVERITY_HIGH,
'updated_by_user_id' => (int) $user->getKey(),
]);
$newFinding = Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'source' => 'baseline.compare',
'fingerprint' => 'baseline-fingerprint-new',
'severity' => Finding::SEVERITY_CRITICAL,
'status' => Finding::STATUS_NEW,
'created_at' => $now->subMinutes(10),
'evidence_jsonb' => ['change_type' => 'missing_policy'],
]);
$reopenedFinding = Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'source' => 'baseline.compare',
'fingerprint' => 'baseline-fingerprint-reopened',
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_REOPENED,
'reopened_at' => $now->subMinutes(5),
'evidence_jsonb' => ['change_type' => 'different_version'],
]);
Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'source' => 'baseline.compare',
'fingerprint' => 'baseline-too-old',
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
'created_at' => $now->subDays(1),
'evidence_jsonb' => ['change_type' => 'missing_policy'],
]);
Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'source' => 'baseline.compare',
'fingerprint' => 'baseline-below-threshold',
'severity' => Finding::SEVERITY_MEDIUM,
'status' => Finding::STATUS_NEW,
'created_at' => $now->subMinutes(5),
'evidence_jsonb' => ['change_type' => 'unexpected_policy'],
]);
Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'source' => 'permission_check',
'fingerprint' => 'not-baseline',
'severity' => Finding::SEVERITY_CRITICAL,
'status' => Finding::STATUS_NEW,
'created_at' => $now->subMinutes(5),
'evidence_jsonb' => ['change_type' => 'missing_policy'],
]);
$events = invokeBaselineHighDriftEvents($workspaceId, $windowStart);
expect($events)->toHaveCount(2);
$eventsByFindingId = collect($events)->keyBy(static fn (array $event): int => (int) $event['metadata']['finding_id']);
expect($eventsByFindingId[$newFinding->getKey()])
->toMatchArray([
'event_type' => 'baseline_high_drift',
'tenant_id' => (int) $tenant->getKey(),
'severity' => Finding::SEVERITY_CRITICAL,
'fingerprint_key' => 'finding_fingerprint:baseline-fingerprint-new',
'metadata' => [
'finding_id' => (int) $newFinding->getKey(),
'finding_fingerprint' => 'baseline-fingerprint-new',
'change_type' => 'missing_policy',
],
]);
expect($eventsByFindingId[$reopenedFinding->getKey()])
->toMatchArray([
'event_type' => 'baseline_high_drift',
'tenant_id' => (int) $tenant->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'fingerprint_key' => 'finding_fingerprint:baseline-fingerprint-reopened',
'metadata' => [
'finding_id' => (int) $reopenedFinding->getKey(),
'finding_fingerprint' => 'baseline-fingerprint-reopened',
'change_type' => 'different_version',
],
]);
});
it('uses the finding fingerprint for dedupe and remains cooldown compatible', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
[$rule, $destination] = createBaselineHighDriftRuleWithDestination($workspaceId, cooldownSeconds: 3600);
$finding = Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'source' => 'baseline.compare',
'fingerprint' => 'stable-fingerprint-key',
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
'evidence_jsonb' => ['change_type' => 'missing_policy'],
]);
$event = [
'event_type' => 'baseline_high_drift',
'tenant_id' => (int) $tenant->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'fingerprint_key' => 'finding_fingerprint:stable-fingerprint-key',
'title' => 'Baseline drift detected',
'body' => 'A baseline finding was created.',
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'finding_fingerprint' => 'stable-fingerprint-key',
'change_type' => 'missing_policy',
],
];
$workspace = Workspace::query()->findOrFail($workspaceId);
$dispatchService = app(AlertDispatchService::class);
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
$deliveries = AlertDelivery::query()
->where('workspace_id', $workspaceId)
->where('alert_rule_id', (int) $rule->getKey())
->where('alert_destination_id', (int) $destination->getKey())
->where('event_type', 'baseline_high_drift')
->orderBy('id')
->get();
expect($deliveries)->toHaveCount(2);
expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED);
expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
});

View File

@ -4,14 +4,20 @@
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\WorkspaceSetting;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use function Pest\Laravel\mock;
// --- T041: Compare idempotent finding fingerprint tests ---
@ -69,7 +75,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -91,7 +97,7 @@
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$scopeKey = 'baseline_profile:' . $profile->getKey();
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
@ -102,12 +108,203 @@
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
expect($findings->count())->toBe(3);
// Lifecycle v2 fields must be initialized for new findings.
expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count());
expect($findings->pluck('last_seen_at')->filter()->count())->toBe($findings->count());
expect($findings->pluck('times_seen')->every(fn ($value) => (int) $value === 1))->toBeTrue();
$severities = $findings->pluck('severity')->sort()->values()->all();
expect($severities)->toContain(Finding::SEVERITY_HIGH);
expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
expect($severities)->toContain(Finding::SEVERITY_LOW);
});
it('does not fail compare when baseline severity mapping setting is missing', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-a-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'content-a'),
'meta_jsonb' => ['display_name' => 'Policy A'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-b-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'content-b'),
'meta_jsonb' => ['display_name' => 'Policy B'],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-a-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true],
'display_name' => 'Policy A modified',
]);
$opService = app(OperationRunService::class);
$run = $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']],
],
initiator: $user,
);
$settingsResolver = mock(SettingsResolver::class);
$settingsResolver
->shouldReceive('resolveValue')
->andThrow(new InvalidArgumentException('Unknown setting key: baseline.severity_mapping'));
$baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
settingsResolver: $settingsResolver,
baselineAutoCloseService: $baselineAutoCloseService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->get();
// policyB missing (high), policyA different (medium) = 2 findings.
expect($findings->count())->toBe(2);
expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_HIGH);
expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_MEDIUM);
});
it('treats inventory items not seen in latest inventory sync as missing', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['settingsCatalogPolicy']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'settings-catalog-policy-uuid',
'policy_type' => 'settingsCatalogPolicy',
'baseline_hash' => hash('sha256', 'content-a'),
'meta_jsonb' => ['display_name' => 'Settings Catalog A'],
]);
$olderInventoryRun = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subMinutes(5),
'context' => [
'policy_types' => ['settingsCatalogPolicy'],
'selection_hash' => 'older',
],
]);
// Inventory item exists, but it was NOT observed in the latest sync run.
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'settings-catalog-policy-uuid',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog A',
'meta_jsonb' => ['etag' => 'abc'],
'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(),
'last_seen_at' => now()->subMinutes(5),
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'policy_types' => ['settingsCatalogPolicy'],
'selection_hash' => 'latest',
],
]);
$opService = app(OperationRunService::class);
$run = $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' => ['settingsCatalogPolicy']],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->get();
expect($findings->count())->toBe(1);
expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy');
});
it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -138,7 +335,7 @@
// First run
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -156,7 +353,7 @@
$opService,
);
$scopeKey = 'baseline_profile:' . $profile->getKey();
$scopeKey = 'baseline_profile:'.$profile->getKey();
$countAfterFirst = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
@ -171,7 +368,7 @@
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -241,7 +438,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -274,6 +471,96 @@
expect($findings)->toBe(0);
});
it('does not create missing_policy findings for baseline snapshot items outside effective scope', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$metaContent = ['policy_key' => 'value123'];
$driftHasher = app(DriftHasher::class);
$contentHash = $driftHasher->hashNormalized($metaContent);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'matching-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $contentHash,
'meta_jsonb' => ['display_name' => 'Matching Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'foundation-uuid',
'policy_type' => 'notificationMessageTemplate',
'baseline_hash' => hash('sha256', 'foundation-content'),
'meta_jsonb' => ['display_name' => 'Foundation Template'],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'matching-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => $metaContent,
'display_name' => 'Matching Policy',
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'foundation-uuid',
'policy_type' => 'notificationMessageTemplate',
'meta_jsonb' => ['some' => 'value'],
'display_name' => 'Foundation Template',
]);
$opService = app(OperationRunService::class);
$run = $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']],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run = $run->fresh();
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? -1))->toBe(0);
$scopeKey = 'baseline_profile:'.$profile->getKey();
expect(
Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->count()
)->toBe(0);
});
// --- T042: Summary counts severity breakdown tests ---
it('writes severity breakdown in summary_counts', function () {
@ -330,7 +617,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -384,7 +671,7 @@
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
@ -412,3 +699,260 @@
expect((int) $result['findings_total'])->toBe(1);
expect((int) $result['findings_upserted'])->toBe(1);
});
it('reopens a previously resolved baseline finding when the same drift reappears', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-reappears',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-content'),
'meta_jsonb' => ['display_name' => 'Policy Reappears'],
]);
$operationRuns = app(OperationRunService::class);
$firstRun = $operationRuns->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']],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($firstRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->sole();
$finding->forceFill([
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now()->subMinute(),
'resolved_reason' => 'manually_resolved',
])->save();
$firstRun->update(['completed_at' => now()->subMinute()]);
$secondRun = $operationRuns->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']],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($secondRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$finding->refresh();
expect($finding->status)->toBe(Finding::STATUS_REOPENED);
expect($finding->resolved_at)->toBeNull();
expect($finding->resolved_reason)->toBeNull();
expect($finding->reopened_at)->not->toBeNull();
});
it('preserves an existing open workflow status when the same baseline drift is seen again', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'triaged-policy',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-content'),
'meta_jsonb' => ['display_name' => 'Triaged Policy'],
]);
$operationRuns = app(OperationRunService::class);
$firstRun = $operationRuns->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']],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($firstRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->sole();
$finding->forceFill([
'status' => Finding::STATUS_TRIAGED,
'triaged_at' => now()->subMinute(),
])->save();
$firstRun->update(['completed_at' => now()->subMinute()]);
$secondRun = $operationRuns->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']],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($secondRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$finding->refresh();
expect($finding->status)->toBe(Finding::STATUS_TRIAGED);
expect($finding->current_operation_run_id)->toBe((int) $secondRun->getKey());
});
it('applies the workspace baseline severity mapping by change type', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
WorkspaceSetting::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'domain' => 'baseline',
'key' => 'severity_mapping',
'value' => [
'missing_policy' => Finding::SEVERITY_CRITICAL,
'different_version' => Finding::SEVERITY_LOW,
'unexpected_policy' => Finding::SEVERITY_MEDIUM,
],
'updated_by_user_id' => (int) $user->getKey(),
]);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'missing-policy',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-a'),
'meta_jsonb' => ['display_name' => 'Missing Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'different-policy',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-b'),
'meta_jsonb' => ['display_name' => 'Different Policy'],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'different-policy',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true],
'display_name' => 'Different Policy',
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'unexpected-policy',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['unexpected' => true],
'display_name' => 'Unexpected Policy',
]);
$operationRuns = app(OperationRunService::class);
$run = $operationRuns->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']],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$findings = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->get()
->keyBy(fn (Finding $finding): string => (string) data_get($finding->evidence_jsonb, 'change_type'));
expect($findings['missing_policy']->severity)->toBe(Finding::SEVERITY_CRITICAL);
expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW);
expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM);
});

View File

@ -7,6 +7,7 @@
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
// --- T040: Compare precondition 422 tests ---
@ -23,7 +24,7 @@
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
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('rejects compare when assigned profile is in draft status', function () {
@ -49,7 +50,7 @@
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
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('rejects compare when assigned profile is archived [EC-001]', function () {
@ -74,6 +75,7 @@
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('rejects compare when profile has no active snapshot', function () {
@ -99,6 +101,7 @@
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('enqueues compare successfully when all preconditions are met', function () {
@ -132,7 +135,7 @@
/** @var OperationRun $run */
$run = $result['run'];
expect($run->type)->toBe('baseline_compare');
expect($run->type)->toBe(OperationRunType::BaselineCompare->value);
expect($run->status)->toBe('queued');
$context = is_array($run->context) ? $run->context : [];
@ -174,5 +177,5 @@
expect($result1['ok'])->toBeTrue();
expect($result2['ok'])->toBeTrue();
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(1);
});

View File

@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareStats;
it('returns no_tenant state when tenant is null', function (): void {
$stats = BaselineCompareStats::forTenant(null);
expect($stats->state)->toBe('no_tenant')
->and($stats->message)->toContain('No tenant');
});
it('returns no_assignment state when tenant has no baseline assignment', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('no_assignment')
->and($stats->profileName)->toBeNull();
});
it('returns no_snapshot state when profile has no active snapshot', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'active_snapshot_id' => null,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('no_snapshot')
->and($stats->profileName)->toBe($profile->name);
});
it('returns comparing state when a run is queued', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'queued',
'outcome' => 'pending',
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('comparing')
->and($stats->operationRunId)->not->toBeNull();
});
it('returns failed state when the latest run has failed outcome', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'failed',
'failure_summary' => ['message' => 'Graph API timeout'],
'completed_at' => now()->subHour(),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('failed')
->and($stats->failureReason)->toBe('Graph API timeout')
->and($stats->operationRunId)->not->toBeNull()
->and($stats->lastComparedHuman)->not->toBeNull()
->and($stats->lastComparedIso)->not->toBeNull();
});
it('returns ready state with grouped severity counts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$scopeKey = 'baseline_profile:'.$profile->getKey();
Finding::factory()->count(2)->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_MEDIUM,
'status' => Finding::STATUS_NEW,
]);
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_LOW,
'status' => Finding::STATUS_NEW,
]);
// Terminal finding should not be counted in "open" drift totals.
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_RESOLVED,
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'succeeded',
'completed_at' => now()->subHours(2),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('ready')
->and($stats->findingsCount)->toBe(4)
->and($stats->severityCounts)->toBe([
'high' => 2,
'medium' => 1,
'low' => 1,
])
->and($stats->lastComparedHuman)->not->toBeNull()
->and($stats->lastComparedIso)->toContain('T');
});
it('returns idle state when profile is ready but no run exists yet', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('idle')
->and($stats->profileName)->toBe($profile->name)
->and($stats->snapshotId)->toBe((int) $snapshot->getKey());
});
it('forWidget returns grouped severity counts for new findings only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$scopeKey = 'baseline_profile:'.$profile->getKey();
// New finding (should be counted)
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
// Resolved finding (should NOT be counted)
Finding::factory()->create([
'workspace_id' => $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_RESOLVED,
]);
$stats = BaselineCompareStats::forWidget($tenant);
expect($stats->findingsCount)->toBe(1)
->and($stats->severityCounts['high'])->toBe(1);
});

View File

@ -0,0 +1,188 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceSetting;
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
/**
* @return array{0: User, 1: Tenant, 2: BaselineProfile, 3: BaselineSnapshot}
*/
function createBaselineOperabilityFixture(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
return [$user, $tenant, $profile, $snapshot];
}
function runBaselineCompareForSnapshot(
User $user,
Tenant $tenant,
BaselineProfile $profile,
BaselineSnapshot $snapshot,
): OperationRun {
$operationRuns = app(OperationRunService::class);
$run = $operationRuns->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']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
return $run->fresh();
}
it('resolves stale baseline findings after a fully successful compare', function (): void {
[$user, $tenant, $profile, $firstSnapshot] = createBaselineOperabilityFixture();
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $firstSnapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'stale-policy',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-content'),
'meta_jsonb' => ['display_name' => 'Stale Policy'],
]);
$firstRun = runBaselineCompareForSnapshot($user, $tenant, $profile, $firstSnapshot);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->first();
expect($finding)->not->toBeNull();
expect($finding?->status)->toBe(Finding::STATUS_NEW);
$secondSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $secondSnapshot->getKey()]);
$firstRun->update(['completed_at' => now()->subMinute()]);
$secondRun = runBaselineCompareForSnapshot($user, $tenant, $profile, $secondSnapshot);
$finding->refresh();
expect($finding->status)->toBe(Finding::STATUS_RESOLVED);
expect($finding->resolved_reason)->toBe('no_longer_drifting');
expect($finding->resolved_at)->not->toBeNull();
expect($finding->current_operation_run_id)->toBe((int) $secondRun->getKey());
});
dataset('baseline auto close safety gates', [
'safe and enabled' => [
OperationRunOutcome::Succeeded->value,
['total' => 2, 'processed' => 2, 'failed' => 0],
null,
true,
],
'disabled by workspace setting' => [
OperationRunOutcome::Succeeded->value,
['total' => 2, 'processed' => 2, 'failed' => 0],
false,
false,
],
'partially succeeded outcome' => [
OperationRunOutcome::PartiallySucceeded->value,
['total' => 2, 'processed' => 2, 'failed' => 0],
null,
false,
],
'failed outcome' => [
OperationRunOutcome::Failed->value,
['total' => 2, 'processed' => 2, 'failed' => 0],
null,
false,
],
'incomplete processed count' => [
OperationRunOutcome::Succeeded->value,
['total' => 2, 'processed' => 1, 'failed' => 0],
null,
false,
],
'failed work recorded' => [
OperationRunOutcome::Succeeded->value,
['total' => 2, 'processed' => 2, 'failed' => 1],
null,
false,
],
'missing counters' => [
OperationRunOutcome::Succeeded->value,
['processed' => 2, 'failed' => 0],
null,
false,
],
]);
it('gates auto close on outcome completion counts and workspace setting', function (
string $outcome,
array $summaryCounts,
?bool $settingValue,
bool $expected,
): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
if ($settingValue !== null) {
WorkspaceSetting::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'domain' => 'baseline',
'key' => 'auto_close_enabled',
'value' => $settingValue,
'updated_by_user_id' => (int) $user->getKey(),
]);
}
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => $outcome,
'summary_counts' => $summaryCounts,
'completed_at' => now(),
]);
expect(app(BaselineAutoCloseService::class)->shouldAutoClose($tenant, $run))->toBe($expected);
})->with('baseline auto close safety gates');

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use Filament\Facades\Filament;
it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void {
$tenantPanelResources = Filament::getPanel('tenant')->getResources();
expect($tenantPanelResources)->not->toContain(BaselineProfileResource::class);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/t/{$tenant->external_id}")
->assertOk()
->assertDontSee("/admin/t/{$tenant->external_id}/baseline-profiles", false)
->assertDontSee('>Baselines</span>', false);
});
it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$workspaceUrl = BaselineProfileResource::getUrl(panel: 'admin');
expect($workspaceUrl)->toContain('/admin/baseline-profiles');
expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles");
$this->get($workspaceUrl)->assertOk();
$this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound();
});

View File

@ -53,3 +53,15 @@
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
});
it('can refresh stats without calling mount directly', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareLanding::class)
->call('refreshStats')
->assertStatus(200);
});

View File

@ -47,6 +47,11 @@ function workspaceManagerUser(): array
->assertSet('data.backup_retention_keep_last_default', null)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', [])
->assertSet('data.baseline_severity_missing_policy', null)
->assertSet('data.baseline_severity_different_version', null)
->assertSet('data.baseline_severity_unexpected_policy', null)
->assertSet('data.baseline_alert_min_severity', null)
->assertSet('data.baseline_auto_close_enabled', null)
->assertSet('data.findings_sla_critical', null)
->assertSet('data.findings_sla_high', null)
->assertSet('data.findings_sla_medium', null)
@ -56,6 +61,11 @@ function workspaceManagerUser(): array
->set('data.backup_retention_keep_last_default', 55)
->set('data.backup_retention_min_floor', 12)
->set('data.drift_severity_mapping', ['drift' => 'critical'])
->set('data.baseline_severity_missing_policy', 'critical')
->set('data.baseline_severity_different_version', 'low')
->set('data.baseline_severity_unexpected_policy', 'medium')
->set('data.baseline_alert_min_severity', 'critical')
->set('data.baseline_auto_close_enabled', '0')
->set('data.findings_sla_critical', 2)
->set('data.findings_sla_high', 5)
->set('data.findings_sla_medium', 10)
@ -66,6 +76,11 @@ function workspaceManagerUser(): array
->assertHasNoErrors()
->assertSet('data.backup_retention_keep_last_default', 55)
->assertSet('data.backup_retention_min_floor', 12)
->assertSet('data.baseline_severity_missing_policy', 'critical')
->assertSet('data.baseline_severity_different_version', 'low')
->assertSet('data.baseline_severity_unexpected_policy', 'medium')
->assertSet('data.baseline_alert_min_severity', 'critical')
->assertSet('data.baseline_auto_close_enabled', '0')
->assertSet('data.findings_sla_critical', 2)
->assertSet('data.findings_sla_high', 5)
->assertSet('data.findings_sla_medium', 10)
@ -88,6 +103,19 @@ function workspaceManagerUser(): array
expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping'))
->toBe(['drift' => 'critical']);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'severity_mapping'))
->toBe([
'different_version' => 'low',
'missing_policy' => 'critical',
'unexpected_policy' => 'medium',
]);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'alert_min_severity'))
->toBe('critical');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'auto_close_enabled'))
->toBeFalse();
expect(app(SettingsResolver::class)->resolveValue($workspace, 'findings', 'sla_days'))
->toBe([
'critical' => 2,
@ -258,6 +286,41 @@ function workspaceManagerUser(): array
->exists())->toBeFalse();
});
it('rejects malformed baseline settings values', function (): void {
[$workspace, $user] = workspaceManagerUser();
$writer = app(SettingsWriter::class);
expect(fn () => $writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'baseline',
key: 'severity_mapping',
value: ['unknown_change_type' => 'high'],
))->toThrow(ValidationException::class);
expect(fn () => $writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'baseline',
key: 'severity_mapping',
value: ['missing_policy' => 'urgent'],
))->toThrow(ValidationException::class);
expect(fn () => $writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'baseline',
key: 'alert_min_severity',
value: 'urgent',
))->toThrow(ValidationException::class);
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', 'baseline')
->exists())->toBeFalse();
});
it('saves partial findings sla days without auto-filling unset severities', function (): void {
[$workspace, $user] = workspaceManagerUser();

View File

@ -41,6 +41,11 @@
->assertSet('data.backup_retention_keep_last_default', 27)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', [])
->assertSet('data.baseline_severity_missing_policy', null)
->assertSet('data.baseline_severity_different_version', null)
->assertSet('data.baseline_severity_unexpected_policy', null)
->assertSet('data.baseline_alert_min_severity', null)
->assertSet('data.baseline_auto_close_enabled', null)
->assertSet('data.findings_sla_critical', null)
->assertSet('data.findings_sla_high', null)
->assertSet('data.findings_sla_medium', null)
@ -55,6 +60,12 @@
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
->assertFormComponentActionDisabled('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
->assertActionVisible(TestAction::make('reset_baseline_severity_mapping')->schemaComponent('baseline_section'))
->assertActionDisabled(TestAction::make('reset_baseline_severity_mapping')->schemaComponent('baseline_section'))
->assertActionVisible(TestAction::make('reset_baseline_alert_min_severity')->schemaComponent('baseline_section'))
->assertActionDisabled(TestAction::make('reset_baseline_alert_min_severity')->schemaComponent('baseline_section'))
->assertActionVisible(TestAction::make('reset_baseline_auto_close_enabled')->schemaComponent('baseline_section'))
->assertActionDisabled(TestAction::make('reset_baseline_auto_close_enabled')->schemaComponent('baseline_section'))
->assertActionVisible(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section'))
->assertActionDisabled(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section'))
->assertFormComponentActionVisible('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')