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:
parent
0cf612826f
commit
fdfb781144
@ -5,14 +5,13 @@
|
|||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -58,6 +57,10 @@ class BaselineCompareLanding extends Page
|
|||||||
|
|
||||||
public ?string $lastComparedAt = null;
|
public ?string $lastComparedAt = null;
|
||||||
|
|
||||||
|
public ?string $lastComparedIso = null;
|
||||||
|
|
||||||
|
public ?string $failureReason = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -79,101 +82,24 @@ public static function canAccess(): bool
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$this->refreshStats();
|
||||||
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
public function refreshStats(): void
|
||||||
$this->state = 'no_tenant';
|
{
|
||||||
$this->message = 'No tenant selected.';
|
$stats = BaselineCompareStats::forTenant(Tenant::current());
|
||||||
|
|
||||||
return;
|
$this->state = $stats->state;
|
||||||
}
|
$this->message = $stats->message;
|
||||||
|
$this->profileName = $stats->profileName;
|
||||||
$assignment = BaselineTenantAssignment::query()
|
$this->profileId = $stats->profileId;
|
||||||
->where('tenant_id', $tenant->getKey())
|
$this->snapshotId = $stats->snapshotId;
|
||||||
->first();
|
$this->operationRunId = $stats->operationRunId;
|
||||||
|
$this->findingsCount = $stats->findingsCount;
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||||
$this->state = 'no_assignment';
|
$this->lastComparedAt = $stats->lastComparedHuman;
|
||||||
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
|
$this->lastComparedIso = $stats->lastComparedIso;
|
||||||
|
$this->failureReason = $stats->failureReason;
|
||||||
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.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
@ -206,7 +132,7 @@ private function compareNowAction(): Action
|
|||||||
->modalHeading('Start baseline comparison')
|
->modalHeading('Start baseline comparison')
|
||||||
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
|
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
|
||||||
->visible(fn (): bool => $this->canCompare())
|
->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 {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -47,12 +48,15 @@ class WorkspaceSettings extends Page
|
|||||||
protected static ?int $navigationSort = 20;
|
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 = [
|
private const SETTING_FIELDS = [
|
||||||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
'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'],
|
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
'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'],
|
'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_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'],
|
'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',
|
'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;
|
public Workspace $workspace;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -206,6 +221,52 @@ public function content(Schema $schema): Schema
|
|||||||
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
|
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
|
||||||
->hintAction($this->makeResetAction('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')
|
Section::make('Findings settings')
|
||||||
->key('findings_section')
|
->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.'))
|
->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->resetValidation();
|
||||||
|
|
||||||
|
$this->composeBaselineSeveritySubFieldsIntoData();
|
||||||
$this->composeSlaSubFieldsIntoData();
|
$this->composeSlaSubFieldsIntoData();
|
||||||
|
|
||||||
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
|
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
|
||||||
@ -422,6 +484,7 @@ private function loadFormState(): void
|
|||||||
: $this->formatValueForInput($field, $workspaceValue);
|
: $this->formatValueForInput($field, $workspaceValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->decomposeBaselineSeveritySubFields($data, $workspaceOverrides);
|
||||||
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
|
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
|
||||||
|
|
||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
@ -560,6 +623,36 @@ private function slaFieldHelperText(string $severity): string
|
|||||||
return sprintf('Effective: %d days.', $effectiveValue);
|
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>>}
|
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
|
||||||
*/
|
*/
|
||||||
@ -612,6 +705,16 @@ private function normalizedInputValues(): array
|
|||||||
continue;
|
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 !== []
|
$validationErrors['data.'.$field] = $messages !== []
|
||||||
? $messages
|
? $messages
|
||||||
: ['Invalid value.'];
|
: ['Invalid value.'];
|
||||||
@ -834,6 +937,18 @@ private function formatValueForInput(string $field, mixed $value): mixed
|
|||||||
return is_string($encoded) ? $encoded : null;
|
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;
|
return is_numeric($value) ? (int) $value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -851,6 +966,18 @@ private function formatValueForDisplay(string $field, mixed $value): string
|
|||||||
return is_string($encoded) ? $encoded : '{}';
|
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';
|
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
|
private function settingForField(string $field): array
|
||||||
{
|
{
|
||||||
@ -894,6 +1021,23 @@ private function workspaceOverrideForField(string $field): mixed
|
|||||||
return $this->workspaceOverrides[$field] ?? null;
|
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.
|
* 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.
|
* 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;
|
$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
|
private function currentUserCanManage(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -378,6 +378,8 @@ public static function eventTypeOptions(): array
|
|||||||
return [
|
return [
|
||||||
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
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_SLA_DUE => 'SLA due',
|
||||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||||
|
|||||||
@ -42,6 +42,8 @@
|
|||||||
|
|
||||||
class BaselineProfileResource extends Resource
|
class BaselineProfileResource extends Resource
|
||||||
{
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
protected static ?string $model = BaselineProfile::class;
|
protected static ?string $model = BaselineProfile::class;
|
||||||
|
|||||||
@ -4,10 +4,8 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -39,52 +37,20 @@ protected function getViewData(): array
|
|||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$assignment = BaselineTenantAssignment::query()
|
$stats = BaselineCompareStats::forWidget($tenant);
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->with('baselineProfile')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
|
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||||
return $empty;
|
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 [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'profileName' => (string) $profile->name,
|
'profileName' => $stats->profileName,
|
||||||
'findingsCount' => $findingsCount,
|
'findingsCount' => $stats->findingsCount ?? 0,
|
||||||
'highCount' => $highCount,
|
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||||
'mediumCount' => $mediumCount,
|
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
||||||
'lowCount' => $lowCount,
|
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
||||||
'lastComparedAt' => $latestRun?->finished_at?->diffForHumans(),
|
'lastComparedAt' => $stats->lastComparedHuman,
|
||||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,14 +10,17 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Alerts\AlertDispatchService;
|
use App\Services\Alerts\AlertDispatchService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class EvaluateAlertsJob implements ShouldQueue
|
class EvaluateAlertsJob implements ShouldQueue
|
||||||
@ -58,8 +61,10 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
|||||||
try {
|
try {
|
||||||
$events = [
|
$events = [
|
||||||
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
|
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||||
|
...$this->baselineHighDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||||
...$this->slaDueEvents((int) $workspace->getKey(), $windowStart),
|
...$this->slaDueEvents((int) $workspace->getKey(), $windowStart),
|
||||||
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
|
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||||
|
...$this->baselineCompareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||||
];
|
];
|
||||||
@ -206,6 +211,84 @@ private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart)
|
|||||||
return $events;
|
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>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@ -236,7 +319,51 @@ private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowSt
|
|||||||
'severity' => 'high',
|
'severity' => 'high',
|
||||||
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
|
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
|
||||||
'title' => 'Drift compare failed',
|
'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' => [
|
'metadata' => [
|
||||||
'operation_run_id' => (int) $failedRun->getKey(),
|
'operation_run_id' => (int) $failedRun->getKey(),
|
||||||
],
|
],
|
||||||
@ -370,7 +497,7 @@ private function slaDueEvents(int $workspaceId, CarbonImmutable $windowStart): a
|
|||||||
return $events;
|
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 : [];
|
$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
|
private function sanitizeErrorMessage(Throwable $exception): string
|
||||||
|
|||||||
@ -12,13 +12,18 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Baselines\BaselineAutoCloseService;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Drift\DriftHasher;
|
use App\Services\Drift\DriftHasher;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -51,7 +56,12 @@ public function handle(
|
|||||||
BaselineSnapshotIdentity $snapshotIdentity,
|
BaselineSnapshotIdentity $snapshotIdentity,
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
|
?SettingsResolver $settingsResolver = null,
|
||||||
|
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||||
): void {
|
): void {
|
||||||
|
$settingsResolver ??= app(SettingsResolver::class);
|
||||||
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
$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.");
|
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
|
$initiator = $this->operationRun->user_id
|
||||||
? User::query()->find($this->operationRun->user_id)
|
? User::query()->find($this->operationRun->user_id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
|
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
|
||||||
|
|
||||||
$baselineItems = $this->loadBaselineItems($snapshotId);
|
$baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope);
|
||||||
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity);
|
$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,
|
$driftHasher,
|
||||||
$tenant,
|
$tenant,
|
||||||
$profile,
|
$profile,
|
||||||
@ -101,11 +122,14 @@ public function handle(
|
|||||||
$summaryCounts = [
|
$summaryCounts = [
|
||||||
'total' => count($driftResults),
|
'total' => count($driftResults),
|
||||||
'processed' => count($driftResults),
|
'processed' => count($driftResults),
|
||||||
'succeeded' => $upsertedCount,
|
'succeeded' => (int) $upsertResult['processed_count'],
|
||||||
'failed' => count($driftResults) - $upsertedCount,
|
'failed' => 0,
|
||||||
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
|
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
|
||||||
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
|
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
|
||||||
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 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(
|
$operationRunService->updateRun(
|
||||||
@ -115,10 +139,31 @@ public function handle(
|
|||||||
summaryCounts: $summaryCounts,
|
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 = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
$updatedContext['result'] = [
|
$updatedContext['result'] = [
|
||||||
'findings_total' => count($driftResults),
|
'findings_total' => count($driftResults),
|
||||||
'findings_upserted' => $upsertedCount,
|
'findings_upserted' => (int) $upsertResult['processed_count'],
|
||||||
|
'findings_resolved' => $resolvedCount,
|
||||||
'severity_breakdown' => $severityBreakdown,
|
'severity_breakdown' => $severityBreakdown,
|
||||||
];
|
];
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$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>}>
|
* @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 = [];
|
$items = [];
|
||||||
|
|
||||||
BaselineSnapshotItem::query()
|
$query = BaselineSnapshotItem::query()
|
||||||
->where('baseline_snapshot_id', $snapshotId)
|
->where('baseline_snapshot_id', $snapshotId);
|
||||||
|
|
||||||
|
if (! $scope->isEmpty()) {
|
||||||
|
$query->whereIn('policy_type', $scope->policyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->chunk(500, function ($snapshotItems) use (&$items): void {
|
->chunk(500, function ($snapshotItems) use (&$items): void {
|
||||||
foreach ($snapshotItems as $item) {
|
foreach ($snapshotItems as $item) {
|
||||||
$key = $item->policy_type . '|' . $item->subject_external_id;
|
$key = $item->policy_type.'|'.$item->subject_external_id;
|
||||||
$items[$key] = [
|
$items[$key] = [
|
||||||
'subject_type' => (string) $item->subject_type,
|
'subject_type' => (string) $item->subject_type,
|
||||||
'subject_external_id' => (string) $item->subject_external_id,
|
'subject_external_id' => (string) $item->subject_external_id,
|
||||||
@ -163,10 +214,15 @@ private function loadCurrentInventory(
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BaselineScope $scope,
|
BaselineScope $scope,
|
||||||
BaselineSnapshotIdentity $snapshotIdentity,
|
BaselineSnapshotIdentity $snapshotIdentity,
|
||||||
|
?int $latestInventorySyncRunId = null,
|
||||||
): array {
|
): array {
|
||||||
$query = InventoryItem::query()
|
$query = InventoryItem::query()
|
||||||
->where('tenant_id', $tenant->getKey());
|
->where('tenant_id', $tenant->getKey());
|
||||||
|
|
||||||
|
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
|
||||||
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $scope->isEmpty()) {
|
if (! $scope->isEmpty()) {
|
||||||
$query->whereIn('policy_type', $scope->policyTypes);
|
$query->whereIn('policy_type', $scope->policyTypes);
|
||||||
}
|
}
|
||||||
@ -180,7 +236,7 @@ private function loadCurrentInventory(
|
|||||||
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
||||||
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
|
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
|
||||||
|
|
||||||
$key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id;
|
$key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id;
|
||||||
$items[$key] = [
|
$items[$key] = [
|
||||||
'subject_external_id' => (string) $inventoryItem->external_id,
|
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||||
'policy_type' => (string) $inventoryItem->policy_type,
|
'policy_type' => (string) $inventoryItem->policy_type,
|
||||||
@ -197,14 +253,30 @@ private function loadCurrentInventory(
|
|||||||
return $items;
|
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.
|
* 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_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, 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>}>
|
* @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 = [];
|
$drift = [];
|
||||||
|
|
||||||
@ -212,7 +284,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
|||||||
if (! array_key_exists($key, $currentItems)) {
|
if (! array_key_exists($key, $currentItems)) {
|
||||||
$drift[] = [
|
$drift[] = [
|
||||||
'change_type' => 'missing_policy',
|
'change_type' => 'missing_policy',
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||||
'subject_type' => $baselineItem['subject_type'],
|
'subject_type' => $baselineItem['subject_type'],
|
||||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||||
'policy_type' => $baselineItem['policy_type'],
|
'policy_type' => $baselineItem['policy_type'],
|
||||||
@ -233,7 +305,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
|||||||
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
|
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
|
||||||
$drift[] = [
|
$drift[] = [
|
||||||
'change_type' => 'different_version',
|
'change_type' => 'different_version',
|
||||||
'severity' => Finding::SEVERITY_MEDIUM,
|
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
|
||||||
'subject_type' => $baselineItem['subject_type'],
|
'subject_type' => $baselineItem['subject_type'],
|
||||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||||
'policy_type' => $baselineItem['policy_type'],
|
'policy_type' => $baselineItem['policy_type'],
|
||||||
@ -254,7 +326,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
|||||||
if (! array_key_exists($key, $baselineItems)) {
|
if (! array_key_exists($key, $baselineItems)) {
|
||||||
$drift[] = [
|
$drift[] = [
|
||||||
'change_type' => 'unexpected_policy',
|
'change_type' => 'unexpected_policy',
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||||
'subject_type' => 'policy',
|
'subject_type' => 'policy',
|
||||||
'subject_external_id' => $currentItem['subject_external_id'],
|
'subject_external_id' => $currentItem['subject_external_id'],
|
||||||
'policy_type' => $currentItem['policy_type'],
|
'policy_type' => $currentItem['policy_type'],
|
||||||
@ -276,6 +348,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
|||||||
* Upsert drift findings using stable fingerprints.
|
* 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
|
* @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(
|
private function upsertFindings(
|
||||||
DriftHasher $driftHasher,
|
DriftHasher $driftHasher,
|
||||||
@ -283,9 +356,14 @@ private function upsertFindings(
|
|||||||
BaselineProfile $profile,
|
BaselineProfile $profile,
|
||||||
string $scopeKey,
|
string $scopeKey,
|
||||||
array $driftResults,
|
array $driftResults,
|
||||||
): int {
|
): array {
|
||||||
$upsertedCount = 0;
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$observedAt = CarbonImmutable::now();
|
||||||
|
$processedCount = 0;
|
||||||
|
$createdCount = 0;
|
||||||
|
$reopenedCount = 0;
|
||||||
|
$unchangedCount = 0;
|
||||||
|
$seenFingerprints = [];
|
||||||
|
|
||||||
foreach ($driftResults as $driftItem) {
|
foreach ($driftResults as $driftItem) {
|
||||||
$fingerprint = $driftHasher->fingerprint(
|
$fingerprint = $driftHasher->fingerprint(
|
||||||
@ -298,29 +376,99 @@ private function upsertFindings(
|
|||||||
currentHash: $driftItem['current_hash'],
|
currentHash: $driftItem['current_hash'],
|
||||||
);
|
);
|
||||||
|
|
||||||
Finding::query()->updateOrCreate(
|
$seenFingerprints[] = $fingerprint;
|
||||||
[
|
|
||||||
'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(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$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 $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(
|
private function auditStarted(
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
|
|||||||
@ -18,6 +18,10 @@ class AlertRule extends Model
|
|||||||
|
|
||||||
public const string EVENT_COMPARE_FAILED = 'compare_failed';
|
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_SLA_DUE = 'sla_due';
|
||||||
|
|
||||||
public const string EVENT_PERMISSION_MISSING = 'permission_missing';
|
public const string EVENT_PERMISSION_MISSING = 'permission_missing';
|
||||||
|
|||||||
121
app/Services/Baselines/BaselineAutoCloseService.php
Normal file
121
app/Services/Baselines/BaselineAutoCloseService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
final class BaselineCaptureService
|
final class BaselineCaptureService
|
||||||
{
|
{
|
||||||
@ -45,7 +46,7 @@ public function startCapture(
|
|||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
tenant: $sourceTenant,
|
tenant: $sourceTenant,
|
||||||
type: 'baseline_capture',
|
type: OperationRunType::BaselineCapture->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
final class BaselineCompareService
|
final class BaselineCompareService
|
||||||
{
|
{
|
||||||
@ -67,7 +68,7 @@ public function startCompare(
|
|||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'baseline_compare',
|
type: OperationRunType::BaselineCompare->value,
|
||||||
identityInputs: [
|
identityInputs: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
],
|
],
|
||||||
|
|||||||
271
app/Support/Baselines/BaselineCompareStats.php
Normal file
271
app/Support/Baselines/BaselineCompareStats.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
enum OperationRunType: string
|
enum OperationRunType: string
|
||||||
{
|
{
|
||||||
|
case BaselineCapture = 'baseline_capture';
|
||||||
|
case BaselineCompare = 'baseline_compare';
|
||||||
case InventorySync = 'inventory_sync';
|
case InventorySync = 'inventory_sync';
|
||||||
case PolicySync = 'policy.sync';
|
case PolicySync = 'policy.sync';
|
||||||
case PolicySyncOne = 'policy.sync_one';
|
case PolicySyncOne = 'policy.sync_one';
|
||||||
|
|||||||
@ -80,6 +80,74 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
|
|||||||
normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value),
|
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(
|
$this->register(new SettingDefinition(
|
||||||
domain: 'findings',
|
domain: 'findings',
|
||||||
key: 'sla_days',
|
key: 'sla_days',
|
||||||
@ -223,6 +291,61 @@ private static function normalizeSeverityMapping(mixed $value): array
|
|||||||
return $normalized;
|
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>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
<x-filament::page>
|
<x-filament::page>
|
||||||
|
{{-- Auto-refresh while comparison is running --}}
|
||||||
|
@if ($state === 'comparing')
|
||||||
|
<div wire:poll.5s="refreshStats"></div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Row 1: Stats Overview --}}
|
{{-- 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">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
{{-- Stat: Assigned Baseline --}}
|
{{-- Stat: Assigned Baseline --}}
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
@ -19,9 +24,13 @@
|
|||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="flex flex-col gap-1">
|
<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-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' }}">
|
@if ($state === 'failed')
|
||||||
{{ $findingsCount ?? 0 }}
|
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
|
||||||
</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')
|
@if ($state === 'comparing')
|
||||||
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
|
<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" />
|
<x-filament::loading-indicator class="h-3 w-3" />
|
||||||
@ -37,7 +46,7 @@
|
|||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="flex flex-col gap-1">
|
<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-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' }}
|
{{ $lastComparedAt ?? 'Never' }}
|
||||||
</div>
|
</div>
|
||||||
@if ($this->getRunUrl())
|
@if ($this->getRunUrl())
|
||||||
@ -50,6 +59,37 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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 --}}
|
{{-- Critical drift banner --}}
|
||||||
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
|
@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">
|
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||||
|
|||||||
@ -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 repository’s constitution; it does not prescribe languages, external APIs, or framework implementation steps.
|
||||||
@ -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
|
||||||
63
specs/115-baseline-operability-alerts/data-model.md
Normal file
63
specs/115-baseline-operability-alerts/data-model.md
Normal 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.
|
||||||
166
specs/115-baseline-operability-alerts/plan.md
Normal file
166
specs/115-baseline-operability-alerts/plan.md
Normal 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] |
|
||||||
42
specs/115-baseline-operability-alerts/quickstart.md
Normal file
42
specs/115-baseline-operability-alerts/quickstart.md
Normal 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/`.
|
||||||
61
specs/115-baseline-operability-alerts/research.md
Normal file
61
specs/115-baseline-operability-alerts/research.md
Normal 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()`; they’re 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 run’s computed “seen” fingerprint set.
|
||||||
|
- Rationale: The job already has the full drift result set for the tenant+profile; it’s 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`.
|
||||||
230
specs/115-baseline-operability-alerts/spec.md
Normal file
230
specs/115-baseline-operability-alerts/spec.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# Feature Specification: Baseline Operability & Alert Integration (R1.1–R1.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.1–R1.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 doesn’t 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 finding’s 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 don’t 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 MSP’s operational standards.
|
||||||
|
|
||||||
|
**Why this priority**: Settings are required for enterprise adoption; hardcoded severity and alert thresholds don’t 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 run’s 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.
|
||||||
186
specs/115-baseline-operability-alerts/tasks.md
Normal file
186
specs/115-baseline-operability-alerts/tasks.md
Normal 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 3–4)**: 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 (T004–T005).
|
||||||
|
- **US3 (P2)** depends on: baseline settings definitions (T003) and Workspace Settings page wiring (T025–T027).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 T018–T020 (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.
|
||||||
185
tests/Feature/Alerts/BaselineCompareFailedAlertTest.php
Normal file
185
tests/Feature/Alerts/BaselineCompareFailedAlertTest.php
Normal 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);
|
||||||
|
});
|
||||||
211
tests/Feature/Alerts/BaselineHighDriftAlertTest.php
Normal file
211
tests/Feature/Alerts/BaselineHighDriftAlertTest.php
Normal 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);
|
||||||
|
});
|
||||||
@ -4,14 +4,20 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineSnapshotItem;
|
use App\Models\BaselineSnapshotItem;
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\WorkspaceSetting;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Drift\DriftHasher;
|
use App\Services\Drift\DriftHasher;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
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 ---
|
// --- T041: Compare idempotent finding fingerprint tests ---
|
||||||
|
|
||||||
@ -69,7 +75,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'baseline_compare',
|
type: OperationRunType::BaselineCompare->value,
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -91,7 +97,7 @@
|
|||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->outcome)->toBe('succeeded');
|
expect($run->outcome)->toBe('succeeded');
|
||||||
|
|
||||||
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$findings = Finding::query()
|
$findings = Finding::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
@ -102,12 +108,203 @@
|
|||||||
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
|
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
|
||||||
expect($findings->count())->toBe(3);
|
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();
|
$severities = $findings->pluck('severity')->sort()->values()->all();
|
||||||
expect($severities)->toContain(Finding::SEVERITY_HIGH);
|
expect($severities)->toContain(Finding::SEVERITY_HIGH);
|
||||||
expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
|
expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
|
||||||
expect($severities)->toContain(Finding::SEVERITY_LOW);
|
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 () {
|
it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -138,7 +335,7 @@
|
|||||||
// First run
|
// First run
|
||||||
$run1 = $opService->ensureRunWithIdentity(
|
$run1 = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'baseline_compare',
|
type: OperationRunType::BaselineCompare->value,
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -156,7 +353,7 @@
|
|||||||
$opService,
|
$opService,
|
||||||
);
|
);
|
||||||
|
|
||||||
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
$countAfterFirst = Finding::query()
|
$countAfterFirst = Finding::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('source', 'baseline.compare')
|
->where('source', 'baseline.compare')
|
||||||
@ -171,7 +368,7 @@
|
|||||||
|
|
||||||
$run2 = $opService->ensureRunWithIdentity(
|
$run2 = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'baseline_compare',
|
type: OperationRunType::BaselineCompare->value,
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -241,7 +438,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'baseline_compare',
|
type: OperationRunType::BaselineCompare->value,
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -274,6 +471,96 @@
|
|||||||
expect($findings)->toBe(0);
|
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 ---
|
// --- T042: Summary counts severity breakdown tests ---
|
||||||
|
|
||||||
it('writes severity breakdown in summary_counts', function () {
|
it('writes severity breakdown in summary_counts', function () {
|
||||||
@ -330,7 +617,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'baseline_compare',
|
type: OperationRunType::BaselineCompare->value,
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -384,7 +671,7 @@
|
|||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$run = $opService->ensureRunWithIdentity(
|
$run = $opService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'baseline_compare',
|
type: OperationRunType::BaselineCompare->value,
|
||||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
@ -412,3 +699,260 @@
|
|||||||
expect((int) $result['findings_total'])->toBe(1);
|
expect((int) $result['findings_total'])->toBe(1);
|
||||||
expect((int) $result['findings_upserted'])->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);
|
||||||
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
// --- T040: Compare precondition 422 tests ---
|
// --- T040: Compare precondition 422 tests ---
|
||||||
@ -23,7 +24,7 @@
|
|||||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
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 () {
|
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);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
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 () {
|
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);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
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 () {
|
it('rejects compare when profile has no active snapshot', function () {
|
||||||
@ -99,6 +101,7 @@
|
|||||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
|
||||||
|
|
||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
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 () {
|
it('enqueues compare successfully when all preconditions are met', function () {
|
||||||
@ -132,7 +135,7 @@
|
|||||||
|
|
||||||
/** @var OperationRun $run */
|
/** @var OperationRun $run */
|
||||||
$run = $result['run'];
|
$run = $result['run'];
|
||||||
expect($run->type)->toBe('baseline_compare');
|
expect($run->type)->toBe(OperationRunType::BaselineCompare->value);
|
||||||
expect($run->status)->toBe('queued');
|
expect($run->status)->toBe('queued');
|
||||||
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
@ -174,5 +177,5 @@
|
|||||||
expect($result1['ok'])->toBeTrue();
|
expect($result1['ok'])->toBeTrue();
|
||||||
expect($result2['ok'])->toBeTrue();
|
expect($result2['ok'])->toBeTrue();
|
||||||
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
274
tests/Feature/Baselines/BaselineCompareStatsTest.php
Normal file
274
tests/Feature/Baselines/BaselineCompareStatsTest.php
Normal 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);
|
||||||
|
});
|
||||||
188
tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php
Normal file
188
tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php
Normal 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');
|
||||||
@ -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();
|
||||||
|
});
|
||||||
@ -53,3 +53,15 @@
|
|||||||
expect($run)->not->toBeNull();
|
expect($run)->not->toBeNull();
|
||||||
expect($run?->status)->toBe('queued');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@ -47,6 +47,11 @@ function workspaceManagerUser(): array
|
|||||||
->assertSet('data.backup_retention_keep_last_default', null)
|
->assertSet('data.backup_retention_keep_last_default', null)
|
||||||
->assertSet('data.backup_retention_min_floor', null)
|
->assertSet('data.backup_retention_min_floor', null)
|
||||||
->assertSet('data.drift_severity_mapping', [])
|
->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_critical', null)
|
||||||
->assertSet('data.findings_sla_high', null)
|
->assertSet('data.findings_sla_high', null)
|
||||||
->assertSet('data.findings_sla_medium', 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_keep_last_default', 55)
|
||||||
->set('data.backup_retention_min_floor', 12)
|
->set('data.backup_retention_min_floor', 12)
|
||||||
->set('data.drift_severity_mapping', ['drift' => 'critical'])
|
->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_critical', 2)
|
||||||
->set('data.findings_sla_high', 5)
|
->set('data.findings_sla_high', 5)
|
||||||
->set('data.findings_sla_medium', 10)
|
->set('data.findings_sla_medium', 10)
|
||||||
@ -66,6 +76,11 @@ function workspaceManagerUser(): array
|
|||||||
->assertHasNoErrors()
|
->assertHasNoErrors()
|
||||||
->assertSet('data.backup_retention_keep_last_default', 55)
|
->assertSet('data.backup_retention_keep_last_default', 55)
|
||||||
->assertSet('data.backup_retention_min_floor', 12)
|
->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_critical', 2)
|
||||||
->assertSet('data.findings_sla_high', 5)
|
->assertSet('data.findings_sla_high', 5)
|
||||||
->assertSet('data.findings_sla_medium', 10)
|
->assertSet('data.findings_sla_medium', 10)
|
||||||
@ -88,6 +103,19 @@ function workspaceManagerUser(): array
|
|||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping'))
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping'))
|
||||||
->toBe(['drift' => 'critical']);
|
->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'))
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'findings', 'sla_days'))
|
||||||
->toBe([
|
->toBe([
|
||||||
'critical' => 2,
|
'critical' => 2,
|
||||||
@ -258,6 +286,41 @@ function workspaceManagerUser(): array
|
|||||||
->exists())->toBeFalse();
|
->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 {
|
it('saves partial findings sla days without auto-filling unset severities', function (): void {
|
||||||
[$workspace, $user] = workspaceManagerUser();
|
[$workspace, $user] = workspaceManagerUser();
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,11 @@
|
|||||||
->assertSet('data.backup_retention_keep_last_default', 27)
|
->assertSet('data.backup_retention_keep_last_default', 27)
|
||||||
->assertSet('data.backup_retention_min_floor', null)
|
->assertSet('data.backup_retention_min_floor', null)
|
||||||
->assertSet('data.drift_severity_mapping', [])
|
->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_critical', null)
|
||||||
->assertSet('data.findings_sla_high', null)
|
->assertSet('data.findings_sla_high', null)
|
||||||
->assertSet('data.findings_sla_medium', null)
|
->assertSet('data.findings_sla_medium', null)
|
||||||
@ -55,6 +60,12 @@
|
|||||||
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
||||||
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
||||||
->assertFormComponentActionDisabled('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'))
|
->assertActionVisible(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section'))
|
||||||
->assertActionDisabled(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')
|
->assertFormComponentActionVisible('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user