From fdfb7811445f9495ac7e7b4e5f5ad5aed6179536 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 1 Mar 2026 02:26:47 +0000 Subject: [PATCH] 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 Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/140 --- app/Filament/Pages/BaselineCompareLanding.php | 118 +--- .../Pages/Settings/WorkspaceSettings.php | 192 +++++- app/Filament/Resources/AlertRuleResource.php | 2 + .../Resources/BaselineProfileResource.php | 2 + .../Widgets/Dashboard/BaselineCompareNow.php | 52 +- app/Jobs/Alerts/EvaluateAlertsJob.php | 178 +++++- app/Jobs/CompareBaselineToTenantJob.php | 264 ++++++-- app/Models/AlertRule.php | 4 + .../Baselines/BaselineAutoCloseService.php | 121 ++++ .../Baselines/BaselineCaptureService.php | 3 +- .../Baselines/BaselineCompareService.php | 3 +- .../Baselines/BaselineCompareStats.php | 271 +++++++++ app/Support/OperationRunType.php | 2 + app/Support/Settings/SettingsRegistry.php | 123 ++++ .../pages/baseline-compare-landing.blade.php | 50 +- .../checklists/requirements.md | 35 ++ .../baseline-alert-events.openapi.yaml | 70 +++ .../data-model.md | 63 ++ specs/115-baseline-operability-alerts/plan.md | 166 ++++++ .../quickstart.md | 42 ++ .../research.md | 61 ++ specs/115-baseline-operability-alerts/spec.md | 230 +++++++ .../115-baseline-operability-alerts/tasks.md | 186 ++++++ .../Alerts/BaselineCompareFailedAlertTest.php | 185 ++++++ .../Alerts/BaselineHighDriftAlertTest.php | 211 +++++++ .../Baselines/BaselineCompareFindingsTest.php | 562 +++++++++++++++++- .../BaselineComparePreconditionsTest.php | 11 +- .../Baselines/BaselineCompareStatsTest.php | 274 +++++++++ .../BaselineOperabilityAutoCloseTest.php | 188 ++++++ .../BaselineProfileWorkspaceOwnershipTest.php | 36 ++ ...BaselineCompareLandingStartSurfaceTest.php | 12 + .../WorkspaceSettingsManageTest.php | 63 ++ .../WorkspaceSettingsViewOnlyTest.php | 11 + 33 files changed, 3588 insertions(+), 203 deletions(-) create mode 100644 app/Services/Baselines/BaselineAutoCloseService.php create mode 100644 app/Support/Baselines/BaselineCompareStats.php create mode 100644 specs/115-baseline-operability-alerts/checklists/requirements.md create mode 100644 specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml create mode 100644 specs/115-baseline-operability-alerts/data-model.md create mode 100644 specs/115-baseline-operability-alerts/plan.md create mode 100644 specs/115-baseline-operability-alerts/quickstart.md create mode 100644 specs/115-baseline-operability-alerts/research.md create mode 100644 specs/115-baseline-operability-alerts/spec.md create mode 100644 specs/115-baseline-operability-alerts/tasks.md create mode 100644 tests/Feature/Alerts/BaselineCompareFailedAlertTest.php create mode 100644 tests/Feature/Alerts/BaselineHighDriftAlertTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareStatsTest.php create mode 100644 tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php create mode 100644 tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index fb1d8cb..eca4b40 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -5,14 +5,13 @@ namespace App\Filament\Pages; use App\Filament\Resources\FindingResource; -use App\Models\BaselineTenantAssignment; -use App\Models\Finding; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; use App\Services\Auth\CapabilityResolver; use App\Services\Baselines\BaselineCompareService; use App\Support\Auth\Capabilities; +use App\Support\Baselines\BaselineCompareStats; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -58,6 +57,10 @@ class BaselineCompareLanding extends Page public ?string $lastComparedAt = null; + public ?string $lastComparedIso = null; + + public ?string $failureReason = null; + public static function canAccess(): bool { $user = auth()->user(); @@ -79,101 +82,24 @@ public static function canAccess(): bool public function mount(): void { - $tenant = Tenant::current(); + $this->refreshStats(); + } - if (! $tenant instanceof Tenant) { - $this->state = 'no_tenant'; - $this->message = 'No tenant selected.'; + public function refreshStats(): void + { + $stats = BaselineCompareStats::forTenant(Tenant::current()); - return; - } - - $assignment = BaselineTenantAssignment::query() - ->where('tenant_id', $tenant->getKey()) - ->first(); - - if (! $assignment instanceof BaselineTenantAssignment) { - $this->state = 'no_assignment'; - $this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.'; - - return; - } - - $profile = $assignment->baselineProfile; - - if ($profile === null) { - $this->state = 'no_assignment'; - $this->message = 'The assigned baseline profile no longer exists.'; - - return; - } - - $this->profileName = (string) $profile->name; - $this->profileId = (int) $profile->getKey(); - $this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; - - if ($this->snapshotId === null) { - $this->state = 'no_snapshot'; - $this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.'; - - return; - } - - $latestRun = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('type', 'baseline_compare') - ->latest('id') - ->first(); - - if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { - $this->state = 'comparing'; - $this->operationRunId = (int) $latestRun->getKey(); - $this->message = 'A baseline comparison is currently in progress.'; - - return; - } - - if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) { - $this->lastComparedAt = $latestRun->finished_at->diffForHumans(); - } - - $scopeKey = 'baseline_profile:'.$profile->getKey(); - - $findingsQuery = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('source', 'baseline.compare') - ->where('scope_key', $scopeKey); - - $totalFindings = (int) (clone $findingsQuery)->count(); - - if ($totalFindings > 0) { - $this->state = 'ready'; - $this->findingsCount = $totalFindings; - $this->severityCounts = [ - 'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(), - 'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(), - 'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(), - ]; - - if ($latestRun instanceof OperationRun) { - $this->operationRunId = (int) $latestRun->getKey(); - } - - return; - } - - if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') { - $this->state = 'ready'; - $this->findingsCount = 0; - $this->operationRunId = (int) $latestRun->getKey(); - $this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.'; - - return; - } - - $this->state = 'idle'; - $this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.'; + $this->state = $stats->state; + $this->message = $stats->message; + $this->profileName = $stats->profileName; + $this->profileId = $stats->profileId; + $this->snapshotId = $stats->snapshotId; + $this->operationRunId = $stats->operationRunId; + $this->findingsCount = $stats->findingsCount; + $this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null; + $this->lastComparedAt = $stats->lastComparedHuman; + $this->lastComparedIso = $stats->lastComparedIso; + $this->failureReason = $stats->failureReason; } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration @@ -206,7 +132,7 @@ private function compareNowAction(): Action ->modalHeading('Start baseline comparison') ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.') ->visible(fn (): bool => $this->canCompare()) - ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true)) + ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true)) ->action(function (): void { $user = auth()->user(); diff --git a/app/Filament/Pages/Settings/WorkspaceSettings.php b/app/Filament/Pages/Settings/WorkspaceSettings.php index 40261b9..0692aa7 100644 --- a/app/Filament/Pages/Settings/WorkspaceSettings.php +++ b/app/Filament/Pages/Settings/WorkspaceSettings.php @@ -20,6 +20,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; @@ -47,12 +48,15 @@ class WorkspaceSettings extends Page protected static ?int $navigationSort = 20; /** - * @var array + * @var array */ private const SETTING_FIELDS = [ 'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'], 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], + 'baseline_severity_mapping' => ['domain' => 'baseline', 'key' => 'severity_mapping', 'type' => 'json'], + 'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'], + 'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'], 'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'], 'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'], 'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'], @@ -79,6 +83,17 @@ class WorkspaceSettings extends Page 'findings_sla_low' => 'low', ]; + /** + * Baseline severity mapping is edited as explicit fields per change type. + * + * @var array + */ + private const BASELINE_MAPPING_SUB_FIELDS = [ + 'baseline_severity_missing_policy' => 'missing_policy', + 'baseline_severity_different_version' => 'different_version', + 'baseline_severity_unexpected_policy' => 'unexpected_policy', + ]; + public Workspace $workspace; /** @@ -206,6 +221,52 @@ public function content(Schema $schema): Schema ->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping')) ->hintAction($this->makeResetAction('drift_severity_mapping')), ]), + Section::make('Baseline settings') + ->key('baseline_section') + ->description($this->sectionDescription('baseline', 'Tune baseline drift severity mapping, alert threshold, and stale-finding auto-close behavior.')) + ->columns(2) + ->afterHeader([ + $this->makeResetAction('baseline_severity_mapping')->label('Reset mapping')->size('sm'), + $this->makeResetAction('baseline_alert_min_severity')->label('Reset threshold')->size('sm'), + $this->makeResetAction('baseline_auto_close_enabled')->label('Reset auto-close')->size('sm'), + ]) + ->schema([ + Select::make('baseline_severity_missing_policy') + ->label('Missing policy severity') + ->options(self::severityOptions()) + ->placeholder('Unset (uses default)') + ->native(false) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->baselineSeverityFieldHelperText('missing_policy')), + Select::make('baseline_severity_different_version') + ->label('Different version severity') + ->options(self::severityOptions()) + ->placeholder('Unset (uses default)') + ->native(false) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->baselineSeverityFieldHelperText('different_version')), + Select::make('baseline_severity_unexpected_policy') + ->label('Unexpected policy severity') + ->options(self::severityOptions()) + ->placeholder('Unset (uses default)') + ->native(false) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->baselineSeverityFieldHelperText('unexpected_policy')), + Select::make('baseline_alert_min_severity') + ->label('Minimum alert severity') + ->options(self::severityOptions()) + ->placeholder('Unset (uses default)') + ->native(false) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->helperTextFor('baseline_alert_min_severity')), + Select::make('baseline_auto_close_enabled') + ->label('Auto-close stale drift') + ->options(self::booleanOptions()) + ->placeholder('Unset (uses default)') + ->native(false) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->helperTextFor('baseline_auto_close_enabled')), + ]), Section::make('Findings settings') ->key('findings_section') ->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.')) @@ -302,6 +363,7 @@ public function save(): void $this->resetValidation(); + $this->composeBaselineSeveritySubFieldsIntoData(); $this->composeSlaSubFieldsIntoData(); [$normalizedValues, $validationErrors] = $this->normalizedInputValues(); @@ -422,6 +484,7 @@ private function loadFormState(): void : $this->formatValueForInput($field, $workspaceValue); } + $this->decomposeBaselineSeveritySubFields($data, $workspaceOverrides); $this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings); $this->data = $data; @@ -560,6 +623,36 @@ private function slaFieldHelperText(string $severity): string return sprintf('Effective: %d days.', $effectiveValue); } + private function baselineSeverityFieldHelperText(string $changeType): string + { + $resolved = $this->resolvedSettings['baseline_severity_mapping'] ?? null; + + if (! is_array($resolved)) { + return ''; + } + + $effectiveValue = is_array($resolved['value'] ?? null) + ? (string) ($resolved['value'][$changeType] ?? '') + : ''; + + $systemDefault = is_array($resolved['system_default'] ?? null) + ? (string) ($resolved['system_default'][$changeType] ?? '') + : ''; + + if (! $this->hasWorkspaceOverride('baseline_severity_mapping')) { + return sprintf('Default: %s.', $systemDefault); + } + + if ( + is_array($this->workspaceOverrideForField('baseline_severity_mapping')) + && array_key_exists($changeType, $this->workspaceOverrideForField('baseline_severity_mapping')) + ) { + return sprintf('Effective: %s.', $effectiveValue); + } + + return sprintf('Unset. Effective value: %s (system default).', $effectiveValue); + } + /** * @return array{0: array, 1: array>} */ @@ -612,6 +705,16 @@ private function normalizedInputValues(): array continue; } + if ($field === 'baseline_severity_mapping') { + foreach (array_keys(self::BASELINE_MAPPING_SUB_FIELDS) as $subField) { + $validationErrors['data.'.$subField] = $messages !== [] + ? $messages + : ['Invalid value.']; + } + + continue; + } + $validationErrors['data.'.$field] = $messages !== [] ? $messages : ['Invalid value.']; @@ -834,6 +937,18 @@ private function formatValueForInput(string $field, mixed $value): mixed return is_string($encoded) ? $encoded : null; } + if ($setting['type'] === 'string') { + return is_string($value) ? $value : null; + } + + if ($setting['type'] === 'bool') { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + return is_numeric($value) ? (string) (int) $value : null; + } + return is_numeric($value) ? (int) $value : null; } @@ -851,6 +966,18 @@ private function formatValueForDisplay(string $field, mixed $value): string return is_string($encoded) ? $encoded : '{}'; } + if ($setting['type'] === 'string') { + return is_string($value) && $value !== '' ? $value : 'null'; + } + + if ($setting['type'] === 'bool') { + if (is_bool($value)) { + return $value ? 'enabled' : 'disabled'; + } + + return 'null'; + } + return is_numeric($value) ? (string) (int) $value : 'null'; } @@ -864,7 +991,7 @@ private function sourceLabel(string $source): string } /** - * @return array{domain: string, key: string, type: 'int'|'json'} + * @return array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'} */ private function settingForField(string $field): array { @@ -894,6 +1021,23 @@ private function workspaceOverrideForField(string $field): mixed return $this->workspaceOverrides[$field] ?? null; } + /** + * Decompose the baseline severity mapping JSON setting into explicit change-type sub-fields. + * + * @param array $data + * @param array $workspaceOverrides + */ + private function decomposeBaselineSeveritySubFields(array &$data, array &$workspaceOverrides): void + { + $mappingOverride = $workspaceOverrides['baseline_severity_mapping'] ?? null; + + foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) { + $data[$subField] = is_array($mappingOverride) && isset($mappingOverride[$changeType]) + ? (string) $mappingOverride[$changeType] + : null; + } + } + /** * Decompose the findings_sla_days JSON setting into individual SLA sub-fields. * @@ -913,6 +1057,26 @@ private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides, } } + /** + * Re-compose baseline severity mapping sub-fields back into the baseline_severity_mapping data key before save. + */ + private function composeBaselineSeveritySubFieldsIntoData(): void + { + $values = []; + + foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) { + $value = $this->data[$subField] ?? null; + + if (! is_string($value) || trim($value) === '') { + continue; + } + + $values[$changeType] = strtolower(trim($value)); + } + + $this->data['baseline_severity_mapping'] = $values !== [] ? $values : null; + } + /** * Re-compose individual SLA sub-fields back into the findings_sla_days data key before save. */ @@ -933,6 +1097,30 @@ private function composeSlaSubFieldsIntoData(): void $this->data['findings_sla_days'] = $hasAnyValue ? $values : null; } + /** + * @return array + */ + private static function severityOptions(): array + { + return [ + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + 'critical' => 'Critical', + ]; + } + + /** + * @return array + */ + private static function booleanOptions(): array + { + return [ + '1' => 'Enabled', + '0' => 'Disabled', + ]; + } + private function currentUserCanManage(): bool { $user = auth()->user(); diff --git a/app/Filament/Resources/AlertRuleResource.php b/app/Filament/Resources/AlertRuleResource.php index e213103..262ced4 100644 --- a/app/Filament/Resources/AlertRuleResource.php +++ b/app/Filament/Resources/AlertRuleResource.php @@ -378,6 +378,8 @@ public static function eventTypeOptions(): array return [ AlertRule::EVENT_HIGH_DRIFT => 'High drift', AlertRule::EVENT_COMPARE_FAILED => 'Compare failed', + AlertRule::EVENT_BASELINE_HIGH_DRIFT => 'Baseline drift', + AlertRule::EVENT_BASELINE_COMPARE_FAILED => 'Baseline compare failed', AlertRule::EVENT_SLA_DUE => 'SLA due', AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing', AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)', diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php index 37065c0..f93f7ff 100644 --- a/app/Filament/Resources/BaselineProfileResource.php +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -42,6 +42,8 @@ class BaselineProfileResource extends Resource { + protected static bool $isDiscovered = false; + protected static bool $isScopedToTenant = false; protected static ?string $model = BaselineProfile::class; diff --git a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php index 4108995..978c3f5 100644 --- a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php +++ b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php @@ -4,10 +4,8 @@ namespace App\Filament\Widgets\Dashboard; -use App\Models\BaselineTenantAssignment; -use App\Models\Finding; -use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\Baselines\BaselineCompareStats; use Filament\Facades\Filament; use Filament\Widgets\Widget; @@ -39,52 +37,20 @@ protected function getViewData(): array return $empty; } - $assignment = BaselineTenantAssignment::query() - ->where('tenant_id', $tenant->getKey()) - ->with('baselineProfile') - ->first(); + $stats = BaselineCompareStats::forWidget($tenant); - if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) { + if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) { return $empty; } - $profile = $assignment->baselineProfile; - $scopeKey = 'baseline_profile:'.$profile->getKey(); - - $findingsQuery = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('source', 'baseline.compare') - ->where('scope_key', $scopeKey) - ->where('status', Finding::STATUS_NEW); - - $findingsCount = (int) (clone $findingsQuery)->count(); - $highCount = (int) (clone $findingsQuery) - ->where('severity', Finding::SEVERITY_HIGH) - ->count(); - $mediumCount = (int) (clone $findingsQuery) - ->where('severity', Finding::SEVERITY_MEDIUM) - ->count(); - $lowCount = (int) (clone $findingsQuery) - ->where('severity', Finding::SEVERITY_LOW) - ->count(); - - $latestRun = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('type', 'baseline_compare') - ->where('context->baseline_profile_id', (string) $profile->getKey()) - ->whereNotNull('completed_at') - ->latest('completed_at') - ->first(); - return [ 'hasAssignment' => true, - 'profileName' => (string) $profile->name, - 'findingsCount' => $findingsCount, - 'highCount' => $highCount, - 'mediumCount' => $mediumCount, - 'lowCount' => $lowCount, - 'lastComparedAt' => $latestRun?->finished_at?->diffForHumans(), + 'profileName' => $stats->profileName, + 'findingsCount' => $stats->findingsCount ?? 0, + 'highCount' => $stats->severityCounts['high'] ?? 0, + 'mediumCount' => $stats->severityCounts['medium'] ?? 0, + 'lowCount' => $stats->severityCounts['low'] ?? 0, + 'lastComparedAt' => $stats->lastComparedHuman, 'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant), ]; } diff --git a/app/Jobs/Alerts/EvaluateAlertsJob.php b/app/Jobs/Alerts/EvaluateAlertsJob.php index 56f39f4..47d4c0c 100644 --- a/app/Jobs/Alerts/EvaluateAlertsJob.php +++ b/app/Jobs/Alerts/EvaluateAlertsJob.php @@ -10,14 +10,17 @@ use App\Models\Workspace; use App\Services\Alerts\AlertDispatchService; use App\Services\OperationRunService; +use App\Services\Settings\SettingsResolver; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use Carbon\CarbonImmutable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Arr; use Throwable; class EvaluateAlertsJob implements ShouldQueue @@ -58,8 +61,10 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic try { $events = [ ...$this->highDriftEvents((int) $workspace->getKey(), $windowStart), + ...$this->baselineHighDriftEvents((int) $workspace->getKey(), $windowStart), ...$this->slaDueEvents((int) $workspace->getKey(), $windowStart), ...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart), + ...$this->baselineCompareFailedEvents((int) $workspace->getKey(), $windowStart), ...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart), ...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart), ]; @@ -206,6 +211,84 @@ private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart) return $events; } + /** + * @return array> + */ + 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> */ @@ -236,7 +319,51 @@ private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowSt 'severity' => 'high', 'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(), 'title' => 'Drift compare failed', - 'body' => $this->firstFailureMessage($failedRun), + 'body' => $this->firstFailureMessage($failedRun, 'A drift compare operation run failed.'), + 'metadata' => [ + 'operation_run_id' => (int) $failedRun->getKey(), + ], + ]; + } + + return $events; + } + + /** + * @return array> + */ + private function baselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array + { + $failedRuns = OperationRun::query() + ->where('workspace_id', $workspaceId) + ->whereNotNull('tenant_id') + ->where('type', OperationRunType::BaselineCompare->value) + ->where('status', OperationRunStatus::Completed->value) + ->whereIn('outcome', [ + OperationRunOutcome::Failed->value, + OperationRunOutcome::PartiallySucceeded->value, + ]) + ->whereNotNull('completed_at') + ->where('completed_at', '>', $windowStart) + ->orderBy('id') + ->get(); + + $events = []; + + foreach ($failedRuns as $failedRun) { + $tenantId = (int) ($failedRun->tenant_id ?? 0); + + if ($tenantId <= 0) { + continue; + } + + $events[] = [ + 'event_type' => AlertRule::EVENT_BASELINE_COMPARE_FAILED, + 'tenant_id' => $tenantId, + 'severity' => 'high', + 'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(), + 'title' => 'Baseline compare failed', + 'body' => $this->firstFailureMessage($failedRun, 'A baseline compare operation run failed.'), 'metadata' => [ 'operation_run_id' => (int) $failedRun->getKey(), ], @@ -370,7 +497,7 @@ private function slaDueEvents(int $workspaceId, CarbonImmutable $windowStart): a return $events; } - private function firstFailureMessage(OperationRun $run): string + private function firstFailureMessage(OperationRun $run, string $defaultMessage): string { $failures = is_array($run->failure_summary) ? $run->failure_summary : []; @@ -386,7 +513,52 @@ private function firstFailureMessage(OperationRun $run): string } } - return 'A drift compare operation run failed.'; + return $defaultMessage; + } + + private function baselineAlertMinimumSeverity(int $workspaceId): string + { + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return Finding::SEVERITY_HIGH; + } + + try { + $value = app(SettingsResolver::class)->resolveValue( + workspace: $workspace, + domain: 'baseline', + key: 'alert_min_severity', + ); + } catch (\InvalidArgumentException) { + return Finding::SEVERITY_HIGH; + } + + $severity = strtolower(trim((string) $value)); + + return array_key_exists($severity, $this->severityRank()) + ? $severity + : Finding::SEVERITY_HIGH; + } + + private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool + { + $rank = $this->severityRank(); + + return ($rank[$eventSeverity] ?? 0) >= ($rank[$minimumSeverity] ?? 0); + } + + /** + * @return array + */ + 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 diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index 2d0cb1b..eaa2d2f 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -12,13 +12,18 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; +use App\Services\Baselines\BaselineAutoCloseService; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Services\Settings\SettingsResolver; use App\Support\Baselines\BaselineScope; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; +use Carbon\CarbonImmutable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -51,7 +56,12 @@ public function handle( BaselineSnapshotIdentity $snapshotIdentity, AuditLogger $auditLogger, OperationRunService $operationRunService, + ?SettingsResolver $settingsResolver = null, + ?BaselineAutoCloseService $baselineAutoCloseService = null, ): void { + $settingsResolver ??= app(SettingsResolver::class); + $baselineAutoCloseService ??= app(BaselineAutoCloseService::class); + if (! $this->operationRun instanceof OperationRun) { $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.')); @@ -74,21 +84,32 @@ public function handle( throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found."); } + $workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first(); + + if (! $workspace instanceof Workspace) { + throw new RuntimeException("Workspace #{$tenant->workspace_id} not found."); + } + $initiator = $this->operationRun->user_id ? User::query()->find($this->operationRun->user_id) : null; $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); - $scopeKey = 'baseline_profile:' . $profile->getKey(); + $scopeKey = 'baseline_profile:'.$profile->getKey(); $this->auditStarted($auditLogger, $tenant, $profile, $initiator); - $baselineItems = $this->loadBaselineItems($snapshotId); - $currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity); + $baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope); + $latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant); + $currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId); - $driftResults = $this->computeDrift($baselineItems, $currentItems); + $driftResults = $this->computeDrift( + $baselineItems, + $currentItems, + $this->resolveSeverityMapping($workspace, $settingsResolver), + ); - $upsertedCount = $this->upsertFindings( + $upsertResult = $this->upsertFindings( $driftHasher, $tenant, $profile, @@ -101,11 +122,14 @@ public function handle( $summaryCounts = [ 'total' => count($driftResults), 'processed' => count($driftResults), - 'succeeded' => $upsertedCount, - 'failed' => count($driftResults) - $upsertedCount, + 'succeeded' => (int) $upsertResult['processed_count'], + 'failed' => 0, 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, + 'findings_created' => (int) $upsertResult['created_count'], + 'findings_reopened' => (int) $upsertResult['reopened_count'], + 'findings_unchanged' => (int) $upsertResult['unchanged_count'], ]; $operationRunService->updateRun( @@ -115,10 +139,31 @@ public function handle( summaryCounts: $summaryCounts, ); + $resolvedCount = 0; + + if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) { + $resolvedCount = $baselineAutoCloseService->resolveStaleFindings( + tenant: $tenant, + baselineProfileId: (int) $profile->getKey(), + seenFingerprints: $upsertResult['seen_fingerprints'], + currentOperationRunId: (int) $this->operationRun->getKey(), + ); + + $summaryCounts['findings_resolved'] = $resolvedCount; + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: $summaryCounts, + ); + } + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext['result'] = [ 'findings_total' => count($driftResults), - 'findings_upserted' => $upsertedCount, + 'findings_upserted' => (int) $upsertResult['processed_count'], + 'findings_resolved' => $resolvedCount, 'severity_breakdown' => $severityBreakdown, ]; $this->operationRun->update(['context' => $updatedContext]); @@ -131,16 +176,22 @@ public function handle( * * @return array}> */ - private function loadBaselineItems(int $snapshotId): array + private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array { $items = []; - BaselineSnapshotItem::query() - ->where('baseline_snapshot_id', $snapshotId) + $query = BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', $snapshotId); + + if (! $scope->isEmpty()) { + $query->whereIn('policy_type', $scope->policyTypes); + } + + $query ->orderBy('id') ->chunk(500, function ($snapshotItems) use (&$items): void { foreach ($snapshotItems as $item) { - $key = $item->policy_type . '|' . $item->subject_external_id; + $key = $item->policy_type.'|'.$item->subject_external_id; $items[$key] = [ 'subject_type' => (string) $item->subject_type, 'subject_external_id' => (string) $item->subject_external_id, @@ -163,10 +214,15 @@ private function loadCurrentInventory( Tenant $tenant, BaselineScope $scope, BaselineSnapshotIdentity $snapshotIdentity, + ?int $latestInventorySyncRunId = null, ): array { $query = InventoryItem::query() ->where('tenant_id', $tenant->getKey()); + if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) { + $query->where('last_seen_operation_run_id', $latestInventorySyncRunId); + } + if (! $scope->isEmpty()) { $query->whereIn('policy_type', $scope->policyTypes); } @@ -180,7 +236,7 @@ private function loadCurrentInventory( $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; $currentHash = $snapshotIdentity->hashItemContent($metaJsonb); - $key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id; + $key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id; $items[$key] = [ 'subject_external_id' => (string) $inventoryItem->external_id, 'policy_type' => (string) $inventoryItem->policy_type, @@ -197,14 +253,30 @@ private function loadCurrentInventory( return $items; } + private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int + { + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::InventorySync->value) + ->where('status', OperationRunStatus::Completed->value) + ->orderByDesc('completed_at') + ->orderByDesc('id') + ->first(['id']); + + $runId = $run?->getKey(); + + return is_numeric($runId) ? (int) $runId : null; + } + /** * Compare baseline items vs current inventory and produce drift results. * * @param array}> $baselineItems * @param array}> $currentItems + * @param array $severityMapping * @return array}> */ - private function computeDrift(array $baselineItems, array $currentItems): array + private function computeDrift(array $baselineItems, array $currentItems, array $severityMapping): array { $drift = []; @@ -212,7 +284,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array if (! array_key_exists($key, $currentItems)) { $drift[] = [ 'change_type' => 'missing_policy', - 'severity' => Finding::SEVERITY_HIGH, + 'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'), 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $baselineItem['subject_external_id'], 'policy_type' => $baselineItem['policy_type'], @@ -233,7 +305,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) { $drift[] = [ 'change_type' => 'different_version', - 'severity' => Finding::SEVERITY_MEDIUM, + 'severity' => $this->severityForChangeType($severityMapping, 'different_version'), 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $baselineItem['subject_external_id'], 'policy_type' => $baselineItem['policy_type'], @@ -254,7 +326,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array if (! array_key_exists($key, $baselineItems)) { $drift[] = [ 'change_type' => 'unexpected_policy', - 'severity' => Finding::SEVERITY_LOW, + 'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'), 'subject_type' => 'policy', 'subject_external_id' => $currentItem['subject_external_id'], 'policy_type' => $currentItem['policy_type'], @@ -276,6 +348,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array * Upsert drift findings using stable fingerprints. * * @param array}> $driftResults + * @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array} */ private function upsertFindings( DriftHasher $driftHasher, @@ -283,9 +356,14 @@ private function upsertFindings( BaselineProfile $profile, string $scopeKey, array $driftResults, - ): int { - $upsertedCount = 0; + ): array { $tenantId = (int) $tenant->getKey(); + $observedAt = CarbonImmutable::now(); + $processedCount = 0; + $createdCount = 0; + $reopenedCount = 0; + $unchangedCount = 0; + $seenFingerprints = []; foreach ($driftResults as $driftItem) { $fingerprint = $driftHasher->fingerprint( @@ -298,29 +376,99 @@ private function upsertFindings( currentHash: $driftItem['current_hash'], ); - Finding::query()->updateOrCreate( - [ - 'tenant_id' => $tenantId, - 'fingerprint' => $fingerprint, - ], - [ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'source' => 'baseline.compare', - 'scope_key' => $scopeKey, - 'subject_type' => $driftItem['subject_type'], - 'subject_external_id' => $driftItem['subject_external_id'], - 'severity' => $driftItem['severity'], - 'status' => Finding::STATUS_NEW, - 'evidence_jsonb' => $driftItem['evidence'], - 'baseline_operation_run_id' => null, - 'current_operation_run_id' => (int) $this->operationRun->getKey(), - ], - ); + $seenFingerprints[] = $fingerprint; - $upsertedCount++; + $finding = Finding::query() + ->where('tenant_id', $tenantId) + ->where('fingerprint', $fingerprint) + ->first(); + + $isNewFinding = ! $finding instanceof Finding; + + if ($isNewFinding) { + $finding = new Finding; + } else { + $this->observeFinding( + finding: $finding, + observedAt: $observedAt, + currentOperationRunId: (int) $this->operationRun->getKey(), + ); + } + + $finding->forceFill([ + 'tenant_id' => $tenantId, + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'source' => 'baseline.compare', + 'scope_key' => $scopeKey, + 'subject_type' => $driftItem['subject_type'], + 'subject_external_id' => $driftItem['subject_external_id'], + 'severity' => $driftItem['severity'], + 'fingerprint' => $fingerprint, + 'evidence_jsonb' => $driftItem['evidence'], + 'baseline_operation_run_id' => null, + 'current_operation_run_id' => (int) $this->operationRun->getKey(), + ]); + + if ($isNewFinding) { + $finding->forceFill([ + 'status' => Finding::STATUS_NEW, + 'reopened_at' => null, + 'resolved_at' => null, + 'resolved_reason' => null, + 'acknowledged_at' => null, + 'acknowledged_by_user_id' => null, + 'first_seen_at' => $observedAt, + 'last_seen_at' => $observedAt, + 'times_seen' => 1, + ]); + + $createdCount++; + } elseif (Finding::isTerminalStatus($finding->status)) { + $finding->forceFill([ + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => now(), + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => null, + 'closed_reason' => null, + 'closed_by_user_id' => null, + ]); + + $reopenedCount++; + } else { + $unchangedCount++; + } + + $finding->save(); + $processedCount++; } - return $upsertedCount; + return [ + 'processed_count' => $processedCount, + 'created_count' => $createdCount, + 'reopened_count' => $reopenedCount, + 'unchanged_count' => $unchangedCount, + 'seen_fingerprints' => array_values(array_unique($seenFingerprints)), + ]; + } + + private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void + { + if ($finding->first_seen_at === null) { + $finding->first_seen_at = $observedAt; + } + + if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) { + $finding->last_seen_at = $observedAt; + } + + $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; + + if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) { + $finding->times_seen = max(0, $timesSeen) + 1; + } elseif ($timesSeen < 1) { + $finding->times_seen = 1; + } } /** @@ -339,6 +487,44 @@ private function countBySeverity(array $driftResults): array return $counts; } + /** + * @return array + */ + 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 $severityMapping + */ + private function severityForChangeType(array $severityMapping, string $changeType): string + { + $severity = $severityMapping[$changeType] ?? null; + + if (! is_string($severity) || $severity === '') { + return match ($changeType) { + 'missing_policy' => Finding::SEVERITY_HIGH, + 'different_version' => Finding::SEVERITY_MEDIUM, + default => Finding::SEVERITY_LOW, + }; + } + + return $severity; + } + private function auditStarted( AuditLogger $auditLogger, Tenant $tenant, diff --git a/app/Models/AlertRule.php b/app/Models/AlertRule.php index 7a095d9..fb97e85 100644 --- a/app/Models/AlertRule.php +++ b/app/Models/AlertRule.php @@ -18,6 +18,10 @@ class AlertRule extends Model public const string EVENT_COMPARE_FAILED = 'compare_failed'; + public const string EVENT_BASELINE_HIGH_DRIFT = 'baseline_high_drift'; + + public const string EVENT_BASELINE_COMPARE_FAILED = 'baseline_compare_failed'; + public const string EVENT_SLA_DUE = 'sla_due'; public const string EVENT_PERMISSION_MISSING = 'permission_missing'; diff --git a/app/Services/Baselines/BaselineAutoCloseService.php b/app/Services/Baselines/BaselineAutoCloseService.php new file mode 100644 index 0000000..3773edc --- /dev/null +++ b/app/Services/Baselines/BaselineAutoCloseService.php @@ -0,0 +1,121 @@ +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 $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(); + } +} diff --git a/app/Services/Baselines/BaselineCaptureService.php b/app/Services/Baselines/BaselineCaptureService.php index f596176..8877202 100644 --- a/app/Services/Baselines/BaselineCaptureService.php +++ b/app/Services/Baselines/BaselineCaptureService.php @@ -12,6 +12,7 @@ use App\Services\OperationRunService; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; +use App\Support\OperationRunType; final class BaselineCaptureService { @@ -45,7 +46,7 @@ public function startCapture( $run = $this->runs->ensureRunWithIdentity( tenant: $sourceTenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: [ 'baseline_profile_id' => (int) $profile->getKey(), ], diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php index a4cc940..efa951b 100644 --- a/app/Services/Baselines/BaselineCompareService.php +++ b/app/Services/Baselines/BaselineCompareService.php @@ -13,6 +13,7 @@ use App\Services\OperationRunService; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; +use App\Support\OperationRunType; final class BaselineCompareService { @@ -67,7 +68,7 @@ public function startCompare( $run = $this->runs->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_compare', + type: OperationRunType::BaselineCompare->value, identityInputs: [ 'baseline_profile_id' => (int) $profile->getKey(), ], diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php new file mode 100644 index 0000000..c6ceca2 --- /dev/null +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -0,0 +1,271 @@ + $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, + ); + } +} diff --git a/app/Support/OperationRunType.php b/app/Support/OperationRunType.php index b892195..34185d8 100644 --- a/app/Support/OperationRunType.php +++ b/app/Support/OperationRunType.php @@ -4,6 +4,8 @@ enum OperationRunType: string { + case BaselineCapture = 'baseline_capture'; + case BaselineCompare = 'baseline_compare'; case InventorySync = 'inventory_sync'; case PolicySync = 'policy.sync'; case PolicySyncOne = 'policy.sync_one'; diff --git a/app/Support/Settings/SettingsRegistry.php b/app/Support/Settings/SettingsRegistry.php index ec84937..960c27f 100644 --- a/app/Support/Settings/SettingsRegistry.php +++ b/app/Support/Settings/SettingsRegistry.php @@ -80,6 +80,74 @@ static function (string $attribute, mixed $value, \Closure $fail): void { normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value), )); + $this->register(new SettingDefinition( + domain: 'baseline', + key: 'severity_mapping', + type: 'json', + systemDefault: self::defaultBaselineSeverityMapping(), + rules: [ + 'required', + 'array', + static function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_array($value)) { + $fail('The baseline severity mapping must be a JSON object.'); + + return; + } + + $supportedKeys = array_fill_keys(self::supportedBaselineChangeTypes(), true); + + foreach ($value as $changeType => $severity) { + if (! is_string($changeType) || ! isset($supportedKeys[$changeType])) { + $fail(sprintf( + 'Baseline severity mapping keys must be one of: %s.', + implode(', ', self::supportedBaselineChangeTypes()), + )); + + return; + } + + if (! is_string($severity)) { + $fail(sprintf('Severity for "%s" must be a string.', $changeType)); + + return; + } + + $normalizedSeverity = strtolower($severity); + + if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) { + $fail(sprintf( + 'Severity for "%s" must be one of: %s.', + $changeType, + implode(', ', self::supportedFindingSeverities()), + )); + + return; + } + } + }, + ], + normalizer: static fn (mixed $value): array => self::normalizeBaselineSeverityMapping($value), + )); + + $this->register(new SettingDefinition( + domain: 'baseline', + key: 'alert_min_severity', + type: 'string', + systemDefault: Finding::SEVERITY_HIGH, + rules: ['required', 'string', 'in:low,medium,high,critical'], + normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)), + )); + + $this->register(new SettingDefinition( + domain: 'baseline', + key: 'auto_close_enabled', + type: 'bool', + systemDefault: true, + rules: ['required', 'boolean'], + normalizer: static fn (mixed $value): bool => filter_var($value, FILTER_VALIDATE_BOOL), + )); + $this->register(new SettingDefinition( domain: 'findings', key: 'sla_days', @@ -223,6 +291,61 @@ private static function normalizeSeverityMapping(mixed $value): array return $normalized; } + /** + * @return array + */ + private static function supportedBaselineChangeTypes(): array + { + return [ + 'different_version', + 'missing_policy', + 'unexpected_policy', + ]; + } + + /** + * @return array + */ + private static function defaultBaselineSeverityMapping(): array + { + return [ + 'different_version' => Finding::SEVERITY_MEDIUM, + 'missing_policy' => Finding::SEVERITY_HIGH, + 'unexpected_policy' => Finding::SEVERITY_LOW, + ]; + } + + /** + * @return array + */ + 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 */ diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 6edc020..279c08e 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -1,6 +1,11 @@ + {{-- Auto-refresh while comparison is running --}} + @if ($state === 'comparing') +
+ @endif + {{-- Row 1: Stats Overview --}} - @if (in_array($state, ['ready', 'idle', 'comparing'])) + @if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
{{-- Stat: Assigned Baseline --}} @@ -19,9 +24,13 @@
Total Findings
-
- {{ $findingsCount ?? 0 }} -
+ @if ($state === 'failed') +
Error
+ @else +
+ {{ $findingsCount ?? 0 }} +
+ @endif @if ($state === 'comparing')
@@ -37,7 +46,7 @@
Last Compared
-
+
{{ $lastComparedAt ?? 'Never' }}
@if ($this->getRunUrl()) @@ -50,6 +59,37 @@
@endif + {{-- Failed run banner --}} + @if ($state === 'failed') +
+
+ +
+
+ Comparison Failed +
+
+ {{ $failureReason ?? 'The last baseline comparison failed. Review the run details or retry.' }} +
+
+ @if ($this->getRunUrl()) + + View failed run + + @endif +
+
+
+
+ @endif + {{-- Critical drift banner --}} @if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
diff --git a/specs/115-baseline-operability-alerts/checklists/requirements.md b/specs/115-baseline-operability-alerts/checklists/requirements.md new file mode 100644 index 0000000..3e714c9 --- /dev/null +++ b/specs/115-baseline-operability-alerts/checklists/requirements.md @@ -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. \ No newline at end of file diff --git a/specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml b/specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml new file mode 100644 index 0000000..6feae65 --- /dev/null +++ b/specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml @@ -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 diff --git a/specs/115-baseline-operability-alerts/data-model.md b/specs/115-baseline-operability-alerts/data-model.md new file mode 100644 index 0000000..4bac86d --- /dev/null +++ b/specs/115-baseline-operability-alerts/data-model.md @@ -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. diff --git a/specs/115-baseline-operability-alerts/plan.md b/specs/115-baseline-operability-alerts/plan.md new file mode 100644 index 0000000..aff19b3 --- /dev/null +++ b/specs/115-baseline-operability-alerts/plan.md @@ -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] | diff --git a/specs/115-baseline-operability-alerts/quickstart.md b/specs/115-baseline-operability-alerts/quickstart.md new file mode 100644 index 0000000..b35aa54 --- /dev/null +++ b/specs/115-baseline-operability-alerts/quickstart.md @@ -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/`. diff --git a/specs/115-baseline-operability-alerts/research.md b/specs/115-baseline-operability-alerts/research.md new file mode 100644 index 0000000..2dac4d4 --- /dev/null +++ b/specs/115-baseline-operability-alerts/research.md @@ -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`. diff --git a/specs/115-baseline-operability-alerts/spec.md b/specs/115-baseline-operability-alerts/spec.md new file mode 100644 index 0000000..e6c7410 --- /dev/null +++ b/specs/115-baseline-operability-alerts/spec.md @@ -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. \ No newline at end of file diff --git a/specs/115-baseline-operability-alerts/tasks.md b/specs/115-baseline-operability-alerts/tasks.md new file mode 100644 index 0000000..3327a25 --- /dev/null +++ b/specs/115-baseline-operability-alerts/tasks.md @@ -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. diff --git a/tests/Feature/Alerts/BaselineCompareFailedAlertTest.php b/tests/Feature/Alerts/BaselineCompareFailedAlertTest.php new file mode 100644 index 0000000..e918e38 --- /dev/null +++ b/tests/Feature/Alerts/BaselineCompareFailedAlertTest.php @@ -0,0 +1,185 @@ +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> + */ +function invokeBaselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array +{ + $job = new EvaluateAlertsJob($workspaceId); + $reflection = new ReflectionMethod($job, 'baselineCompareFailedEvents'); + + /** @var array> $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); +}); diff --git a/tests/Feature/Alerts/BaselineHighDriftAlertTest.php b/tests/Feature/Alerts/BaselineHighDriftAlertTest.php new file mode 100644 index 0000000..f6b02f3 --- /dev/null +++ b/tests/Feature/Alerts/BaselineHighDriftAlertTest.php @@ -0,0 +1,211 @@ +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> + */ +function invokeBaselineHighDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array +{ + $job = new EvaluateAlertsJob($workspaceId); + $reflection = new ReflectionMethod($job, 'baselineHighDriftEvents'); + + /** @var array> $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); +}); diff --git a/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/tests/Feature/Baselines/BaselineCompareFindingsTest.php index bbf8704..63b5bd1 100644 --- a/tests/Feature/Baselines/BaselineCompareFindingsTest.php +++ b/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -4,14 +4,20 @@ use App\Models\BaselineProfile; use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshotItem; -use App\Models\BaselineTenantAssignment; use App\Models\Finding; use App\Models\InventoryItem; use App\Models\OperationRun; +use App\Models\WorkspaceSetting; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Services\Settings\SettingsResolver; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\OperationRunType; + +use function Pest\Laravel\mock; // --- T041: Compare idempotent finding fingerprint tests --- @@ -69,7 +75,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_compare', + type: OperationRunType::BaselineCompare->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -91,7 +97,7 @@ expect($run->status)->toBe('completed'); expect($run->outcome)->toBe('succeeded'); - $scopeKey = 'baseline_profile:' . $profile->getKey(); + $scopeKey = 'baseline_profile:'.$profile->getKey(); $findings = Finding::query() ->where('tenant_id', $tenant->getKey()) @@ -102,12 +108,203 @@ // policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings expect($findings->count())->toBe(3); + // Lifecycle v2 fields must be initialized for new findings. + expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count()); + expect($findings->pluck('last_seen_at')->filter()->count())->toBe($findings->count()); + expect($findings->pluck('times_seen')->every(fn ($value) => (int) $value === 1))->toBeTrue(); + $severities = $findings->pluck('severity')->sort()->values()->all(); expect($severities)->toContain(Finding::SEVERITY_HIGH); expect($severities)->toContain(Finding::SEVERITY_MEDIUM); expect($severities)->toContain(Finding::SEVERITY_LOW); }); +it('does not fail compare when baseline severity mapping setting is missing', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-a-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'content-a'), + 'meta_jsonb' => ['display_name' => 'Policy A'], + ]); + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-b-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'content-b'), + 'meta_jsonb' => ['display_name' => 'Policy B'], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-a-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['different_content' => true], + 'display_name' => 'Policy A modified', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $settingsResolver = mock(SettingsResolver::class); + $settingsResolver + ->shouldReceive('resolveValue') + ->andThrow(new InvalidArgumentException('Unknown setting key: baseline.severity_mapping')); + + $baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver); + + (new CompareBaselineToTenantJob($run))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + settingsResolver: $settingsResolver, + baselineAutoCloseService: $baselineAutoCloseService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->get(); + + // policyB missing (high), policyA different (medium) = 2 findings. + expect($findings->count())->toBe(2); + expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_HIGH); + expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_MEDIUM); +}); + +it('treats inventory items not seen in latest inventory sync as missing', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['settingsCatalogPolicy']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'settings-catalog-policy-uuid', + 'policy_type' => 'settingsCatalogPolicy', + 'baseline_hash' => hash('sha256', 'content-a'), + 'meta_jsonb' => ['display_name' => 'Settings Catalog A'], + ]); + + $olderInventoryRun = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinutes(5), + 'context' => [ + 'policy_types' => ['settingsCatalogPolicy'], + 'selection_hash' => 'older', + ], + ]); + + // Inventory item exists, but it was NOT observed in the latest sync run. + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'settings-catalog-policy-uuid', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog A', + 'meta_jsonb' => ['etag' => 'abc'], + 'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(), + 'last_seen_at' => now()->subMinutes(5), + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'policy_types' => ['settingsCatalogPolicy'], + 'selection_hash' => 'latest', + ], + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['settingsCatalogPolicy']], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->get(); + + expect($findings->count())->toBe(1); + expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy'); +}); + it('produces idempotent fingerprints so re-running compare updates existing findings', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -138,7 +335,7 @@ // First run $run1 = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_compare', + type: OperationRunType::BaselineCompare->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -156,7 +353,7 @@ $opService, ); - $scopeKey = 'baseline_profile:' . $profile->getKey(); + $scopeKey = 'baseline_profile:'.$profile->getKey(); $countAfterFirst = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('source', 'baseline.compare') @@ -171,7 +368,7 @@ $run2 = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_compare', + type: OperationRunType::BaselineCompare->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -241,7 +438,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_compare', + type: OperationRunType::BaselineCompare->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -274,6 +471,96 @@ expect($findings)->toBe(0); }); +it('does not create missing_policy findings for baseline snapshot items outside effective scope', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + $metaContent = ['policy_key' => 'value123']; + $driftHasher = app(DriftHasher::class); + $contentHash = $driftHasher->hashNormalized($metaContent); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'matching-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $contentHash, + 'meta_jsonb' => ['display_name' => 'Matching Policy'], + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'foundation-uuid', + 'policy_type' => 'notificationMessageTemplate', + 'baseline_hash' => hash('sha256', 'foundation-content'), + 'meta_jsonb' => ['display_name' => 'Foundation Template'], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'matching-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => $metaContent, + 'display_name' => 'Matching Policy', + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'foundation-uuid', + 'policy_type' => 'notificationMessageTemplate', + 'meta_jsonb' => ['some' => 'value'], + 'display_name' => 'Foundation Template', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run = $run->fresh(); + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? -1))->toBe(0); + + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + expect( + Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->count() + )->toBe(0); +}); + // --- T042: Summary counts severity breakdown tests --- it('writes severity breakdown in summary_counts', function () { @@ -330,7 +617,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_compare', + type: OperationRunType::BaselineCompare->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -384,7 +671,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_compare', + type: OperationRunType::BaselineCompare->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -412,3 +699,260 @@ expect((int) $result['findings_total'])->toBe(1); expect((int) $result['findings_upserted'])->toBe(1); }); + +it('reopens a previously resolved baseline finding when the same drift reappears', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-reappears', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-content'), + 'meta_jsonb' => ['display_name' => 'Policy Reappears'], + ]); + + $operationRuns = app(OperationRunService::class); + + $firstRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($firstRun))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $finding = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->sole(); + + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => now()->subMinute(), + 'resolved_reason' => 'manually_resolved', + ])->save(); + + $firstRun->update(['completed_at' => now()->subMinute()]); + + $secondRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($secondRun))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $finding->refresh(); + expect($finding->status)->toBe(Finding::STATUS_REOPENED); + expect($finding->resolved_at)->toBeNull(); + expect($finding->resolved_reason)->toBeNull(); + expect($finding->reopened_at)->not->toBeNull(); +}); + +it('preserves an existing open workflow status when the same baseline drift is seen again', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'triaged-policy', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-content'), + 'meta_jsonb' => ['display_name' => 'Triaged Policy'], + ]); + + $operationRuns = app(OperationRunService::class); + + $firstRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($firstRun))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $finding = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->sole(); + + $finding->forceFill([ + 'status' => Finding::STATUS_TRIAGED, + 'triaged_at' => now()->subMinute(), + ])->save(); + + $firstRun->update(['completed_at' => now()->subMinute()]); + + $secondRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($secondRun))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $finding->refresh(); + expect($finding->status)->toBe(Finding::STATUS_TRIAGED); + expect($finding->current_operation_run_id)->toBe((int) $secondRun->getKey()); +}); + +it('applies the workspace baseline severity mapping by change type', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'domain' => 'baseline', + 'key' => 'severity_mapping', + 'value' => [ + 'missing_policy' => Finding::SEVERITY_CRITICAL, + 'different_version' => Finding::SEVERITY_LOW, + 'unexpected_policy' => Finding::SEVERITY_MEDIUM, + ], + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'missing-policy', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-a'), + 'meta_jsonb' => ['display_name' => 'Missing Policy'], + ]); + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'different-policy', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-b'), + 'meta_jsonb' => ['display_name' => 'Different Policy'], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'different-policy', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['different_content' => true], + 'display_name' => 'Different Policy', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'unexpected-policy', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['unexpected' => true], + 'display_name' => 'Unexpected Policy', + ]); + + $operationRuns = app(OperationRunService::class); + $run = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run))->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $operationRuns, + ); + + $findings = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->get() + ->keyBy(fn (Finding $finding): string => (string) data_get($finding->evidence_jsonb, 'change_type')); + + expect($findings['missing_policy']->severity)->toBe(Finding::SEVERITY_CRITICAL); + expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW); + expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM); +}); diff --git a/tests/Feature/Baselines/BaselineComparePreconditionsTest.php b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php index b8df465..0ceb0c3 100644 --- a/tests/Feature/Baselines/BaselineComparePreconditionsTest.php +++ b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Services\Baselines\BaselineCompareService; use App\Support\Baselines\BaselineReasonCodes; +use App\Support\OperationRunType; use Illuminate\Support\Facades\Queue; // --- T040: Compare precondition 422 tests --- @@ -23,7 +24,7 @@ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('rejects compare when assigned profile is in draft status', function () { @@ -49,7 +50,7 @@ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('rejects compare when assigned profile is archived [EC-001]', function () { @@ -74,6 +75,7 @@ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('rejects compare when profile has no active snapshot', function () { @@ -99,6 +101,7 @@ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT); Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('enqueues compare successfully when all preconditions are met', function () { @@ -132,7 +135,7 @@ /** @var OperationRun $run */ $run = $result['run']; - expect($run->type)->toBe('baseline_compare'); + expect($run->type)->toBe(OperationRunType::BaselineCompare->value); expect($run->status)->toBe('queued'); $context = is_array($run->context) ? $run->context : []; @@ -174,5 +177,5 @@ expect($result1['ok'])->toBeTrue(); expect($result2['ok'])->toBeTrue(); expect($result1['run']->getKey())->toBe($result2['run']->getKey()); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(1); }); diff --git a/tests/Feature/Baselines/BaselineCompareStatsTest.php b/tests/Feature/Baselines/BaselineCompareStatsTest.php new file mode 100644 index 0000000..4cf703d --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareStatsTest.php @@ -0,0 +1,274 @@ +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); +}); diff --git a/tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php b/tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php new file mode 100644 index 0000000..4e19620 --- /dev/null +++ b/tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php @@ -0,0 +1,188 @@ +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'); diff --git a/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php b/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php new file mode 100644 index 0000000..7989514 --- /dev/null +++ b/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php @@ -0,0 +1,36 @@ +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', 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(); +}); diff --git a/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php index 6777cde..c28dbb0 100644 --- a/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php +++ b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php @@ -53,3 +53,15 @@ expect($run)->not->toBeNull(); expect($run?->status)->toBe('queued'); }); + +it('can refresh stats without calling mount directly', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(BaselineCompareLanding::class) + ->call('refreshStats') + ->assertStatus(200); +}); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php index ae324ef..5448cc6 100644 --- a/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php @@ -47,6 +47,11 @@ function workspaceManagerUser(): array ->assertSet('data.backup_retention_keep_last_default', null) ->assertSet('data.backup_retention_min_floor', null) ->assertSet('data.drift_severity_mapping', []) + ->assertSet('data.baseline_severity_missing_policy', null) + ->assertSet('data.baseline_severity_different_version', null) + ->assertSet('data.baseline_severity_unexpected_policy', null) + ->assertSet('data.baseline_alert_min_severity', null) + ->assertSet('data.baseline_auto_close_enabled', null) ->assertSet('data.findings_sla_critical', null) ->assertSet('data.findings_sla_high', null) ->assertSet('data.findings_sla_medium', null) @@ -56,6 +61,11 @@ function workspaceManagerUser(): array ->set('data.backup_retention_keep_last_default', 55) ->set('data.backup_retention_min_floor', 12) ->set('data.drift_severity_mapping', ['drift' => 'critical']) + ->set('data.baseline_severity_missing_policy', 'critical') + ->set('data.baseline_severity_different_version', 'low') + ->set('data.baseline_severity_unexpected_policy', 'medium') + ->set('data.baseline_alert_min_severity', 'critical') + ->set('data.baseline_auto_close_enabled', '0') ->set('data.findings_sla_critical', 2) ->set('data.findings_sla_high', 5) ->set('data.findings_sla_medium', 10) @@ -66,6 +76,11 @@ function workspaceManagerUser(): array ->assertHasNoErrors() ->assertSet('data.backup_retention_keep_last_default', 55) ->assertSet('data.backup_retention_min_floor', 12) + ->assertSet('data.baseline_severity_missing_policy', 'critical') + ->assertSet('data.baseline_severity_different_version', 'low') + ->assertSet('data.baseline_severity_unexpected_policy', 'medium') + ->assertSet('data.baseline_alert_min_severity', 'critical') + ->assertSet('data.baseline_auto_close_enabled', '0') ->assertSet('data.findings_sla_critical', 2) ->assertSet('data.findings_sla_high', 5) ->assertSet('data.findings_sla_medium', 10) @@ -88,6 +103,19 @@ function workspaceManagerUser(): array expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping')) ->toBe(['drift' => 'critical']); + expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'severity_mapping')) + ->toBe([ + 'different_version' => 'low', + 'missing_policy' => 'critical', + 'unexpected_policy' => 'medium', + ]); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'alert_min_severity')) + ->toBe('critical'); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'auto_close_enabled')) + ->toBeFalse(); + expect(app(SettingsResolver::class)->resolveValue($workspace, 'findings', 'sla_days')) ->toBe([ 'critical' => 2, @@ -258,6 +286,41 @@ function workspaceManagerUser(): array ->exists())->toBeFalse(); }); +it('rejects malformed baseline settings values', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $writer = app(SettingsWriter::class); + + expect(fn () => $writer->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'baseline', + key: 'severity_mapping', + value: ['unknown_change_type' => 'high'], + ))->toThrow(ValidationException::class); + + expect(fn () => $writer->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'baseline', + key: 'severity_mapping', + value: ['missing_policy' => 'urgent'], + ))->toThrow(ValidationException::class); + + expect(fn () => $writer->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'baseline', + key: 'alert_min_severity', + value: 'urgent', + ))->toThrow(ValidationException::class); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'baseline') + ->exists())->toBeFalse(); +}); + it('saves partial findings sla days without auto-filling unset severities', function (): void { [$workspace, $user] = workspaceManagerUser(); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php index 6800c65..d3e6704 100644 --- a/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php @@ -41,6 +41,11 @@ ->assertSet('data.backup_retention_keep_last_default', 27) ->assertSet('data.backup_retention_min_floor', null) ->assertSet('data.drift_severity_mapping', []) + ->assertSet('data.baseline_severity_missing_policy', null) + ->assertSet('data.baseline_severity_different_version', null) + ->assertSet('data.baseline_severity_unexpected_policy', null) + ->assertSet('data.baseline_alert_min_severity', null) + ->assertSet('data.baseline_auto_close_enabled', null) ->assertSet('data.findings_sla_critical', null) ->assertSet('data.findings_sla_high', null) ->assertSet('data.findings_sla_medium', null) @@ -55,6 +60,12 @@ ->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content') ->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content') ->assertFormComponentActionDisabled('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content') + ->assertActionVisible(TestAction::make('reset_baseline_severity_mapping')->schemaComponent('baseline_section')) + ->assertActionDisabled(TestAction::make('reset_baseline_severity_mapping')->schemaComponent('baseline_section')) + ->assertActionVisible(TestAction::make('reset_baseline_alert_min_severity')->schemaComponent('baseline_section')) + ->assertActionDisabled(TestAction::make('reset_baseline_alert_min_severity')->schemaComponent('baseline_section')) + ->assertActionVisible(TestAction::make('reset_baseline_auto_close_enabled')->schemaComponent('baseline_section')) + ->assertActionDisabled(TestAction::make('reset_baseline_auto_close_enabled')->schemaComponent('baseline_section')) ->assertActionVisible(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section')) ->assertActionDisabled(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section')) ->assertFormComponentActionVisible('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')