From 7ac53f4cc433f6c366551cd0447772c59f2a7b8f Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 25 Feb 2026 01:48:01 +0000 Subject: [PATCH] feat(111): findings workflow + SLA settings (#135) Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation. Key changes: - Findings workflow service + SLA policy and alerting. - Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults. - New migrations, jobs, command, UI/resource updates, and comprehensive test coverage. Tests: - `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped). Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/135 --- .../TenantpilotBackfillFindingLifecycle.php | 114 +++ .../Pages/Settings/WorkspaceSettings.php | 381 ++++++++- app/Filament/Resources/AlertRuleResource.php | 1 + app/Filament/Resources/FindingResource.php | 754 ++++++++++++++++-- .../FindingResource/Pages/ListFindings.php | 205 ++++- .../FindingResource/Pages/ViewFinding.php | 11 + app/Jobs/Alerts/EvaluateAlertsJob.php | 153 +++- app/Jobs/BackfillFindingLifecycleJob.php | 385 +++++++++ app/Jobs/GenerateReviewPackJob.php | 4 +- app/Models/Finding.php | 96 +++ app/Policies/FindingPolicy.php | 68 +- app/Providers/Filament/AdminPanelProvider.php | 1 + app/Services/Auth/RoleCapabilityMap.php | 15 + app/Services/Drift/DriftFindingGenerator.php | 341 +++++--- .../EntraAdminRolesFindingGenerator.php | 170 +++- app/Services/Findings/FindingSlaPolicy.php | 52 ++ .../Findings/FindingWorkflowService.php | 380 +++++++++ .../PermissionPostureFindingGenerator.php | 167 +++- app/Services/ReviewPackService.php | 2 +- app/Services/Settings/SettingsResolver.php | 29 +- app/Support/Auth/Capabilities.php | 12 + .../Badges/Domains/FindingStatusBadge.php | 8 +- app/Support/OperationCatalog.php | 2 + app/Support/Settings/SettingsRegistry.php | 108 +++ database/factories/FindingFactory.php | 16 + ..._lifecycle_v2_fields_to_findings_table.php | 59 ++ ..._key_and_sla_indexes_to_findings_table.php | 38 + ...nforce_not_null_on_finding_seen_fields.php | 82 ++ .../filament-sidebar-store-fallback.js | 163 ++++ .../livewire-intercept-shim.blade.php | 1 + .../checklists/requirements.md | 35 + .../contracts/api-contracts.md | 122 +++ specs/111-findings-workflow-sla/data-model.md | 98 +++ specs/111-findings-workflow-sla/plan.md | 180 +++++ specs/111-findings-workflow-sla/quickstart.md | 110 +++ specs/111-findings-workflow-sla/research.md | 193 +++++ specs/111-findings-workflow-sla/spec.md | 269 +++++++ specs/111-findings-workflow-sla/tasks.md | 339 ++++++++ .../Alerts/PermissionMissingAlertTest.php | 27 + tests/Feature/Alerts/SlaDueAlertTest.php | 196 +++++ .../DriftAcknowledgeAuthorizationTest.php | 6 +- tests/Feature/Drift/DriftAcknowledgeTest.php | 9 +- ...AcknowledgeAllMatchingConfirmationTest.php | 10 +- .../DriftBulkAcknowledgeAllMatchingTest.php | 10 +- .../DriftBulkAcknowledgeAuthorizationTest.php | 16 +- .../Drift/DriftBulkAcknowledgeTest.php | 11 +- .../AdminRolesAlertIntegrationTest.php | 31 +- .../EntraAdminRolesFindingGeneratorTest.php | 39 +- .../Findings/DriftStaleAutoResolveTest.php | 136 ++++ .../Feature/Findings/FindingAuditLogTest.php | 81 ++ .../Feature/Findings/FindingBackfillTest.php | 134 ++++ .../Findings/FindingBulkActionsTest.php | 155 ++++ tests/Feature/Findings/FindingRbacTest.php | 66 ++ .../Findings/FindingRecurrenceTest.php | 341 ++++++++ .../FindingWorkflowRowActionsTest.php | 126 +++ .../FindingWorkflowViewActionsTest.php | 81 ++ .../Findings/FindingsListDefaultsTest.php | 51 ++ .../Findings/FindingsListFiltersTest.php | 93 +++ tests/Feature/Models/FindingResolvedTest.php | 27 +- .../PermissionPostureFindingGeneratorTest.php | 47 +- .../ReviewPack/ReviewPackPruneTest.php | 5 +- .../WorkspaceSettingsManageTest.php | 159 +++- .../WorkspaceSettingsViewOnlyTest.php | 9 +- .../Support/Badges/FindingBadgeTest.php | 16 +- tests/Unit/Badges/FindingBadgesTest.php | 24 +- .../Findings/FindingWorkflowServiceTest.php | 105 +++ .../Settings/FindingsSlaDaysSettingTest.php | 75 ++ 67 files changed, 6937 insertions(+), 313 deletions(-) create mode 100644 app/Console/Commands/TenantpilotBackfillFindingLifecycle.php create mode 100644 app/Jobs/BackfillFindingLifecycleJob.php create mode 100644 app/Services/Findings/FindingSlaPolicy.php create mode 100644 app/Services/Findings/FindingWorkflowService.php create mode 100644 database/migrations/2026_02_24_160000_add_finding_lifecycle_v2_fields_to_findings_table.php create mode 100644 database/migrations/2026_02_24_160001_add_finding_recurrence_key_and_sla_indexes_to_findings_table.php create mode 100644 database/migrations/2026_02_24_160002_enforce_not_null_on_finding_seen_fields.php create mode 100644 public/js/tenantpilot/filament-sidebar-store-fallback.js create mode 100644 specs/111-findings-workflow-sla/checklists/requirements.md create mode 100644 specs/111-findings-workflow-sla/contracts/api-contracts.md create mode 100644 specs/111-findings-workflow-sla/data-model.md create mode 100644 specs/111-findings-workflow-sla/plan.md create mode 100644 specs/111-findings-workflow-sla/quickstart.md create mode 100644 specs/111-findings-workflow-sla/research.md create mode 100644 specs/111-findings-workflow-sla/spec.md create mode 100644 specs/111-findings-workflow-sla/tasks.md create mode 100644 tests/Feature/Alerts/SlaDueAlertTest.php create mode 100644 tests/Feature/Findings/DriftStaleAutoResolveTest.php create mode 100644 tests/Feature/Findings/FindingAuditLogTest.php create mode 100644 tests/Feature/Findings/FindingBackfillTest.php create mode 100644 tests/Feature/Findings/FindingBulkActionsTest.php create mode 100644 tests/Feature/Findings/FindingRbacTest.php create mode 100644 tests/Feature/Findings/FindingRecurrenceTest.php create mode 100644 tests/Feature/Findings/FindingWorkflowRowActionsTest.php create mode 100644 tests/Feature/Findings/FindingWorkflowViewActionsTest.php create mode 100644 tests/Feature/Findings/FindingsListDefaultsTest.php create mode 100644 tests/Feature/Findings/FindingsListFiltersTest.php create mode 100644 tests/Unit/Findings/FindingWorkflowServiceTest.php create mode 100644 tests/Unit/Settings/FindingsSlaDaysSettingTest.php diff --git a/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php b/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php new file mode 100644 index 0000000..ef3a625 --- /dev/null +++ b/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php @@ -0,0 +1,114 @@ +option('tenant'))); + + if ($tenantIdentifiers === []) { + $this->error('Provide one or more tenants via --tenant={id|external_id}.'); + + return self::FAILURE; + } + + $tenants = $this->resolveTenants($tenantIdentifiers); + + if ($tenants->isEmpty()) { + $this->info('No tenants matched the provided identifiers.'); + + return self::SUCCESS; + } + + $queued = 0; + $skipped = 0; + + foreach ($tenants as $tenant) { + if (! $tenant instanceof Tenant) { + continue; + } + + $run = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: 'findings.lifecycle.backfill', + identityInputs: [ + 'tenant_id' => (int) $tenant->getKey(), + 'trigger' => 'backfill', + ], + context: [ + 'workspace_id' => (int) $tenant->workspace_id, + 'source' => 'tenantpilot:findings:backfill-lifecycle', + ], + initiator: null, + ); + + if (! $run->wasRecentlyCreated) { + $skipped++; + + continue; + } + + $operationRuns->dispatchOrFail($run, function () use ($tenant): void { + BackfillFindingLifecycleJob::dispatch( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: null, + ); + }); + + $queued++; + } + + $this->info(sprintf( + 'Queued %d backfill run(s), skipped %d duplicate run(s).', + $queued, + $skipped, + )); + + return self::SUCCESS; + } + + /** + * @param array $tenantIdentifiers + * @return \Illuminate\Support\Collection + */ + private function resolveTenants(array $tenantIdentifiers) + { + $tenantIds = []; + + foreach ($tenantIdentifiers as $identifier) { + $tenant = Tenant::query() + ->forTenant($identifier) + ->first(); + + if ($tenant instanceof Tenant) { + $tenantIds[] = (int) $tenant->getKey(); + } + } + + $tenantIds = array_values(array_unique($tenantIds)); + + if ($tenantIds === []) { + return collect(); + } + + return Tenant::query() + ->whereIn('id', $tenantIds) + ->orderBy('id') + ->get(); + } +} diff --git a/app/Filament/Pages/Settings/WorkspaceSettings.php b/app/Filament/Pages/Settings/WorkspaceSettings.php index 3395883..40261b9 100644 --- a/app/Filament/Pages/Settings/WorkspaceSettings.php +++ b/app/Filament/Pages/Settings/WorkspaceSettings.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceSetting; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsWriter; @@ -18,12 +19,13 @@ use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions\Action; -use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -51,10 +53,32 @@ class WorkspaceSettings extends Page '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'], + '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'], ]; + /** + * Fields rendered as Filament KeyValue components (array state, not JSON string). + * + * @var array + */ + private const KEYVALUE_FIELDS = [ + 'drift_severity_mapping', + ]; + + /** + * Findings SLA days are decomposed into individual form fields per severity. + * + * @var array + */ + private const SLA_SUB_FIELDS = [ + 'findings_sla_critical' => 'critical', + 'findings_sla_high' => 'high', + 'findings_sla_medium' => 'medium', + 'findings_sla_low' => 'low', + ]; + public Workspace $workspace; /** @@ -72,6 +96,13 @@ class WorkspaceSettings extends Page */ public array $resolvedSettings = []; + /** + * Per-domain "last modified" metadata: domain => {user_name, updated_at}. + * + * @var array + */ + public array $domainLastModified = []; + /** * @return array */ @@ -135,11 +166,13 @@ public function content(Schema $schema): Schema ->statePath('data') ->schema([ Section::make('Backup settings') - ->description('Workspace defaults used when a schedule has no explicit value.') + ->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.')) ->schema([ TextInput::make('backup_retention_keep_last_default') ->label('Default retention keep-last') ->placeholder('Unset (uses default)') + ->suffix('versions') + ->hint('1 – 365') ->numeric() ->integer() ->minValue(1) @@ -150,6 +183,8 @@ public function content(Schema $schema): Schema TextInput::make('backup_retention_min_floor') ->label('Minimum retention floor') ->placeholder('Unset (uses default)') + ->suffix('versions') + ->hint('1 – 365') ->numeric() ->integer() ->minValue(1) @@ -159,22 +194,79 @@ public function content(Schema $schema): Schema ->hintAction($this->makeResetAction('backup_retention_min_floor')), ]), Section::make('Drift settings') - ->description('Map finding types to severities as JSON.') + ->description($this->sectionDescription('drift', 'Map finding types to severity levels. Allowed severities: critical, high, medium, low.')) ->schema([ - Textarea::make('drift_severity_mapping') - ->label('Severity mapping (JSON object)') - ->rows(8) - ->placeholder("{\n \"drift\": \"critical\"\n}") + KeyValue::make('drift_severity_mapping') + ->label('Severity mapping') + ->keyLabel('Finding type') + ->valueLabel('Severity') + ->keyPlaceholder('e.g. drift') + ->valuePlaceholder('critical, high, medium, or low') ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping')) ->hintAction($this->makeResetAction('drift_severity_mapping')), ]), + 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.')) + ->columns(2) + ->afterHeader([ + $this->makeResetAction('findings_sla_days')->label('Reset all SLA')->size('sm'), + ]) + ->schema([ + TextInput::make('findings_sla_critical') + ->label('Critical severity') + ->placeholder('Unset (uses default)') + ->suffix('days') + ->hint('1 – 3,650') + ->numeric() + ->integer() + ->minValue(1) + ->maxValue(3650) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->slaFieldHelperText('critical')), + TextInput::make('findings_sla_high') + ->label('High severity') + ->placeholder('Unset (uses default)') + ->suffix('days') + ->hint('1 – 3,650') + ->numeric() + ->integer() + ->minValue(1) + ->maxValue(3650) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->slaFieldHelperText('high')), + TextInput::make('findings_sla_medium') + ->label('Medium severity') + ->placeholder('Unset (uses default)') + ->suffix('days') + ->hint('1 – 3,650') + ->numeric() + ->integer() + ->minValue(1) + ->maxValue(3650) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->slaFieldHelperText('medium')), + TextInput::make('findings_sla_low') + ->label('Low severity') + ->placeholder('Unset (uses default)') + ->suffix('days') + ->hint('1 – 3,650') + ->numeric() + ->integer() + ->minValue(1) + ->maxValue(3650) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->slaFieldHelperText('low')), + ]), Section::make('Operations settings') - ->description('Workspace controls for operations retention and thresholds.') + ->description($this->sectionDescription('operations', 'Workspace controls for operations retention and thresholds.')) ->schema([ TextInput::make('operations_operation_run_retention_days') - ->label('Operation run retention (days)') + ->label('Operation run retention') ->placeholder('Unset (uses default)') + ->suffix('days') + ->hint('7 – 3,650') ->numeric() ->integer() ->minValue(7) @@ -183,8 +275,10 @@ public function content(Schema $schema): Schema ->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days')) ->hintAction($this->makeResetAction('operations_operation_run_retention_days')), TextInput::make('operations_stuck_run_threshold_minutes') - ->label('Stuck run threshold (minutes)') + ->label('Stuck run threshold') ->placeholder('Unset (uses default)') + ->suffix('minutes') + ->hint('0 – 10,080') ->numeric() ->integer() ->minValue(0) @@ -206,6 +300,10 @@ public function save(): void $this->authorizeWorkspaceManage($user); + $this->resetValidation(); + + $this->composeSlaSubFieldsIntoData(); + [$normalizedValues, $validationErrors] = $this->normalizedInputValues(); if ($validationErrors !== []) { @@ -320,13 +418,79 @@ private function loadFormState(): void ]; $data[$field] = $workspaceValue === null - ? null + ? (in_array($field, self::KEYVALUE_FIELDS, true) ? [] : null) : $this->formatValueForInput($field, $workspaceValue); } + $this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings); + $this->data = $data; $this->workspaceOverrides = $workspaceOverrides; $this->resolvedSettings = $resolvedSettings; + + $this->loadDomainLastModified(); + } + + /** + * Load per-domain "last modified" metadata from workspace_settings. + */ + private function loadDomainLastModified(): void + { + $domains = array_unique(array_column(self::SETTING_FIELDS, 'domain')); + + $records = WorkspaceSetting::query() + ->where('workspace_id', (int) $this->workspace->getKey()) + ->whereIn('domain', $domains) + ->whereNotNull('updated_by_user_id') + ->with('updatedByUser:id,name') + ->get(); + + $domainInfo = []; + + foreach ($records as $record) { + /** @var WorkspaceSetting $record */ + $domain = $record->domain; + $updatedAt = $record->updated_at; + + if (! $updatedAt instanceof Carbon) { + continue; + } + + if (isset($domainInfo[$domain]) && $domainInfo[$domain]['updated_at']->gte($updatedAt)) { + continue; + } + + $user = $record->updatedByUser; + + $domainInfo[$domain] = [ + 'user_name' => $user instanceof User ? $user->name : 'Unknown', + 'updated_at' => $updatedAt, + ]; + } + + $this->domainLastModified = $domainInfo; + } + + /** + * Build a section description that appends "last modified" info when available. + */ + private function sectionDescription(string $domain, string $baseDescription): string + { + $meta = $this->domainLastModified[$domain] ?? null; + + if (! is_array($meta)) { + return $baseDescription; + } + + /** @var Carbon $updatedAt */ + $updatedAt = $meta['updated_at']; + + return sprintf( + '%s — Last modified by %s, %s.', + $baseDescription, + $meta['user_name'], + $updatedAt->diffForHumans(), + ); } private function makeResetAction(string $field): Action @@ -373,6 +537,29 @@ private function helperTextFor(string $field): string return sprintf('Effective value: %s.', $effectiveValue); } + private function slaFieldHelperText(string $severity): string + { + $resolved = $this->resolvedSettings['findings_sla_days'] ?? null; + + if (! is_array($resolved)) { + return ''; + } + + $effectiveValue = is_array($resolved['value'] ?? null) + ? (int) ($resolved['value'][$severity] ?? 0) + : 0; + + $systemDefault = is_array($resolved['system_default'] ?? null) + ? (int) ($resolved['system_default'][$severity] ?? 0) + : 0; + + if (! $this->hasWorkspaceOverride('findings_sla_days')) { + return sprintf('Default: %d days.', $systemDefault); + } + + return sprintf('Effective: %d days.', $effectiveValue); + } + /** * @return array{0: array, 1: array>} */ @@ -396,6 +583,35 @@ private function normalizedInputValues(): array } } + if ($field === 'findings_sla_days') { + $severityToField = array_flip(self::SLA_SUB_FIELDS); + + $targeted = false; + + foreach ($messages as $message) { + if (preg_match('/include "(?critical|high|medium|low)"/i', $message, $matches) === 1) { + $severity = strtolower((string) $matches['severity']); + $subField = $severityToField[$severity] ?? null; + + if (is_string($subField)) { + $validationErrors['data.'.$subField] ??= []; + $validationErrors['data.'.$subField][] = $message; + $targeted = true; + } + } + } + + if (! $targeted) { + foreach (self::SLA_SUB_FIELDS as $subField => $_severity) { + $validationErrors['data.'.$subField] = $messages !== [] + ? $messages + : ['Invalid value.']; + } + } + + continue; + } + $validationErrors['data.'.$field] = $messages !== [] ? $messages : ['Invalid value.']; @@ -417,8 +633,20 @@ private function normalizeFieldInput(string $field, mixed $value): mixed return null; } + if (is_array($value) && $value === []) { + return null; + } + if ($setting['type'] === 'json') { $value = $this->normalizeJsonInput($value); + + if (in_array($field, self::KEYVALUE_FIELDS, true)) { + $value = $this->normalizeKeyValueInput($value); + + if ($value === []) { + return null; + } + } } $definition = $this->settingDefinition($field); @@ -435,6 +663,87 @@ private function normalizeFieldInput(string $field, mixed $value): mixed return $definition->normalize($validator->validated()['value']); } + /** + * Normalize KeyValue component state. + * + * Filament's KeyValue UI keeps an empty row by default, which can submit as + * ['' => ''] and would otherwise fail validation. We treat empty rows as unset. + * + * @param array $value + * @return array + */ + private function normalizeKeyValueInput(array $value): array + { + $normalized = []; + + foreach ($value as $key => $item) { + if (is_array($item) && array_key_exists('key', $item)) { + $rowKey = $item['key']; + $rowValue = $item['value'] ?? null; + + if (! is_string($rowKey)) { + continue; + } + + $trimmedKey = trim($rowKey); + + if ($trimmedKey === '') { + continue; + } + + if (is_string($rowValue)) { + $trimmedValue = trim($rowValue); + + if ($trimmedValue === '') { + continue; + } + + $normalized[$trimmedKey] = $trimmedValue; + + continue; + } + + if ($rowValue === null) { + continue; + } + + $normalized[$trimmedKey] = $rowValue; + + continue; + } + + if (! is_string($key)) { + continue; + } + + $trimmedKey = trim($key); + + if ($trimmedKey === '') { + continue; + } + + if (is_string($item)) { + $trimmedValue = trim($item); + + if ($trimmedValue === '') { + continue; + } + + $normalized[$trimmedKey] = $trimmedValue; + + continue; + } + + if ($item === null) { + continue; + } + + $normalized[$trimmedKey] = $item; + } + + return $normalized; + } + private function normalizeJsonInput(mixed $value): array { if (is_array($value)) { @@ -516,6 +825,10 @@ private function formatValueForInput(string $field, mixed $value): mixed return null; } + if (in_array($field, self::KEYVALUE_FIELDS, true)) { + return $value; + } + $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : null; @@ -578,14 +891,46 @@ private function hasWorkspaceOverride(string $field): bool private function workspaceOverrideForField(string $field): mixed { - $setting = $this->settingForField($field); - $resolved = app(SettingsResolver::class)->resolveDetailed( - workspace: $this->workspace, - domain: $setting['domain'], - key: $setting['key'], - ); + return $this->workspaceOverrides[$field] ?? null; + } - return $resolved['workspace_value']; + /** + * Decompose the findings_sla_days JSON setting into individual SLA sub-fields. + * + * @param array $data + * @param array $workspaceOverrides + * @param array $resolvedSettings + */ + private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides, array &$resolvedSettings): void + { + $slaOverride = $workspaceOverrides['findings_sla_days'] ?? null; + $slaResolved = $resolvedSettings['findings_sla_days'] ?? null; + + foreach (self::SLA_SUB_FIELDS as $subField => $severity) { + $data[$subField] = is_array($slaOverride) && isset($slaOverride[$severity]) + ? (int) $slaOverride[$severity] + : null; + } + } + + /** + * Re-compose individual SLA sub-fields back into the findings_sla_days data key before save. + */ + private function composeSlaSubFieldsIntoData(): void + { + $values = []; + $hasAnyValue = false; + + foreach (self::SLA_SUB_FIELDS as $subField => $severity) { + $val = $this->data[$subField] ?? null; + + if ($val !== null && (is_string($val) ? trim($val) !== '' : true)) { + $values[$severity] = (int) $val; + $hasAnyValue = true; + } + } + + $this->data['findings_sla_days'] = $hasAnyValue ? $values : null; } private function currentUserCanManage(): bool diff --git a/app/Filament/Resources/AlertRuleResource.php b/app/Filament/Resources/AlertRuleResource.php index 5c9cf29..e213103 100644 --- a/app/Filament/Resources/AlertRuleResource.php +++ b/app/Filament/Resources/AlertRuleResource.php @@ -378,6 +378,7 @@ public static function eventTypeOptions(): array return [ AlertRule::EVENT_HIGH_DRIFT => 'High drift', AlertRule::EVENT_COMPARE_FAILED => '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/FindingResource.php b/app/Filament/Resources/FindingResource.php index a2f4b13..4fd2700 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -7,8 +7,10 @@ use App\Models\InventoryItem; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Models\TenantMembership; use App\Models\User; use App\Services\Drift\DriftFindingDiffBuilder; +use App\Services\Findings\FindingWorkflowService; use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; @@ -23,6 +25,8 @@ use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; @@ -36,6 +40,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use InvalidArgumentException; +use Throwable; use UnitEnum; class FindingResource extends Resource @@ -64,7 +70,7 @@ public static function canViewAny(): bool return false; } - return $user->can(Capabilities::TENANT_VIEW, $tenant); + return $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant); } public static function canView(Model $record): bool @@ -81,7 +87,7 @@ public static function canView(Model $record): bool return false; } - if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) { + if (! $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant)) { return false; } @@ -95,12 +101,12 @@ public static function canView(Model $record): bool public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) - ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action supports acknowledging all matching findings.') + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.') - ->exempt(ActionSurfaceSlot::DetailHeader, 'View page intentionally has no additional header actions.'); + ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.'); } public static function form(Schema $schema): Schema @@ -146,6 +152,27 @@ public static function infolist(Schema $schema): Schema ->openUrlInNewTab(), TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'), TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'), + TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'), + TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'), + TextEntry::make('times_seen')->label('Times seen')->placeholder('—'), + TextEntry::make('sla_days')->label('SLA days')->placeholder('—'), + TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'), + TextEntry::make('owner_user_id') + ->label('Owner') + ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->ownerUser?->name ?? ($state ? 'User #'.$state : '—')), + TextEntry::make('assignee_user_id') + ->label('Assignee') + ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->assigneeUser?->name ?? ($state ? 'User #'.$state : '—')), + TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'), + TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'), + TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'), + TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'), + TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'), + TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'), + TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'), + TextEntry::make('closed_by_user_id') + ->label('Closed by') + ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')), TextEntry::make('created_at')->label('Created')->dateTime(), ]) ->columns(2) @@ -278,22 +305,65 @@ public static function table(Table $table): Table ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'), Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(), + Tables\Columns\TextColumn::make('due_at') + ->label('Due') + ->dateTime() + ->sortable() + ->placeholder('—'), + Tables\Columns\TextColumn::make('assigneeUser.name') + ->label('Assignee') + ->placeholder('—'), Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at')->since()->label('Created'), ]) ->filters([ + Tables\Filters\Filter::make('open') + ->label('Open') + ->default() + ->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())), + Tables\Filters\Filter::make('overdue') + ->label('Overdue') + ->query(fn (Builder $query): Builder => $query + ->whereIn('status', Finding::openStatusesForQuery()) + ->whereNotNull('due_at') + ->where('due_at', '<', now())), + Tables\Filters\Filter::make('high_severity') + ->label('High severity') + ->query(fn (Builder $query): Builder => $query->whereIn('severity', [ + Finding::SEVERITY_HIGH, + Finding::SEVERITY_CRITICAL, + ])), + Tables\Filters\Filter::make('my_assigned') + ->label('My assigned') + ->query(function (Builder $query): Builder { + $userId = auth()->id(); + + if (! is_numeric($userId)) { + return $query->whereRaw('1 = 0'); + } + + return $query->where('assignee_user_id', (int) $userId); + }), Tables\Filters\SelectFilter::make('status') ->options([ Finding::STATUS_NEW => 'New', - Finding::STATUS_ACKNOWLEDGED => 'Acknowledged', + Finding::STATUS_TRIAGED => 'Triaged', + Finding::STATUS_ACKNOWLEDGED => 'Triaged (legacy acknowledged)', + Finding::STATUS_IN_PROGRESS => 'In progress', + Finding::STATUS_REOPENED => 'Reopened', + Finding::STATUS_RESOLVED => 'Resolved', + Finding::STATUS_CLOSED => 'Closed', + Finding::STATUS_RISK_ACCEPTED => 'Risk accepted', ]) - ->default(Finding::STATUS_NEW), + ->label('Status'), Tables\Filters\SelectFilter::make('finding_type') ->options([ Finding::FINDING_TYPE_DRIFT => 'Drift', + Finding::FINDING_TYPE_PERMISSION_POSTURE => 'Permission posture', + Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles', ]) - ->default(Finding::FINDING_TYPE_DRIFT), + ->label('Type'), Tables\Filters\Filter::make('scope_key') ->form([ TextInput::make('scope_key') @@ -337,42 +407,7 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), Actions\ActionGroup::make([ - UiEnforcement::forAction( - Actions\Action::make('acknowledge') - ->label('Acknowledge') - ->icon('heroicon-o-check') - ->color('gray') - ->requiresConfirmation() - ->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW) - ->action(function (Finding $record): void { - $tenant = Tenant::current(); - $user = auth()->user(); - - if (! $tenant || ! $user instanceof User) { - return; - } - - if ((int) $record->tenant_id !== (int) $tenant->getKey()) { - Notification::make() - ->title('Finding belongs to a different tenant') - ->danger() - ->send(); - - return; - } - - $record->acknowledge($user); - - Notification::make() - ->title('Finding acknowledged') - ->success() - ->send(); - }), - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(), + ...static::workflowActions(), ]) ->label('More') ->icon('heroicon-o-ellipsis-vertical') @@ -381,12 +416,12 @@ public static function table(Table $table): Table ->bulkActions([ BulkActionGroup::make([ UiEnforcement::forBulkAction( - BulkAction::make('acknowledge_selected') - ->label('Acknowledge selected') + BulkAction::make('triage_selected') + ->label('Triage selected') ->icon('heroicon-o-check') ->color('gray') ->requiresConfirmation() - ->action(function (Collection $records): void { + ->action(function (Collection $records, FindingWorkflowService $workflow): void { $tenant = Filament::getTenant(); $user = auth()->user(); @@ -394,8 +429,9 @@ public static function table(Table $table): Table return; } - $acknowledgedCount = 0; + $triagedCount = 0; $skippedCount = 0; + $failedCount = 0; foreach ($records as $record) { if (! $record instanceof Finding) { @@ -410,30 +446,343 @@ public static function table(Table $table): Table continue; } - if ($record->status !== Finding::STATUS_NEW) { + if (! in_array((string) $record->status, [ + Finding::STATUS_NEW, + Finding::STATUS_REOPENED, + Finding::STATUS_ACKNOWLEDGED, + ], true)) { $skippedCount++; continue; } - $record->acknowledge($user); - $acknowledgedCount++; + try { + $workflow->triage($record, $tenant, $user); + $triagedCount++; + } catch (Throwable) { + $failedCount++; + } } - $body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.'; + $body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.'; if ($skippedCount > 0) { $body .= " Skipped {$skippedCount}."; } + if ($failedCount > 0) { + $body .= " Failed {$failedCount}."; + } Notification::make() - ->title('Bulk acknowledge completed') + ->title('Bulk triage completed') ->body($body) - ->success() + ->status($failedCount > 0 ? 'warning' : 'success') ->send(); }) ->deselectRecordsAfterCompletion(), ) - ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) + ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('assign_selected') + ->label('Assign selected') + ->icon('heroicon-o-user-plus') + ->color('gray') + ->requiresConfirmation() + ->form([ + Select::make('assignee_user_id') + ->label('Assignee') + ->placeholder('Unassigned') + ->options(fn (): array => static::tenantMemberOptions()) + ->searchable(), + Select::make('owner_user_id') + ->label('Owner') + ->placeholder('Unassigned') + ->options(fn (): array => static::tenantMemberOptions()) + ->searchable(), + ]) + ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { + $tenant = Filament::getTenant(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + $assigneeUserId = is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null; + $ownerUserId = is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null; + + $assignedCount = 0; + $skippedCount = 0; + $failedCount = 0; + + foreach ($records as $record) { + if (! $record instanceof Finding) { + $skippedCount++; + + continue; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + $skippedCount++; + + continue; + } + + if (! $record->hasOpenStatus()) { + $skippedCount++; + + continue; + } + + try { + $workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId); + $assignedCount++; + } catch (Throwable) { + $failedCount++; + } + } + + $body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; + } + if ($failedCount > 0) { + $body .= " Failed {$failedCount}."; + } + + Notification::make() + ->title('Bulk assign completed') + ->body($body) + ->status($failedCount > 0 ? 'warning' : 'success') + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('resolve_selected') + ->label('Resolve selected') + ->icon('heroicon-o-check-badge') + ->color('success') + ->requiresConfirmation() + ->form([ + Textarea::make('resolved_reason') + ->label('Resolution reason') + ->rows(3) + ->required() + ->maxLength(255), + ]) + ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { + $tenant = Filament::getTenant(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + $reason = (string) ($data['resolved_reason'] ?? ''); + + $resolvedCount = 0; + $skippedCount = 0; + $failedCount = 0; + + foreach ($records as $record) { + if (! $record instanceof Finding) { + $skippedCount++; + + continue; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + $skippedCount++; + + continue; + } + + if (! $record->hasOpenStatus()) { + $skippedCount++; + + continue; + } + + try { + $workflow->resolve($record, $tenant, $user, $reason); + $resolvedCount++; + } catch (Throwable) { + $failedCount++; + } + } + + $body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; + } + if ($failedCount > 0) { + $body .= " Failed {$failedCount}."; + } + + Notification::make() + ->title('Bulk resolve completed') + ->body($body) + ->status($failedCount > 0 ? 'warning' : 'success') + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('close_selected') + ->label('Close selected') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->form([ + Textarea::make('closed_reason') + ->label('Close reason') + ->rows(3) + ->required() + ->maxLength(255), + ]) + ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { + $tenant = Filament::getTenant(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + $reason = (string) ($data['closed_reason'] ?? ''); + + $closedCount = 0; + $skippedCount = 0; + $failedCount = 0; + + foreach ($records as $record) { + if (! $record instanceof Finding) { + $skippedCount++; + + continue; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + $skippedCount++; + + continue; + } + + if (! $record->hasOpenStatus()) { + $skippedCount++; + + continue; + } + + try { + $workflow->close($record, $tenant, $user, $reason); + $closedCount++; + } catch (Throwable) { + $failedCount++; + } + } + + $body = "Closed {$closedCount} finding".($closedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; + } + if ($failedCount > 0) { + $body .= " Failed {$failedCount}."; + } + + Notification::make() + ->title('Bulk close completed') + ->body($body) + ->status($failedCount > 0 ? 'warning' : 'success') + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), + + UiEnforcement::forBulkAction( + BulkAction::make('risk_accept_selected') + ->label('Risk accept selected') + ->icon('heroicon-o-shield-check') + ->color('warning') + ->requiresConfirmation() + ->form([ + Textarea::make('closed_reason') + ->label('Risk acceptance reason') + ->rows(3) + ->required() + ->maxLength(255), + ]) + ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { + $tenant = Filament::getTenant(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + $reason = (string) ($data['closed_reason'] ?? ''); + + $acceptedCount = 0; + $skippedCount = 0; + $failedCount = 0; + + foreach ($records as $record) { + if (! $record instanceof Finding) { + $skippedCount++; + + continue; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + $skippedCount++; + + continue; + } + + if (! $record->hasOpenStatus()) { + $skippedCount++; + + continue; + } + + try { + $workflow->riskAccept($record, $tenant, $user, $reason); + $acceptedCount++; + } catch (Throwable) { + $failedCount++; + } + } + + $body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; + } + if ($failedCount > 0) { + $body .= " Failed {$failedCount}."; + } + + Notification::make() + ->title('Bulk risk accept completed') + ->body($body) + ->status($failedCount > 0 ? 'warning' : 'success') + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ) + ->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(), ])->label('More'), @@ -445,6 +794,7 @@ public static function getEloquentQuery(): Builder $tenantId = Tenant::current()?->getKey(); return parent::getEloquentQuery() + ->with(['assigneeUser', 'ownerUser', 'closedByUser']) ->addSelect([ 'subject_display_name' => InventoryItem::query() ->select('display_name') @@ -462,4 +812,300 @@ public static function getPages(): array 'view' => Pages\ViewFinding::route('/{record}'), ]; } + + /** + * @return array + */ + public static function workflowActions(): array + { + return [ + static::triageAction(), + static::startProgressAction(), + static::assignAction(), + static::resolveAction(), + static::closeAction(), + static::riskAcceptAction(), + static::reopenAction(), + ]; + } + + public static function triageAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('triage') + ->label('Triage') + ->icon('heroicon-o-check') + ->color('gray') + ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ + Finding::STATUS_NEW, + Finding::STATUS_REOPENED, + Finding::STATUS_ACKNOWLEDGED, + ], true)) + ->action(function (Finding $record, FindingWorkflowService $workflow): void { + static::runWorkflowMutation( + record: $record, + successTitle: 'Finding triaged', + callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->triage($finding, $tenant, $user), + ); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + } + + public static function startProgressAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('start_progress') + ->label('Start progress') + ->icon('heroicon-o-play') + ->color('info') + ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ + Finding::STATUS_TRIAGED, + Finding::STATUS_ACKNOWLEDGED, + ], true)) + ->action(function (Finding $record, FindingWorkflowService $workflow): void { + static::runWorkflowMutation( + record: $record, + successTitle: 'Finding moved to in progress', + callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->startProgress($finding, $tenant, $user), + ); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + } + + public static function assignAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('assign') + ->label('Assign') + ->icon('heroicon-o-user-plus') + ->color('gray') + ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) + ->fillForm(fn (Finding $record): array => [ + 'assignee_user_id' => $record->assignee_user_id, + 'owner_user_id' => $record->owner_user_id, + ]) + ->form([ + Select::make('assignee_user_id') + ->label('Assignee') + ->placeholder('Unassigned') + ->options(fn (): array => static::tenantMemberOptions()) + ->searchable(), + Select::make('owner_user_id') + ->label('Owner') + ->placeholder('Unassigned') + ->options(fn (): array => static::tenantMemberOptions()) + ->searchable(), + ]) + ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { + static::runWorkflowMutation( + record: $record, + successTitle: 'Finding assignment updated', + callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign( + $finding, + $tenant, + $user, + is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null, + is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null, + ), + ); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + } + + public static function resolveAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('resolve') + ->label('Resolve') + ->icon('heroicon-o-check-badge') + ->color('success') + ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) + ->requiresConfirmation() + ->form([ + Textarea::make('resolved_reason') + ->label('Resolution reason') + ->rows(3) + ->required() + ->maxLength(255), + ]) + ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { + static::runWorkflowMutation( + record: $record, + successTitle: 'Finding resolved', + callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve( + $finding, + $tenant, + $user, + (string) ($data['resolved_reason'] ?? ''), + ), + ); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + } + + public static function closeAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('close') + ->label('Close') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->form([ + Textarea::make('closed_reason') + ->label('Close reason') + ->rows(3) + ->required() + ->maxLength(255), + ]) + ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { + static::runWorkflowMutation( + record: $record, + successTitle: 'Finding closed', + callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close( + $finding, + $tenant, + $user, + (string) ($data['closed_reason'] ?? ''), + ), + ); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + } + + public static function riskAcceptAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('risk_accept') + ->label('Risk accept') + ->icon('heroicon-o-shield-check') + ->color('warning') + ->requiresConfirmation() + ->form([ + Textarea::make('closed_reason') + ->label('Risk acceptance reason') + ->rows(3) + ->required() + ->maxLength(255), + ]) + ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { + static::runWorkflowMutation( + record: $record, + successTitle: 'Finding marked as risk accepted', + callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept( + $finding, + $tenant, + $user, + (string) ($data['closed_reason'] ?? ''), + ), + ); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + } + + public static function reopenAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('reopen') + ->label('Reopen') + ->icon('heroicon-o-arrow-uturn-left') + ->color('warning') + ->requiresConfirmation() + ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) + ->action(function (Finding $record, FindingWorkflowService $workflow): void { + static::runWorkflowMutation( + record: $record, + successTitle: 'Finding reopened', + callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen($finding, $tenant, $user), + ); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); + } + + /** + * @param callable(Finding, Tenant, User): Finding $callback + */ + private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void + { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + Notification::make() + ->title('Finding belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + try { + $callback($record, $tenant, $user); + } catch (InvalidArgumentException $e) { + Notification::make() + ->title('Workflow action failed') + ->body($e->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title($successTitle) + ->success() + ->send(); + } + + /** + * @return array + */ + private static function tenantMemberOptions(): array + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return []; + } + + return TenantMembership::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->join('users', 'users.id', '=', 'tenant_memberships.user_id') + ->orderBy('users.name') + ->pluck('users.name', 'users.id') + ->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name]) + ->all(); + } } diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index 2b175a2..14329c2 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -3,8 +3,16 @@ namespace App\Filament\Resources\FindingResource\Pages; use App\Filament\Resources\FindingResource; +use App\Jobs\BackfillFindingLifecycleJob; use App\Models\Finding; +use App\Models\Tenant; +use App\Models\User; +use App\Services\Findings\FindingWorkflowService; +use App\Services\OperationRunService; use App\Support\Auth\Capabilities; +use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiTooltips; use Filament\Actions; @@ -13,6 +21,7 @@ use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; +use Throwable; class ListFindings extends ListRecords { @@ -22,8 +31,84 @@ protected function getHeaderActions(): array { return [ UiEnforcement::forAction( - Actions\Action::make('acknowledge_all_matching') - ->label('Acknowledge all matching') + Actions\Action::make('backfill_lifecycle') + ->label('Backfill findings lifecycle') + ->icon('heroicon-o-wrench-screwdriver') + ->color('gray') + ->requiresConfirmation() + ->modalHeading('Backfill findings lifecycle') + ->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.') + ->action(function (OperationRunService $operationRuns): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = \Filament\Facades\Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $opRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: 'findings.lifecycle.backfill', + identityInputs: [ + 'tenant_id' => (int) $tenant->getKey(), + 'trigger' => 'backfill', + ], + context: [ + 'workspace_id' => (int) $tenant->workspace_id, + 'initiator_user_id' => (int) $user->getKey(), + ], + initiator: $user, + ); + + $runUrl = OperationRunLinks::view($opRun, $tenant); + + if ($opRun->wasRecentlyCreated === false) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + + return; + } + + $operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void { + BackfillFindingLifecycleJob::dispatch( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: (int) $user->getKey(), + ); + }); + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->body('The backfill will run in the background. You can continue working while it completes.') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('triage_all_matching') + ->label('Triage all matching') ->icon('heroicon-o-check') ->color('gray') ->requiresConfirmation() @@ -31,7 +116,7 @@ protected function getHeaderActions(): array ->modalDescription(function (): string { $count = $this->getAllMatchingCount(); - return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; + return "You are about to triage {$count} finding".($count === 1 ? '' : 's').' matching the current filters.'; }) ->form(function (): array { $count = $this->getAllMatchingCount(); @@ -42,46 +127,90 @@ protected function getHeaderActions(): array return [ TextInput::make('confirmation') - ->label('Type ACKNOWLEDGE to confirm') + ->label('Type TRIAGE to confirm') ->required() - ->in(['ACKNOWLEDGE']) + ->in(['TRIAGE']) ->validationMessages([ - 'in' => 'Please type ACKNOWLEDGE to confirm.', + 'in' => 'Please type TRIAGE to confirm.', ]), ]; }) - ->action(function (array $data): void { + ->action(function (FindingWorkflowService $workflow): void { $query = $this->buildAllMatchingQuery(); $count = (clone $query)->count(); if ($count === 0) { Notification::make() ->title('No matching findings') - ->body('There are no new findings matching the current filters.') + ->body('There are no new findings matching the current filters to triage.') ->warning() ->send(); return; } - $updated = $query->update([ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => auth()->id(), - ]); + $user = auth()->user(); + $tenant = \Filament\Facades\Filament::getTenant(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $triagedCount = 0; + $skippedCount = 0; + $failedCount = 0; + + $query->orderBy('id')->chunkById(200, function ($findings) use ($workflow, $tenant, $user, &$triagedCount, &$skippedCount, &$failedCount): void { + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + $skippedCount++; + + continue; + } + + if (! in_array((string) $finding->status, [ + Finding::STATUS_NEW, + Finding::STATUS_REOPENED, + Finding::STATUS_ACKNOWLEDGED, + ], true)) { + $skippedCount++; + + continue; + } + + try { + $workflow->triage($finding, $tenant, $user); + $triagedCount++; + } catch (Throwable) { + $failedCount++; + } + } + }); $this->deselectAllTableRecords(); $this->resetPage(); + $body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.'; + if ($skippedCount > 0) { + $body .= " Skipped {$skippedCount}."; + } + if ($failedCount > 0) { + $body .= " Failed {$failedCount}."; + } + Notification::make() - ->title('Bulk acknowledge completed') - ->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.') - ->success() + ->title('Bulk triage completed') + ->body($body) + ->status($failedCount > 0 ? 'warning' : 'success') ->send(); }) ) ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE) + ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(), ]; @@ -106,6 +235,27 @@ protected function buildAllMatchingQuery(): Builder $query->where('finding_type', $findingType); } + if ($this->filterIsActive('overdue')) { + $query->whereNotNull('due_at')->where('due_at', '<', now()); + } + + if ($this->filterIsActive('high_severity')) { + $query->whereIn('severity', [ + Finding::SEVERITY_HIGH, + Finding::SEVERITY_CRITICAL, + ]); + } + + if ($this->filterIsActive('my_assigned')) { + $userId = auth()->id(); + + if (is_numeric($userId)) { + $query->where('assignee_user_id', (int) $userId); + } else { + $query->whereRaw('1 = 0'); + } + } + $scopeKeyState = $this->getTableFilterState('scope_key') ?? []; $scopeKey = Arr::get($scopeKeyState, 'scope_key'); if (is_string($scopeKey) && $scopeKey !== '') { @@ -126,6 +276,23 @@ protected function buildAllMatchingQuery(): Builder return $query; } + private function filterIsActive(string $filterName): bool + { + $state = $this->getTableFilterState($filterName); + + if ($state === true) { + return true; + } + + if (is_array($state)) { + $isActive = Arr::get($state, 'isActive'); + + return $isActive === true; + } + + return false; + } + protected function getAllMatchingCount(): int { return (int) $this->buildAllMatchingQuery()->count(); @@ -141,13 +308,13 @@ protected function getStatusFilterValue(): string : Finding::STATUS_NEW; } - protected function getFindingTypeFilterValue(): string + protected function getFindingTypeFilterValue(): ?string { $state = $this->getTableFilterState('finding_type') ?? []; $value = Arr::get($state, 'value'); return is_string($value) && $value !== '' ? $value - : Finding::FINDING_TYPE_DRIFT; + : null; } } diff --git a/app/Filament/Resources/FindingResource/Pages/ViewFinding.php b/app/Filament/Resources/FindingResource/Pages/ViewFinding.php index f723883..b19d49c 100644 --- a/app/Filament/Resources/FindingResource/Pages/ViewFinding.php +++ b/app/Filament/Resources/FindingResource/Pages/ViewFinding.php @@ -3,9 +3,20 @@ namespace App\Filament\Resources\FindingResource\Pages; use App\Filament\Resources\FindingResource; +use Filament\Actions; use Filament\Resources\Pages\ViewRecord; class ViewFinding extends ViewRecord { protected static string $resource = FindingResource::class; + + protected function getHeaderActions(): array + { + return [ + Actions\ActionGroup::make(FindingResource::workflowActions()) + ->label('Actions') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray'), + ]; + } } diff --git a/app/Jobs/Alerts/EvaluateAlertsJob.php b/app/Jobs/Alerts/EvaluateAlertsJob.php index bf86398..56f39f4 100644 --- a/app/Jobs/Alerts/EvaluateAlertsJob.php +++ b/app/Jobs/Alerts/EvaluateAlertsJob.php @@ -58,6 +58,7 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic try { $events = [ ...$this->highDriftEvents((int) $workspace->getKey(), $windowStart), + ...$this->slaDueEvents((int) $workspace->getKey(), $windowStart), ...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart), ...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart), ...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart), @@ -157,8 +158,28 @@ private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart) ->where('workspace_id', $workspaceId) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL]) - ->where('status', Finding::STATUS_NEW) - ->where('created_at', '>', $windowStart) + ->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(); @@ -225,6 +246,130 @@ private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowSt return $events; } + /** + * @return array> + */ + private function slaDueEvents(int $workspaceId, CarbonImmutable $windowStart): array + { + $now = CarbonImmutable::now('UTC'); + + $newlyOverdueTenantIds = Finding::query() + ->where('workspace_id', $workspaceId) + ->whereNotNull('tenant_id') + ->whereNotNull('due_at') + ->where('due_at', '>', $windowStart) + ->where('due_at', '<=', $now) + ->whereIn('status', Finding::openStatusesForQuery()) + ->orderBy('tenant_id') + ->pluck('tenant_id') + ->map(static fn (mixed $value): int => (int) $value) + ->filter(static fn (int $tenantId): bool => $tenantId > 0) + ->unique() + ->values() + ->all(); + + if ($newlyOverdueTenantIds === []) { + return []; + } + + $severityRank = [ + Finding::SEVERITY_LOW => 1, + Finding::SEVERITY_MEDIUM => 2, + Finding::SEVERITY_HIGH => 3, + Finding::SEVERITY_CRITICAL => 4, + ]; + + /** @var array, severity:string}> $summaryByTenant */ + $summaryByTenant = []; + + foreach ($newlyOverdueTenantIds as $tenantId) { + $summaryByTenant[$tenantId] = [ + 'overdue_total' => 0, + 'overdue_by_severity' => [ + Finding::SEVERITY_CRITICAL => 0, + Finding::SEVERITY_HIGH => 0, + Finding::SEVERITY_MEDIUM => 0, + Finding::SEVERITY_LOW => 0, + ], + 'severity' => Finding::SEVERITY_LOW, + ]; + } + + $overdueFindings = Finding::query() + ->where('workspace_id', $workspaceId) + ->whereIn('tenant_id', $newlyOverdueTenantIds) + ->whereNotNull('due_at') + ->where('due_at', '<=', $now) + ->whereIn('status', Finding::openStatusesForQuery()) + ->orderBy('tenant_id') + ->orderBy('id') + ->get(['tenant_id', 'severity']); + + foreach ($overdueFindings as $finding) { + $tenantId = (int) ($finding->tenant_id ?? 0); + + if (! isset($summaryByTenant[$tenantId])) { + continue; + } + + $severity = strtolower(trim((string) $finding->severity)); + + if (! array_key_exists($severity, $severityRank)) { + $severity = Finding::SEVERITY_HIGH; + } + + $summaryByTenant[$tenantId]['overdue_total']++; + $summaryByTenant[$tenantId]['overdue_by_severity'][$severity]++; + + $currentSeverity = $summaryByTenant[$tenantId]['severity']; + + if (($severityRank[$severity] ?? 0) > ($severityRank[$currentSeverity] ?? 0)) { + $summaryByTenant[$tenantId]['severity'] = $severity; + } + } + + $windowFingerprint = $windowStart->setTimezone('UTC')->format('Uu'); + $events = []; + + foreach ($newlyOverdueTenantIds as $tenantId) { + $summary = $summaryByTenant[$tenantId] ?? null; + + if (! is_array($summary) || (int) ($summary['overdue_total'] ?? 0) <= 0) { + continue; + } + + /** @var array $counts */ + $counts = $summary['overdue_by_severity']; + + $events[] = [ + 'event_type' => AlertRule::EVENT_SLA_DUE, + 'tenant_id' => $tenantId, + 'severity' => (string) ($summary['severity'] ?? Finding::SEVERITY_HIGH), + 'fingerprint_key' => sprintf('sla_due:tenant:%d:window:%s', $tenantId, $windowFingerprint), + 'title' => 'SLA due findings detected', + 'body' => sprintf( + '%d open finding(s) are overdue (critical: %d, high: %d, medium: %d, low: %d).', + (int) $summary['overdue_total'], + (int) ($counts[Finding::SEVERITY_CRITICAL] ?? 0), + (int) ($counts[Finding::SEVERITY_HIGH] ?? 0), + (int) ($counts[Finding::SEVERITY_MEDIUM] ?? 0), + (int) ($counts[Finding::SEVERITY_LOW] ?? 0), + ), + 'metadata' => [ + 'overdue_total' => (int) $summary['overdue_total'], + 'overdue_by_severity' => [ + Finding::SEVERITY_CRITICAL => (int) ($counts[Finding::SEVERITY_CRITICAL] ?? 0), + Finding::SEVERITY_HIGH => (int) ($counts[Finding::SEVERITY_HIGH] ?? 0), + Finding::SEVERITY_MEDIUM => (int) ($counts[Finding::SEVERITY_MEDIUM] ?? 0), + Finding::SEVERITY_LOW => (int) ($counts[Finding::SEVERITY_LOW] ?? 0), + ], + ], + ]; + } + + return $events; + } + private function firstFailureMessage(OperationRun $run): string { $failures = is_array($run->failure_summary) ? $run->failure_summary : []; @@ -265,7 +410,7 @@ private function permissionMissingEvents(int $workspaceId, CarbonImmutable $wind $findings = Finding::query() ->where('workspace_id', $workspaceId) ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) - ->where('status', Finding::STATUS_NEW) + ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) ->where('updated_at', '>', $windowStart) ->orderBy('id') ->get(); @@ -304,7 +449,7 @@ private function entraAdminRolesHighEvents(int $workspaceId, CarbonImmutable $wi ->where('workspace_id', $workspaceId) ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) ->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL]) - ->where('status', Finding::STATUS_NEW) + ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) ->where('updated_at', '>', $windowStart) ->orderBy('id') ->get(); diff --git a/app/Jobs/BackfillFindingLifecycleJob.php b/app/Jobs/BackfillFindingLifecycleJob.php new file mode 100644 index 0000000..a9d629d --- /dev/null +++ b/app/Jobs/BackfillFindingLifecycleJob.php @@ -0,0 +1,385 @@ +find($this->tenantId); + + if (! $tenant instanceof Tenant) { + return; + } + + $initiator = $this->initiatorUserId !== null + ? User::query()->find($this->initiatorUserId) + : null; + + $operationRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: 'findings.lifecycle.backfill', + identityInputs: [ + 'tenant_id' => $this->tenantId, + 'trigger' => 'backfill', + ], + context: [ + 'workspace_id' => $this->workspaceId, + 'initiator_user_id' => $this->initiatorUserId, + ], + initiator: $initiator instanceof User ? $initiator : null, + ); + + $lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900); + + if (! $lock->get()) { + if ($operationRun->status !== OperationRunStatus::Completed->value) { + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Blocked->value, + failures: [ + [ + 'code' => 'findings.lifecycle.backfill.lock_busy', + 'message' => 'Another findings lifecycle backfill is already running for this tenant.', + ], + ], + ); + } + + return; + } + + try { + $total = (int) Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->count(); + + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Running->value, + outcome: OperationRunOutcome::Pending->value, + summaryCounts: [ + 'total' => $total, + 'processed' => 0, + 'updated' => 0, + 'skipped' => 0, + 'failed' => 0, + ], + ); + + $operationRun->refresh(); + + $backfillStartedAt = $operationRun->started_at !== null + ? CarbonImmutable::instance($operationRun->started_at) + : CarbonImmutable::now('UTC'); + + Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->orderBy('id') + ->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void { + $processed = 0; + $updated = 0; + $skipped = 0; + + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + continue; + } + + $processed++; + + $originalAttributes = $finding->getAttributes(); + + $this->backfillLifecycleFields($finding, $backfillStartedAt); + $this->backfillLegacyAcknowledgedStatus($finding); + $this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt); + $this->backfillDriftRecurrenceKey($finding); + + if ($finding->isDirty()) { + $finding->save(); + $updated++; + } else { + $finding->setRawAttributes($originalAttributes, sync: true); + $skipped++; + } + } + + $operationRuns->incrementSummaryCounts($operationRun, [ + 'processed' => $processed, + 'updated' => $updated, + 'skipped' => $skipped, + ]); + }); + + $consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt); + + if ($consolidatedDuplicates > 0) { + $operationRuns->incrementSummaryCounts($operationRun, [ + 'updated' => $consolidatedDuplicates, + ]); + } + + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + } catch (Throwable $e) { + $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); + $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); + + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'findings.lifecycle.backfill.failed', + 'reason_code' => $reasonCode, + 'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.', + ]], + ); + + throw $e; + } finally { + $lock->release(); + } + } + + private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void + { + $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt; + + if ($finding->first_seen_at === null) { + $finding->first_seen_at = $createdAt; + } + + if ($finding->last_seen_at === null) { + $finding->last_seen_at = $createdAt; + } + + if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { + $lastSeen = CarbonImmutable::instance($finding->last_seen_at); + $firstSeen = CarbonImmutable::instance($finding->first_seen_at); + + if ($lastSeen->lessThan($firstSeen)) { + $finding->last_seen_at = $firstSeen; + } + } + + $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; + + if ($timesSeen < 1) { + $finding->times_seen = 1; + } + } + + private function backfillLegacyAcknowledgedStatus(Finding $finding): void + { + if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) { + return; + } + + $finding->status = Finding::STATUS_TRIAGED; + + if ($finding->triaged_at === null) { + if ($finding->acknowledged_at !== null) { + $finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at); + } elseif ($finding->created_at !== null) { + $finding->triaged_at = CarbonImmutable::instance($finding->created_at); + } + } + } + + private function backfillSlaFields( + Finding $finding, + Tenant $tenant, + FindingSlaPolicy $slaPolicy, + CarbonImmutable $backfillStartedAt, + ): void { + if (! Finding::isOpenStatus((string) $finding->status)) { + return; + } + + if ($finding->sla_days === null) { + $finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt); + } + } + + private function backfillDriftRecurrenceKey(Finding $finding): void + { + if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) { + return; + } + + if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') { + return; + } + + $tenantId = (int) ($finding->tenant_id ?? 0); + $scopeKey = (string) ($finding->scope_key ?? ''); + $subjectType = (string) ($finding->subject_type ?? ''); + $subjectExternalId = (string) ($finding->subject_external_id ?? ''); + + if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') { + return; + } + + $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; + $kind = Arr::get($evidence, 'summary.kind'); + $changeType = Arr::get($evidence, 'change_type'); + + $kind = is_string($kind) ? $kind : ''; + $changeType = is_string($changeType) ? $changeType : ''; + + if ($kind === '') { + return; + } + + $dimension = $this->recurrenceDimension($kind, $changeType); + + $finding->recurrence_key = hash('sha256', sprintf( + 'drift:%d:%s:%s:%s:%s', + $tenantId, + $scopeKey, + $subjectType, + $subjectExternalId, + $dimension, + )); + } + + private function recurrenceDimension(string $kind, string $changeType): string + { + $kind = strtolower(trim($kind)); + $changeType = strtolower(trim($changeType)); + + return match ($kind) { + 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'), + default => $kind, + }; + } + + private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int + { + $duplicateKeys = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->whereNotNull('recurrence_key') + ->select(['recurrence_key']) + ->groupBy('recurrence_key') + ->havingRaw('COUNT(*) > 1') + ->pluck('recurrence_key') + ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') + ->values(); + + if ($duplicateKeys->isEmpty()) { + return 0; + } + + $consolidated = 0; + + foreach ($duplicateKeys as $recurrenceKey) { + if (! is_string($recurrenceKey) || $recurrenceKey === '') { + continue; + } + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('recurrence_key', $recurrenceKey) + ->orderBy('id') + ->get(); + + $canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey); + + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + continue; + } + + if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) { + continue; + } + + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => $backfillStartedAt, + 'resolved_reason' => 'consolidated_duplicate', + 'recurrence_key' => null, + ])->save(); + + $consolidated++; + } + } + + return $consolidated; + } + + /** + * @param Collection $findings + */ + private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding + { + if ($findings->isEmpty()) { + return null; + } + + $openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status)); + + $candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings; + + $alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey); + + if ($alreadyCanonical instanceof Finding) { + return $alreadyCanonical; + } + + /** @var Finding $sorted */ + $sorted = $candidates + ->sortByDesc(function (Finding $finding): array { + $lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0; + $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0; + + return [ + max($lastSeen, $createdAt), + (int) $finding->getKey(), + ]; + }) + ->first(); + + return $sorted; + } +} diff --git a/app/Jobs/GenerateReviewPackJob.php b/app/Jobs/GenerateReviewPackJob.php index 9760c89..85a02d3 100644 --- a/app/Jobs/GenerateReviewPackJob.php +++ b/app/Jobs/GenerateReviewPackJob.php @@ -82,10 +82,10 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati ->get() ->keyBy('report_type'); - // 2. Collect Findings (open + acknowledged) + // 2. Collect open findings $findings = Finding::query() ->where('tenant_id', $tenantId) - ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) + ->whereIn('status', Finding::openStatusesForQuery()) ->orderBy('severity') ->orderBy('created_at') ->get(); diff --git a/app/Models/Finding.php b/app/Models/Finding.php index b8fdf03..a428b4c 100644 --- a/app/Models/Finding.php +++ b/app/Models/Finding.php @@ -32,14 +32,33 @@ class Finding extends Model public const string STATUS_ACKNOWLEDGED = 'acknowledged'; + public const string STATUS_TRIAGED = 'triaged'; + + public const string STATUS_IN_PROGRESS = 'in_progress'; + + public const string STATUS_REOPENED = 'reopened'; + public const string STATUS_RESOLVED = 'resolved'; + public const string STATUS_CLOSED = 'closed'; + + public const string STATUS_RISK_ACCEPTED = 'risk_accepted'; + protected $guarded = []; protected $casts = [ 'acknowledged_at' => 'datetime', + 'closed_at' => 'datetime', + 'due_at' => 'datetime', 'evidence_jsonb' => 'array', + 'first_seen_at' => 'datetime', + 'in_progress_at' => 'datetime', + 'last_seen_at' => 'datetime', + 'reopened_at' => 'datetime', 'resolved_at' => 'datetime', + 'sla_days' => 'integer', + 'times_seen' => 'integer', + 'triaged_at' => 'datetime', ]; public function tenant(): BelongsTo @@ -62,6 +81,83 @@ public function acknowledgedByUser(): BelongsTo return $this->belongsTo(User::class, 'acknowledged_by_user_id'); } + public function ownerUser(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function assigneeUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_user_id'); + } + + public function closedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'closed_by_user_id'); + } + + /** + * @return array + */ + public static function openStatuses(): array + { + return [ + self::STATUS_NEW, + self::STATUS_TRIAGED, + self::STATUS_IN_PROGRESS, + self::STATUS_REOPENED, + ]; + } + + /** + * @return array + */ + public static function terminalStatuses(): array + { + return [ + self::STATUS_RESOLVED, + self::STATUS_CLOSED, + self::STATUS_RISK_ACCEPTED, + ]; + } + + /** + * @return array + */ + public static function openStatusesForQuery(): array + { + return [ + ...self::openStatuses(), + self::STATUS_ACKNOWLEDGED, + ]; + } + + public static function canonicalizeStatus(?string $status): ?string + { + if ($status === self::STATUS_ACKNOWLEDGED) { + return self::STATUS_TRIAGED; + } + + return $status; + } + + public static function isOpenStatus(?string $status): bool + { + return is_string($status) && in_array($status, self::openStatusesForQuery(), true); + } + + public static function isTerminalStatus(?string $status): bool + { + $canonical = self::canonicalizeStatus($status); + + return is_string($canonical) && in_array($canonical, self::terminalStatuses(), true); + } + + public function hasOpenStatus(): bool + { + return self::isOpenStatus($this->status); + } + public function acknowledge(User $user): void { if ($this->status === self::STATUS_ACKNOWLEDGED) { diff --git a/app/Policies/FindingPolicy.php b/app/Policies/FindingPolicy.php index b11bf1b..7c075a2 100644 --- a/app/Policies/FindingPolicy.php +++ b/app/Policies/FindingPolicy.php @@ -17,11 +17,15 @@ public function viewAny(User $user): bool { $tenant = Tenant::current(); - if (! $tenant) { + if (! $tenant instanceof Tenant) { return false; } - return $user->canAccessTenant($tenant); + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW); } public function view(User $user, Finding $finding): bool @@ -36,10 +40,60 @@ public function view(User $user, Finding $finding): bool return false; } - return (int) $finding->tenant_id === (int) $tenant->getKey(); + if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { + return false; + } + + return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW); } public function update(User $user, Finding $finding): bool + { + return $this->triage($user, $finding); + } + + public function triage(User $user, Finding $finding): bool + { + return $this->canMutateWithAnyCapability($user, $finding, [ + Capabilities::TENANT_FINDINGS_TRIAGE, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, + ]); + } + + public function assign(User $user, Finding $finding): bool + { + return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_ASSIGN); + } + + public function resolve(User $user, Finding $finding): bool + { + return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RESOLVE); + } + + public function close(User $user, Finding $finding): bool + { + return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_CLOSE); + } + + public function riskAccept(User $user, Finding $finding): bool + { + return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RISK_ACCEPT); + } + + public function reopen(User $user, Finding $finding): bool + { + return $this->triage($user, $finding); + } + + private function canMutateWithCapability(User $user, Finding $finding, string $capability): bool + { + return $this->canMutateWithAnyCapability($user, $finding, [$capability]); + } + + /** + * @param array $capabilities + */ + private function canMutateWithAnyCapability(User $user, Finding $finding, array $capabilities): bool { $tenant = Tenant::current(); @@ -58,6 +112,12 @@ public function update(User $user, Finding $finding): bool /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); - return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE); + foreach ($capabilities as $capability) { + if ($resolver->can($user, $tenant, $capability)) { + return true; + } + } + + return false; } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 3ee9abf..2682577 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -192,6 +192,7 @@ public function panel(Panel $panel): Panel FilamentInfoWidget::class, ]) ->databaseNotifications() + ->unsavedChangesAlerts() ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/app/Services/Auth/RoleCapabilityMap.php b/app/Services/Auth/RoleCapabilityMap.php index 12a2071..fc0b5ba 100644 --- a/app/Services/Auth/RoleCapabilityMap.php +++ b/app/Services/Auth/RoleCapabilityMap.php @@ -20,6 +20,12 @@ class RoleCapabilityMap Capabilities::TENANT_DELETE, Capabilities::TENANT_SYNC, Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_VIEW, + Capabilities::TENANT_FINDINGS_TRIAGE, + Capabilities::TENANT_FINDINGS_ASSIGN, + Capabilities::TENANT_FINDINGS_RESOLVE, + Capabilities::TENANT_FINDINGS_CLOSE, + Capabilities::TENANT_FINDINGS_RISK_ACCEPT, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, @@ -50,6 +56,12 @@ class RoleCapabilityMap Capabilities::TENANT_MANAGE, Capabilities::TENANT_SYNC, Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_VIEW, + Capabilities::TENANT_FINDINGS_TRIAGE, + Capabilities::TENANT_FINDINGS_ASSIGN, + Capabilities::TENANT_FINDINGS_RESOLVE, + Capabilities::TENANT_FINDINGS_CLOSE, + Capabilities::TENANT_FINDINGS_RISK_ACCEPT, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, @@ -77,6 +89,8 @@ class RoleCapabilityMap Capabilities::TENANT_VIEW, Capabilities::TENANT_SYNC, Capabilities::TENANT_INVENTORY_SYNC_RUN, + Capabilities::TENANT_FINDINGS_VIEW, + Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, @@ -96,6 +110,7 @@ class RoleCapabilityMap TenantRole::Readonly->value => [ Capabilities::TENANT_VIEW, + Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_ROLE_MAPPING_VIEW, diff --git a/app/Services/Drift/DriftFindingGenerator.php b/app/Services/Drift/DriftFindingGenerator.php index 61069c2..3ffa1a3 100644 --- a/app/Services/Drift/DriftFindingGenerator.php +++ b/app/Services/Drift/DriftFindingGenerator.php @@ -10,7 +10,9 @@ use App\Models\Workspace; use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Findings\FindingSlaPolicy; use App\Services\Settings\SettingsResolver; +use Carbon\CarbonImmutable; use Illuminate\Support\Arr; use RuntimeException; @@ -22,6 +24,7 @@ public function __construct( private readonly SettingsNormalizer $settingsNormalizer, private readonly ScopeTagsNormalizer $scopeTagsNormalizer, private readonly SettingsResolver $settingsResolver, + private readonly FindingSlaPolicy $slaPolicy, ) {} public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int @@ -30,6 +33,8 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c throw new RuntimeException('Baseline/current run must be finished.'); } + $observedAt = CarbonImmutable::instance($current->completed_at); + /** @var array $selection */ $selection = is_array($current->context) ? $current->context : []; @@ -42,12 +47,13 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c $created = 0; $resolvedSeverity = $this->resolveSeverityForFindingType($tenant, Finding::FINDING_TYPE_DRIFT); + $seenRecurrenceKeys = []; Policy::query() ->where('tenant_id', $tenant->getKey()) ->whereIn('policy_type', $policyTypes) ->orderBy('id') - ->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, &$created): void { + ->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, $observedAt, &$seenRecurrenceKeys, &$created): void { foreach ($policies as $policy) { if (! $policy instanceof Policy) { continue; @@ -80,16 +86,6 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c default => 'modified', }; - $fingerprint = $this->hasher->fingerprint( - tenantId: (int) $tenant->getKey(), - scopeKey: $scopeKey, - subjectType: 'policy', - subjectExternalId: (string) $policy->external_id, - changeType: $changeType, - baselineHash: $baselineSnapshotHash, - currentHash: $currentSnapshotHash, - ); - $rawEvidence = [ 'change_type' => $changeType, 'summary' => [ @@ -108,33 +104,20 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c ], ]; - $finding = Finding::query()->firstOrNew([ - 'tenant_id' => $tenant->getKey(), - 'fingerprint' => $fingerprint, - ]); - - $wasNew = ! $finding->exists; - - $finding->forceFill([ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'scope_key' => $scopeKey, - 'baseline_operation_run_id' => $baseline->getKey(), - 'current_operation_run_id' => $current->getKey(), - 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, - 'severity' => $resolvedSeverity, - 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), - ]); - - if ($wasNew) { - $finding->forceFill([ - 'status' => Finding::STATUS_NEW, - 'acknowledged_at' => null, - 'acknowledged_by_user_id' => null, - ]); - } - - $finding->save(); + $dimension = $this->recurrenceDimension('policy_snapshot', $changeType); + $wasNew = $this->upsertDriftFinding( + tenant: $tenant, + baseline: $baseline, + current: $current, + scopeKey: $scopeKey, + subjectType: 'policy', + subjectExternalId: (string) $policy->external_id, + severity: $resolvedSeverity, + dimension: $dimension, + rawEvidence: $rawEvidence, + observedAt: $observedAt, + seenRecurrenceKeys: $seenRecurrenceKeys, + ); if ($wasNew) { $created++; @@ -153,16 +136,6 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c $currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments); if ($baselineAssignmentsHash !== $currentAssignmentsHash) { - $fingerprint = $this->hasher->fingerprint( - tenantId: (int) $tenant->getKey(), - scopeKey: $scopeKey, - subjectType: 'assignment', - subjectExternalId: (string) $policy->external_id, - changeType: 'modified', - baselineHash: (string) ($baselineAssignmentsHash ?? ''), - currentHash: (string) ($currentAssignmentsHash ?? ''), - ); - $rawEvidence = [ 'change_type' => 'modified', 'summary' => [ @@ -181,33 +154,20 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c ], ]; - $finding = Finding::query()->firstOrNew([ - 'tenant_id' => $tenant->getKey(), - 'fingerprint' => $fingerprint, - ]); - - $wasNew = ! $finding->exists; - - $finding->forceFill([ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'scope_key' => $scopeKey, - 'baseline_operation_run_id' => $baseline->getKey(), - 'current_operation_run_id' => $current->getKey(), - 'subject_type' => 'assignment', - 'subject_external_id' => (string) $policy->external_id, - 'severity' => $resolvedSeverity, - 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), - ]); - - if ($wasNew) { - $finding->forceFill([ - 'status' => Finding::STATUS_NEW, - 'acknowledged_at' => null, - 'acknowledged_by_user_id' => null, - ]); - } - - $finding->save(); + $dimension = $this->recurrenceDimension('policy_assignments', 'modified'); + $wasNew = $this->upsertDriftFinding( + tenant: $tenant, + baseline: $baseline, + current: $current, + scopeKey: $scopeKey, + subjectType: 'assignment', + subjectExternalId: (string) $policy->external_id, + severity: $resolvedSeverity, + dimension: $dimension, + rawEvidence: $rawEvidence, + observedAt: $observedAt, + seenRecurrenceKeys: $seenRecurrenceKeys, + ); if ($wasNew) { $created++; @@ -228,16 +188,6 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c continue; } - $fingerprint = $this->hasher->fingerprint( - tenantId: (int) $tenant->getKey(), - scopeKey: $scopeKey, - subjectType: 'scope_tag', - subjectExternalId: (string) $policy->external_id, - changeType: 'modified', - baselineHash: $baselineScopeTagsHash, - currentHash: $currentScopeTagsHash, - ); - $rawEvidence = [ 'change_type' => 'modified', 'summary' => [ @@ -256,33 +206,20 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c ], ]; - $finding = Finding::query()->firstOrNew([ - 'tenant_id' => $tenant->getKey(), - 'fingerprint' => $fingerprint, - ]); - - $wasNew = ! $finding->exists; - - $finding->forceFill([ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'scope_key' => $scopeKey, - 'baseline_operation_run_id' => $baseline->getKey(), - 'current_operation_run_id' => $current->getKey(), - 'subject_type' => 'scope_tag', - 'subject_external_id' => (string) $policy->external_id, - 'severity' => $resolvedSeverity, - 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), - ]); - - if ($wasNew) { - $finding->forceFill([ - 'status' => Finding::STATUS_NEW, - 'acknowledged_at' => null, - 'acknowledged_by_user_id' => null, - ]); - } - - $finding->save(); + $dimension = $this->recurrenceDimension('policy_scope_tags', 'modified'); + $wasNew = $this->upsertDriftFinding( + tenant: $tenant, + baseline: $baseline, + current: $current, + scopeKey: $scopeKey, + subjectType: 'scope_tag', + subjectExternalId: (string) $policy->external_id, + severity: $resolvedSeverity, + dimension: $dimension, + rawEvidence: $rawEvidence, + observedAt: $observedAt, + seenRecurrenceKeys: $seenRecurrenceKeys, + ); if ($wasNew) { $created++; @@ -290,9 +227,189 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c } }); + $this->resolveStaleDriftFindings( + tenant: $tenant, + scopeKey: $scopeKey, + seenRecurrenceKeys: $seenRecurrenceKeys, + observedAt: $observedAt, + ); + return $created; } + private function recurrenceDimension(string $kind, string $changeType): string + { + $kind = strtolower(trim($kind)); + $changeType = strtolower(trim($changeType)); + + return match ($kind) { + 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType), + default => $kind, + }; + } + + private function recurrenceKey( + int $tenantId, + string $scopeKey, + string $subjectType, + string $subjectExternalId, + string $dimension, + ): string { + return hash('sha256', sprintf( + 'drift:%d:%s:%s:%s:%s', + $tenantId, + $scopeKey, + $subjectType, + $subjectExternalId, + $dimension, + )); + } + + /** + * @param array $seenRecurrenceKeys + * @param array $rawEvidence + */ + private function upsertDriftFinding( + Tenant $tenant, + OperationRun $baseline, + OperationRun $current, + string $scopeKey, + string $subjectType, + string $subjectExternalId, + string $severity, + string $dimension, + array $rawEvidence, + CarbonImmutable $observedAt, + array &$seenRecurrenceKeys, + ): bool { + $tenantId = (int) $tenant->getKey(); + $recurrenceKey = $this->recurrenceKey($tenantId, $scopeKey, $subjectType, $subjectExternalId, $dimension); + $seenRecurrenceKeys[] = $recurrenceKey; + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('recurrence_key', $recurrenceKey) + ->first(); + + $wasNew = ! $finding instanceof Finding; + + if ($wasNew) { + $finding = new Finding; + } else { + $this->observeFinding($finding, $observedAt, (int) $current->getKey()); + } + + $finding->forceFill([ + 'tenant_id' => $tenantId, + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'scope_key' => $scopeKey, + 'baseline_operation_run_id' => $baseline->getKey(), + 'current_operation_run_id' => $current->getKey(), + 'recurrence_key' => $recurrenceKey, + 'fingerprint' => $recurrenceKey, + 'subject_type' => $subjectType, + 'subject_external_id' => $subjectExternalId, + 'severity' => $severity, + 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), + ]); + + if ($wasNew) { + $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); + + $finding->forceFill([ + 'status' => Finding::STATUS_NEW, + 'acknowledged_at' => null, + 'acknowledged_by_user_id' => null, + 'first_seen_at' => $observedAt, + 'last_seen_at' => $observedAt, + 'times_seen' => 1, + 'sla_days' => $slaDays, + 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), + ]); + } + + $status = (string) $finding->status; + + if ($status === Finding::STATUS_RESOLVED) { + $resolvedAt = $finding->resolved_at; + + if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { + $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); + + $finding->forceFill([ + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => $observedAt, + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => null, + 'closed_reason' => null, + 'closed_by_user_id' => null, + 'sla_days' => $slaDays, + 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), + ]); + } + } + + $finding->save(); + + return $wasNew; + } + + 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; + } + } + + /** + * @param array $seenRecurrenceKeys + */ + private function resolveStaleDriftFindings( + Tenant $tenant, + string $scopeKey, + array $seenRecurrenceKeys, + CarbonImmutable $observedAt, + ): void { + $staleFindingsQuery = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->whereNotNull('recurrence_key') + ->whereIn('status', Finding::openStatusesForQuery()); + + if ($seenRecurrenceKeys !== []) { + $staleFindingsQuery->whereNotIn('recurrence_key', $seenRecurrenceKeys); + } + + $staleFindings = $staleFindingsQuery->get(); + + foreach ($staleFindings as $finding) { + if (! $finding instanceof Finding) { + continue; + } + + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => $observedAt, + 'resolved_reason' => 'no_longer_detected', + ])->save(); + } + } + private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion { if (! $run->completed_at) { diff --git a/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php b/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php index 07a7914..36e51aa 100644 --- a/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php +++ b/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php @@ -8,6 +8,7 @@ use App\Models\Finding; use App\Models\OperationRun; use App\Models\Tenant; +use App\Services\Findings\FindingSlaPolicy; use Carbon\CarbonImmutable; final class EntraAdminRolesFindingGenerator @@ -19,6 +20,7 @@ final class EntraAdminRolesFindingGenerator public function __construct( private readonly HighPrivilegeRoleCatalog $catalog, + private readonly ?FindingSlaPolicy $slaPolicy = null, ) {} /** @@ -39,6 +41,7 @@ public function generate( $roleAssignments = is_array($reportPayload['role_assignments'] ?? null) ? $reportPayload['role_assignments'] : []; $roleDefinitions = is_array($reportPayload['role_definitions'] ?? null) ? $reportPayload['role_definitions'] : []; $measuredAt = (string) ($reportPayload['measured_at'] ?? CarbonImmutable::now('UTC')->toIso8601String()); + $observedAt = $this->resolveObservedAt($measuredAt); $roleDefMap = $this->buildRoleDefMap($roleDefinitions); @@ -67,7 +70,16 @@ public function generate( $evidence = $this->buildEvidence($assignment, $roleDef, $principal, $severity, $measuredAt); - $result = $this->upsertFinding($tenant, $fingerprint, $severity, $evidence, $principalId, $roleDefId, $operationRun); + $result = $this->upsertFinding( + tenant: $tenant, + fingerprint: $fingerprint, + severity: $severity, + evidence: $evidence, + principalId: $principalId, + roleDefId: $roleDefId, + observedAt: $observedAt, + operationRun: $operationRun, + ); match ($result) { 'created' => $created++, @@ -90,10 +102,10 @@ public function generate( } // Aggregate "Too many Global Admins" finding - $resolved += $this->handleGaAggregate($tenant, $gaCount, $gaPrincipals, $currentFingerprints, $operationRun, $created, $reopened); + $resolved += $this->handleGaAggregate($tenant, $gaCount, $gaPrincipals, $currentFingerprints, $observedAt, $operationRun, $created, $reopened); // Auto-resolve stale findings - $resolved += $this->resolveStaleFindings($tenant, $currentFingerprints); + $resolved += $this->resolveStaleFindings($tenant, $currentFingerprints, $observedAt); return new EntraAdminRolesFindingResult( created: $created, @@ -137,26 +149,56 @@ private function upsertFinding( array $evidence, string $principalId, string $roleDefId, + CarbonImmutable $observedAt, ?OperationRun $operationRun, ): string { + $slaPolicy = $this->resolveSlaPolicy(); + $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) ->where('fingerprint', $fingerprint) ->first(); if ($existing instanceof Finding) { - if ($existing->status === Finding::STATUS_RESOLVED) { - $existing->reopen($evidence); + $this->observeFinding($existing, $observedAt); - return 'reopened'; + $existing->forceFill([ + 'severity' => $severity, + 'evidence_jsonb' => $evidence, + 'current_operation_run_id' => $operationRun?->getKey(), + ]); + + if ($existing->status === Finding::STATUS_RESOLVED) { + $resolvedAt = $existing->resolved_at; + + if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { + $slaDays = $slaPolicy->daysForSeverity($severity, $tenant); + + $existing->forceFill([ + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => $observedAt, + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => null, + 'closed_reason' => null, + 'closed_by_user_id' => null, + 'sla_days' => $slaDays, + 'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), + ])->save(); + + return 'reopened'; + } } // Update evidence on existing open finding - $existing->update(['evidence_jsonb' => $evidence]); + $existing->save(); return 'unchanged'; } + $slaDays = $slaPolicy->daysForSeverity($severity, $tenant); + Finding::create([ 'tenant_id' => (int) $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES, @@ -169,6 +211,11 @@ private function upsertFinding( 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), + 'first_seen_at' => $observedAt, + 'last_seen_at' => $observedAt, + 'times_seen' => 1, + 'sla_days' => $slaDays, + 'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); return 'created'; @@ -184,10 +231,12 @@ private function handleGaAggregate( int $gaCount, array $gaPrincipals, array &$currentFingerprints, + CarbonImmutable $observedAt, ?OperationRun $operationRun, int &$created, int &$reopened, ): int { + $slaPolicy = $this->resolveSlaPolicy(); $gaFingerprint = $this->gaAggregateFingerprint($tenant); $currentFingerprints[] = $gaFingerprint; @@ -202,18 +251,46 @@ private function handleGaAggregate( $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) ->where('fingerprint', $gaFingerprint) ->first(); if ($existing instanceof Finding) { + $this->observeFinding($existing, $observedAt); + + $existing->forceFill([ + 'severity' => Finding::SEVERITY_HIGH, + 'evidence_jsonb' => $evidence, + 'current_operation_run_id' => $operationRun?->getKey(), + ]); + if ($existing->status === Finding::STATUS_RESOLVED) { - $existing->reopen($evidence); - $reopened++; - $this->produceAlertEvent($tenant, $gaFingerprint, $evidence); - } else { - $existing->update(['evidence_jsonb' => $evidence]); + $resolvedAt = $existing->resolved_at; + + if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { + $slaDays = $slaPolicy->daysForSeverity(Finding::SEVERITY_HIGH, $tenant); + + $existing->forceFill([ + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => $observedAt, + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => null, + 'closed_reason' => null, + 'closed_by_user_id' => null, + 'sla_days' => $slaDays, + 'due_at' => $slaPolicy->dueAtForSeverity(Finding::SEVERITY_HIGH, $tenant, $observedAt), + ]); + + $reopened++; + $this->produceAlertEvent($tenant, $gaFingerprint, $evidence); + } } + + $existing->save(); } else { + $slaDays = $slaPolicy->daysForSeverity(Finding::SEVERITY_HIGH, $tenant); + Finding::create([ 'tenant_id' => (int) $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES, @@ -226,6 +303,11 @@ private function handleGaAggregate( 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), + 'first_seen_at' => $observedAt, + 'last_seen_at' => $observedAt, + 'times_seen' => 1, + 'sla_days' => $slaDays, + 'due_at' => $slaPolicy->dueAtForSeverity(Finding::SEVERITY_HIGH, $tenant, $observedAt), ]); $created++; $this->produceAlertEvent($tenant, $gaFingerprint, $evidence); @@ -234,12 +316,17 @@ private function handleGaAggregate( // Auto-resolve aggregate if threshold met $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) ->where('fingerprint', $gaFingerprint) - ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) + ->whereIn('status', Finding::openStatusesForQuery()) ->first(); if ($existing instanceof Finding) { - $existing->resolve('ga_count_within_threshold'); + $existing->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => $observedAt, + 'resolved_reason' => 'ga_count_within_threshold', + ])->save(); $resolved++; } } @@ -250,25 +337,74 @@ private function handleGaAggregate( /** * Resolve open findings whose fingerprint is not in the current scan. */ - private function resolveStaleFindings(Tenant $tenant, array $currentFingerprints): int + private function resolveStaleFindings(Tenant $tenant, array $currentFingerprints, CarbonImmutable $observedAt): int { $staleFindings = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) - ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) + ->whereIn('status', Finding::openStatusesForQuery()) ->whereNotIn('fingerprint', $currentFingerprints) ->get(); $resolved = 0; foreach ($staleFindings as $finding) { - $finding->resolve('role_assignment_removed'); + if (! $finding instanceof Finding) { + continue; + } + + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => $observedAt, + 'resolved_reason' => 'role_assignment_removed', + ])->save(); $resolved++; } return $resolved; } + private function resolveObservedAt(string $measuredAt): CarbonImmutable + { + $measuredAt = trim($measuredAt); + + if ($measuredAt !== '') { + try { + return CarbonImmutable::parse($measuredAt); + } catch (\Throwable) { + // Fall through. + } + } + + return CarbonImmutable::now('UTC'); + } + + private function resolveSlaPolicy(): FindingSlaPolicy + { + return $this->slaPolicy ?? app(FindingSlaPolicy::class); + } + + private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void + { + if ($finding->first_seen_at === null) { + $finding->first_seen_at = $observedAt; + } + + $lastSeenAt = $finding->last_seen_at; + $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; + + if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { + $finding->last_seen_at = $observedAt; + $finding->times_seen = max(0, $timesSeen) + 1; + + return; + } + + if ($timesSeen < 1) { + $finding->times_seen = 1; + } + } + private function produceAlertEvent(Tenant $tenant, string $fingerprint, array $evidence): void { $this->alertEvents[] = [ diff --git a/app/Services/Findings/FindingSlaPolicy.php b/app/Services/Findings/FindingSlaPolicy.php new file mode 100644 index 0000000..7fb904f --- /dev/null +++ b/app/Services/Findings/FindingSlaPolicy.php @@ -0,0 +1,52 @@ +daysForSeverity((string) $finding->severity, $tenant); + } + + public function daysForSeverity(string $severity, Tenant $tenant): int + { + $workspace = $tenant->workspace; + + if (! $workspace instanceof Workspace) { + throw new InvalidArgumentException('Tenant workspace is required to resolve findings SLA.'); + } + + $policy = $this->settingsResolver->resolveValue($workspace, 'findings', 'sla_days'); + $policy = is_array($policy) ? $policy : []; + + $severity = strtolower(trim($severity)); + $days = $policy[$severity] ?? null; + + if (! is_numeric($days)) { + throw new InvalidArgumentException(sprintf('No SLA policy days configured for severity [%s].', $severity)); + } + + return (int) $days; + } + + public function dueAtForSeverity(string $severity, Tenant $tenant, ?CarbonImmutable $from = null): CarbonImmutable + { + $from ??= CarbonImmutable::now(); + + return $from->addDays($this->daysForSeverity($severity, $tenant)); + } +} diff --git a/app/Services/Findings/FindingWorkflowService.php b/app/Services/Findings/FindingWorkflowService.php new file mode 100644 index 0000000..fb8f7dd --- /dev/null +++ b/app/Services/Findings/FindingWorkflowService.php @@ -0,0 +1,380 @@ +authorize($finding, $tenant, $actor, [ + Capabilities::TENANT_FINDINGS_TRIAGE, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, + ]); + + $currentStatus = (string) $finding->status; + + if (! in_array($currentStatus, [ + Finding::STATUS_NEW, + Finding::STATUS_REOPENED, + Finding::STATUS_ACKNOWLEDGED, + ], true)) { + throw new InvalidArgumentException('Finding cannot be triaged from the current status.'); + } + + $now = CarbonImmutable::now(); + + return $this->mutateAndAudit( + finding: $finding, + tenant: $tenant, + actor: $actor, + action: 'finding.triaged', + context: [ + 'metadata' => [ + 'triaged_at' => $now->toIso8601String(), + ], + ], + mutate: function (Finding $record) use ($now): void { + $record->status = Finding::STATUS_TRIAGED; + $record->triaged_at = $record->triaged_at ?? $now; + }, + ); + } + + public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding + { + $this->authorize($finding, $tenant, $actor, [ + Capabilities::TENANT_FINDINGS_TRIAGE, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, + ]); + + if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) { + throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.'); + } + + $now = CarbonImmutable::now(); + + return $this->mutateAndAudit( + finding: $finding, + tenant: $tenant, + actor: $actor, + action: 'finding.in_progress', + context: [ + 'metadata' => [ + 'in_progress_at' => $now->toIso8601String(), + ], + ], + mutate: function (Finding $record) use ($now): void { + $record->status = Finding::STATUS_IN_PROGRESS; + $record->in_progress_at = $record->in_progress_at ?? $now; + $record->triaged_at = $record->triaged_at ?? $now; + }, + ); + } + + public function assign( + Finding $finding, + Tenant $tenant, + User $actor, + ?int $assigneeUserId = null, + ?int $ownerUserId = null, + ): Finding { + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_ASSIGN]); + + if (! $finding->hasOpenStatus()) { + throw new InvalidArgumentException('Only open findings can be assigned.'); + } + + $this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id'); + $this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id'); + + return $this->mutateAndAudit( + finding: $finding, + tenant: $tenant, + actor: $actor, + action: 'finding.assigned', + context: [ + 'metadata' => [ + 'assignee_user_id' => $assigneeUserId, + 'owner_user_id' => $ownerUserId, + ], + ], + mutate: function (Finding $record) use ($assigneeUserId, $ownerUserId): void { + $record->assignee_user_id = $assigneeUserId; + $record->owner_user_id = $ownerUserId; + }, + ); + } + + public function resolve(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding + { + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RESOLVE]); + + if (! $finding->hasOpenStatus()) { + throw new InvalidArgumentException('Only open findings can be resolved.'); + } + + $reason = $this->validatedReason($reason, 'resolved_reason'); + $now = CarbonImmutable::now(); + + return $this->mutateAndAudit( + finding: $finding, + tenant: $tenant, + actor: $actor, + action: 'finding.resolved', + context: [ + 'metadata' => [ + 'resolved_reason' => $reason, + 'resolved_at' => $now->toIso8601String(), + ], + ], + mutate: function (Finding $record) use ($reason, $now): void { + $record->status = Finding::STATUS_RESOLVED; + $record->resolved_reason = $reason; + $record->resolved_at = $now; + }, + ); + } + + public function close(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding + { + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]); + + $reason = $this->validatedReason($reason, 'closed_reason'); + $now = CarbonImmutable::now(); + + return $this->mutateAndAudit( + finding: $finding, + tenant: $tenant, + actor: $actor, + action: 'finding.closed', + context: [ + 'metadata' => [ + 'closed_reason' => $reason, + 'closed_at' => $now->toIso8601String(), + ], + ], + mutate: function (Finding $record) use ($reason, $now, $actor): void { + $record->status = Finding::STATUS_CLOSED; + $record->closed_reason = $reason; + $record->closed_at = $now; + $record->closed_by_user_id = (int) $actor->getKey(); + }, + ); + } + + public function riskAccept(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding + { + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RISK_ACCEPT]); + + $reason = $this->validatedReason($reason, 'closed_reason'); + $now = CarbonImmutable::now(); + + return $this->mutateAndAudit( + finding: $finding, + tenant: $tenant, + actor: $actor, + action: 'finding.risk_accepted', + context: [ + 'metadata' => [ + 'closed_reason' => $reason, + 'closed_at' => $now->toIso8601String(), + ], + ], + mutate: function (Finding $record) use ($reason, $now, $actor): void { + $record->status = Finding::STATUS_RISK_ACCEPTED; + $record->closed_reason = $reason; + $record->closed_at = $now; + $record->closed_by_user_id = (int) $actor->getKey(); + }, + ); + } + + public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding + { + $this->authorize($finding, $tenant, $actor, [ + Capabilities::TENANT_FINDINGS_TRIAGE, + Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, + ]); + + if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) { + throw new InvalidArgumentException('Only terminal findings can be reopened.'); + } + + $now = CarbonImmutable::now(); + $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); + $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now); + + return $this->mutateAndAudit( + finding: $finding, + tenant: $tenant, + actor: $actor, + action: 'finding.reopened', + context: [ + 'metadata' => [ + 'reopened_at' => $now->toIso8601String(), + 'sla_days' => $slaDays, + 'due_at' => $dueAt->toIso8601String(), + ], + ], + mutate: function (Finding $record) use ($now, $slaDays, $dueAt): void { + $record->status = Finding::STATUS_REOPENED; + $record->reopened_at = $now; + $record->resolved_at = null; + $record->resolved_reason = null; + $record->closed_at = null; + $record->closed_reason = null; + $record->closed_by_user_id = null; + $record->sla_days = $slaDays; + $record->due_at = $dueAt; + }, + ); + } + + /** + * @param array $capabilities + */ + private function authorize(Finding $finding, Tenant $tenant, User $actor, array $capabilities): void + { + if (! $actor->canAccessTenant($tenant)) { + throw new NotFoundHttpException; + } + + if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { + throw new NotFoundHttpException; + } + + if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) { + throw new NotFoundHttpException; + } + + foreach ($capabilities as $capability) { + if ($this->capabilityResolver->can($actor, $tenant, $capability)) { + return; + } + } + + throw new AuthorizationException('Missing capability for finding workflow action.'); + } + + private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $field): void + { + if ($userId === null) { + return; + } + + if ($userId <= 0) { + throw new InvalidArgumentException(sprintf('%s must be a positive user id.', $field)); + } + + $isMember = TenantMembership::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('user_id', $userId) + ->exists(); + + if (! $isMember) { + throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field)); + } + } + + private function validatedReason(string $reason, string $field): string + { + $reason = trim($reason); + + if ($reason === '') { + throw new InvalidArgumentException(sprintf('%s is required.', $field)); + } + + if (mb_strlen($reason) > 255) { + throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field)); + } + + return $reason; + } + + /** + * @param array $context + */ + private function mutateAndAudit( + Finding $finding, + Tenant $tenant, + User $actor, + string $action, + array $context, + callable $mutate, + ): Finding { + $before = $this->auditSnapshot($finding); + + DB::transaction(function () use ($finding, $mutate): void { + $mutate($finding); + $finding->save(); + }); + + $finding->refresh(); + + $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; + $metadata = array_merge($metadata, [ + 'finding_id' => (int) $finding->getKey(), + 'before_status' => $before['status'] ?? null, + 'after_status' => $finding->status, + 'before' => $before, + 'after' => $this->auditSnapshot($finding), + ]); + + $this->auditLogger->log( + tenant: $tenant, + action: $action, + actorId: (int) $actor->getKey(), + actorEmail: $actor->email, + actorName: $actor->name, + resourceType: 'finding', + resourceId: (string) $finding->getKey(), + context: ['metadata' => $metadata], + ); + + return $finding; + } + + /** + * @return array + */ + private function auditSnapshot(Finding $finding): array + { + return [ + 'status' => $finding->status, + 'severity' => $finding->severity, + 'due_at' => $finding->due_at?->toIso8601String(), + 'sla_days' => $finding->sla_days, + 'assignee_user_id' => $finding->assignee_user_id, + 'owner_user_id' => $finding->owner_user_id, + 'triaged_at' => $finding->triaged_at?->toIso8601String(), + 'in_progress_at' => $finding->in_progress_at?->toIso8601String(), + 'reopened_at' => $finding->reopened_at?->toIso8601String(), + 'resolved_at' => $finding->resolved_at?->toIso8601String(), + 'resolved_reason' => $finding->resolved_reason, + 'closed_at' => $finding->closed_at?->toIso8601String(), + 'closed_reason' => $finding->closed_reason, + 'closed_by_user_id' => $finding->closed_by_user_id, + ]; + } +} diff --git a/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php b/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php index 005cbe1..c69f6e0 100644 --- a/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +++ b/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php @@ -9,6 +9,8 @@ use App\Models\OperationRun; use App\Models\StoredReport; use App\Models\Tenant; +use App\Services\Findings\FindingSlaPolicy; +use Carbon\CarbonImmutable; /** * Generates, auto-resolves, and re-opens permission posture findings @@ -18,6 +20,7 @@ final class PermissionPostureFindingGenerator implements FindingGeneratorContrac { public function __construct( private readonly PostureScoreCalculator $scoreCalculator, + private readonly FindingSlaPolicy $slaPolicy, ) {} /** @@ -28,6 +31,8 @@ public function generate(Tenant $tenant, array $permissionComparison, ?Operation $permissions = $permissionComparison['permissions'] ?? []; $permissions = is_array($permissions) ? $permissions : []; + $observedAt = $this->resolveObservedAt($permissionComparison, $operationRun); + $created = 0; $resolved = 0; $reopened = 0; @@ -53,14 +58,14 @@ public function generate(Tenant $tenant, array $permissionComparison, ?Operation $processedPermissionKeys[] = $key; if ($status === 'error') { - $this->handleErrorPermission($tenant, $key, $type, $features, $operationRun); + $this->handleErrorPermission($tenant, $key, $type, $features, $observedAt, $operationRun); $errors++; continue; } if ($status === 'missing') { - $result = $this->handleMissingPermission($tenant, $key, $type, $features, $operationRun); + $result = $this->handleMissingPermission($tenant, $key, $type, $features, $observedAt, $operationRun); if ($result === 'created') { $created++; @@ -76,13 +81,13 @@ public function generate(Tenant $tenant, array $permissionComparison, ?Operation } // status === 'granted' - if ($this->resolveExistingFinding($tenant, $key, 'permission_granted')) { + if ($this->resolveExistingFinding($tenant, $key, 'permission_granted', $observedAt)) { $resolved++; } } // Step 9: Resolve stale findings for permissions removed from registry - $resolved += $this->resolveStaleFindings($tenant, $processedPermissionKeys); + $resolved += $this->resolveStaleFindings($tenant, $processedPermissionKeys, $observedAt); $postureScore = $this->scoreCalculator->calculate($permissionComparison); @@ -115,28 +120,60 @@ private function handleMissingPermission( string $key, string $type, array $features, + CarbonImmutable $observedAt, ?OperationRun $operationRun, ): string { $fingerprint = $this->fingerprint($tenant, $key); - $evidence = $this->buildEvidence($key, $type, 'missing', $features); + $evidence = $this->buildEvidence($key, $type, 'missing', $features, $observedAt); $severity = $this->deriveSeverity(count($features)); $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->where('fingerprint', $fingerprint) ->first(); if ($finding instanceof Finding) { - if ($finding->status === Finding::STATUS_RESOLVED) { - $finding->reopen($evidence); + $this->observeFinding($finding, $observedAt); - return 'reopened'; + $finding->forceFill([ + 'severity' => $severity, + 'evidence_jsonb' => $evidence, + 'current_operation_run_id' => $operationRun?->getKey(), + ]); + + if ($finding->status === Finding::STATUS_RESOLVED) { + $resolvedAt = $finding->resolved_at; + + if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { + $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); + + $finding->forceFill([ + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => $observedAt, + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => null, + 'closed_reason' => null, + 'closed_by_user_id' => null, + 'sla_days' => $slaDays, + 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), + ]); + + $finding->save(); + + return 'reopened'; + } } // Already open (new or acknowledged) — unchanged + $finding->save(); + return 'unchanged'; } + $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); + Finding::create([ 'tenant_id' => (int) $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE, @@ -149,6 +186,11 @@ private function handleMissingPermission( 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), + 'first_seen_at' => $observedAt, + 'last_seen_at' => $observedAt, + 'times_seen' => 1, + 'sla_days' => $slaDays, + 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); return 'created'; @@ -159,24 +201,57 @@ private function handleErrorPermission( string $key, string $type, array $features, + CarbonImmutable $observedAt, ?OperationRun $operationRun, ): void { $fingerprint = $this->errorFingerprint($tenant, $key); - $evidence = $this->buildEvidence($key, $type, 'error', $features); + $evidence = $this->buildEvidence($key, $type, 'error', $features, $observedAt); $evidence['check_error'] = true; + $severity = Finding::SEVERITY_LOW; $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->where('fingerprint', $fingerprint) ->first(); if ($existing instanceof Finding) { - $existing->update(['evidence_jsonb' => $evidence]); + $this->observeFinding($existing, $observedAt); + + $existing->forceFill([ + 'severity' => $severity, + 'evidence_jsonb' => $evidence, + 'current_operation_run_id' => $operationRun?->getKey(), + ]); + + if ($existing->status === Finding::STATUS_RESOLVED) { + $resolvedAt = $existing->resolved_at; + + if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { + $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); + + $existing->forceFill([ + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => $observedAt, + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => null, + 'closed_reason' => null, + 'closed_by_user_id' => null, + 'sla_days' => $slaDays, + 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), + ]); + } + } + + $existing->save(); return; } + $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); + Finding::create([ 'tenant_id' => (int) $tenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE, @@ -185,28 +260,38 @@ private function handleErrorPermission( 'fingerprint' => $fingerprint, 'subject_type' => 'permission', 'subject_external_id' => $key, - 'severity' => Finding::SEVERITY_LOW, + 'severity' => $severity, 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), + 'first_seen_at' => $observedAt, + 'last_seen_at' => $observedAt, + 'times_seen' => 1, + 'sla_days' => $slaDays, + 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); } - private function resolveExistingFinding(Tenant $tenant, string $key, string $reason): bool + private function resolveExistingFinding(Tenant $tenant, string $key, string $reason, CarbonImmutable $observedAt): bool { $fingerprint = $this->fingerprint($tenant, $key); $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->where('fingerprint', $fingerprint) - ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) + ->whereIn('status', Finding::openStatusesForQuery()) ->first(); if (! $finding instanceof Finding) { return false; } - $finding->resolve($reason); + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => $observedAt, + 'resolved_reason' => $reason, + ])->save(); return true; } @@ -215,12 +300,12 @@ private function resolveExistingFinding(Tenant $tenant, string $key, string $rea * Resolve any open permission_posture findings whose permission_key is not * in the current comparison (handles registry removals). */ - private function resolveStaleFindings(Tenant $tenant, array $processedPermissionKeys): int + private function resolveStaleFindings(Tenant $tenant, array $processedPermissionKeys, CarbonImmutable $observedAt): int { $staleFindings = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) - ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) + ->whereIn('status', Finding::openStatusesForQuery()) ->get(); $resolved = 0; @@ -235,7 +320,11 @@ private function resolveStaleFindings(Tenant $tenant, array $processedPermission } if ($permissionKey !== null && ! in_array($permissionKey, $processedPermissionKeys, true)) { - $finding->resolve('permission_removed_from_registry'); + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => $observedAt, + 'resolved_reason' => 'permission_removed_from_registry', + ])->save(); $resolved++; } } @@ -243,6 +332,46 @@ private function resolveStaleFindings(Tenant $tenant, array $processedPermission return $resolved; } + private function resolveObservedAt(array $comparison, ?OperationRun $operationRun): CarbonImmutable + { + if ($operationRun?->completed_at !== null) { + return CarbonImmutable::instance($operationRun->completed_at); + } + + $refreshedAt = $comparison['last_refreshed_at'] ?? null; + + if (is_string($refreshedAt) && trim($refreshedAt) !== '') { + try { + return CarbonImmutable::parse($refreshedAt); + } catch (\Throwable) { + // Fall through. + } + } + + return CarbonImmutable::now(); + } + + private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void + { + if ($finding->first_seen_at === null) { + $finding->first_seen_at = $observedAt; + } + + $lastSeenAt = $finding->last_seen_at; + $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; + + if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { + $finding->last_seen_at = $observedAt; + $finding->times_seen = max(0, $timesSeen) + 1; + + return; + } + + if ($timesSeen < 1) { + $finding->times_seen = 1; + } + } + /** * @param array> $permissions */ @@ -313,7 +442,7 @@ private function buildAlertEvent(Tenant $tenant, string $key, string $type, arra /** * @return array */ - private function buildEvidence(string $key, string $type, string $actualStatus, array $features): array + private function buildEvidence(string $key, string $type, string $actualStatus, array $features, CarbonImmutable $observedAt): array { return [ 'permission_key' => $key, @@ -321,7 +450,7 @@ private function buildEvidence(string $key, string $type, string $actualStatus, 'expected_status' => 'granted', 'actual_status' => $actualStatus, 'blocked_features' => $features, - 'checked_at' => now()->toIso8601String(), + 'checked_at' => $observedAt->toIso8601String(), ]; } diff --git a/app/Services/ReviewPackService.php b/app/Services/ReviewPackService.php index e05c4a7..b1b2bc4 100644 --- a/app/Services/ReviewPackService.php +++ b/app/Services/ReviewPackService.php @@ -85,7 +85,7 @@ public function computeFingerprint(Tenant $tenant, array $options): string $maxFindingDate = Finding::query() ->where('tenant_id', (int) $tenant->getKey()) - ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED]) + ->whereIn('status', Finding::openStatusesForQuery()) ->max('updated_at'); $data = [ diff --git a/app/Services/Settings/SettingsResolver.php b/app/Services/Settings/SettingsResolver.php index 363c0cd..327079c 100644 --- a/app/Services/Settings/SettingsResolver.php +++ b/app/Services/Settings/SettingsResolver.php @@ -8,6 +8,7 @@ use App\Models\TenantSetting; use App\Models\Workspace; use App\Models\WorkspaceSetting; +use App\Support\Settings\SettingDefinition; use App\Support\Settings\SettingsRegistry; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -43,22 +44,24 @@ public function resolveDetailed(Workspace $workspace, string $domain, string $ke : null; $source = 'system_default'; - $value = $definition->systemDefault; + $rawValue = $definition->systemDefault; if ($workspaceValue !== null) { $source = 'workspace_override'; - $value = $workspaceValue; + $rawValue = $workspaceValue; } if ($tenantValue !== null) { $source = 'tenant_override'; - $value = $tenantValue; + $rawValue = $tenantValue; } + $effectiveValue = $this->mergeWithDefault($definition, $rawValue); + return $this->resolved[$cacheKey] = [ 'domain' => $domain, 'key' => $key, - 'value' => $value, + 'value' => $effectiveValue, 'source' => $source, 'system_default' => $definition->systemDefault, 'workspace_value' => $workspaceValue, @@ -76,6 +79,24 @@ public function clearCache(): void $this->resolved = []; } + /** + * For JSON settings that store partial overrides (e.g. SLA days with only + * some severities set), merge the stored partial with the system default + * so consumers always receive a complete value. + */ + private function mergeWithDefault(SettingDefinition $definition, mixed $value): mixed + { + if ($definition->type !== 'json') { + return $value; + } + + if (! is_array($value) || ! is_array($definition->systemDefault)) { + return $value; + } + + return array_replace($definition->systemDefault, $value); + } + private function workspaceOverrideValue(Workspace $workspace, string $domain, string $key): mixed { $setting = WorkspaceSetting::query() diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 10b3cf2..8c83eff 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -69,6 +69,18 @@ class Capabilities public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run'; // Findings + public const TENANT_FINDINGS_VIEW = 'tenant_findings.view'; + + public const TENANT_FINDINGS_TRIAGE = 'tenant_findings.triage'; + + public const TENANT_FINDINGS_ASSIGN = 'tenant_findings.assign'; + + public const TENANT_FINDINGS_RESOLVE = 'tenant_findings.resolve'; + + public const TENANT_FINDINGS_CLOSE = 'tenant_findings.close'; + + public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept'; + public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge'; // Verification diff --git a/app/Support/Badges/Domains/FindingStatusBadge.php b/app/Support/Badges/Domains/FindingStatusBadge.php index 8d47e9a..214bad3 100644 --- a/app/Support/Badges/Domains/FindingStatusBadge.php +++ b/app/Support/Badges/Domains/FindingStatusBadge.php @@ -11,12 +11,16 @@ final class FindingStatusBadge implements BadgeMapper { public function spec(mixed $value): BadgeSpec { - $state = BadgeCatalog::normalizeState($value); + $state = Finding::canonicalizeStatus(BadgeCatalog::normalizeState($value)); return match ($state) { Finding::STATUS_NEW => new BadgeSpec('New', 'warning', 'heroicon-m-clock'), - Finding::STATUS_ACKNOWLEDGED => new BadgeSpec('Acknowledged', 'gray', 'heroicon-m-check-circle'), + Finding::STATUS_TRIAGED => new BadgeSpec('Triaged', 'gray', 'heroicon-m-check-circle'), + Finding::STATUS_IN_PROGRESS => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'), + Finding::STATUS_REOPENED => new BadgeSpec('Reopened', 'danger', 'heroicon-m-arrow-uturn-left'), Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'success', 'heroicon-o-check-circle'), + Finding::STATUS_CLOSED => new BadgeSpec('Closed', 'gray', 'heroicon-o-x-circle'), + Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'), default => BadgeSpec::unknown(), }; } diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index a420fb1..b267e7f 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -53,6 +53,7 @@ public static function labels(): array 'entra.admin_roles.scan' => 'Entra admin roles scan', 'tenant.review_pack.generate' => 'Review pack generation', 'rbac.health_check' => 'RBAC health check', + 'findings.lifecycle.backfill' => 'Findings lifecycle backfill', ]; } @@ -86,6 +87,7 @@ public static function expectedDurationSeconds(string $operationType): ?int 'entra.admin_roles.scan' => 60, 'tenant.review_pack.generate' => 60, 'rbac.health_check' => 30, + 'findings.lifecycle.backfill' => 300, default => null, }; } diff --git a/app/Support/Settings/SettingsRegistry.php b/app/Support/Settings/SettingsRegistry.php index efdade6..ec84937 100644 --- a/app/Support/Settings/SettingsRegistry.php +++ b/app/Support/Settings/SettingsRegistry.php @@ -80,6 +80,59 @@ static function (string $attribute, mixed $value, \Closure $fail): void { normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value), )); + $this->register(new SettingDefinition( + domain: 'findings', + key: 'sla_days', + type: 'json', + systemDefault: self::defaultFindingsSlaDays(), + rules: [ + 'required', + 'array', + static function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_array($value)) { + $fail('The findings SLA days setting must be a JSON object.'); + + return; + } + + $supportedSeverities = self::supportedFindingSeverities(); + $supportedMap = array_fill_keys($supportedSeverities, true); + + foreach ($value as $severity => $days) { + if (! is_string($severity)) { + $fail('Each findings SLA key must be a severity string.'); + + return; + } + + $normalizedSeverity = strtolower($severity); + + if (! isset($supportedMap[$normalizedSeverity])) { + $fail(sprintf( + 'Unsupported findings SLA severity "%s". Expected only: %s.', + $severity, + implode(', ', $supportedSeverities), + )); + + return; + } + + $normalizedDays = filter_var($days, FILTER_VALIDATE_INT); + + if ($normalizedDays === false || $normalizedDays < 1 || $normalizedDays > 3650) { + $fail(sprintf( + 'Findings SLA days for "%s" must be an integer between 1 and 3650.', + $normalizedSeverity, + )); + + return; + } + } + }, + ], + normalizer: static fn (mixed $value): array => self::normalizeFindingsSlaDays($value), + )); + $this->register(new SettingDefinition( domain: 'operations', key: 'operation_run_retention_days', @@ -169,4 +222,59 @@ private static function normalizeSeverityMapping(mixed $value): array return $normalized; } + + /** + * @return array + */ + private static function defaultFindingsSlaDays(): array + { + return [ + Finding::SEVERITY_CRITICAL => 3, + Finding::SEVERITY_HIGH => 7, + Finding::SEVERITY_MEDIUM => 14, + Finding::SEVERITY_LOW => 30, + ]; + } + + /** + * @return array + */ + private static function normalizeFindingsSlaDays(mixed $value): array + { + if (! is_array($value)) { + return self::defaultFindingsSlaDays(); + } + + $normalized = []; + + foreach ($value as $severity => $days) { + if (! is_string($severity)) { + continue; + } + + $normalizedSeverity = strtolower($severity); + + if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) { + continue; + } + + $normalizedDays = filter_var($days, FILTER_VALIDATE_INT); + + if ($normalizedDays === false) { + continue; + } + + $normalized[$normalizedSeverity] = (int) $normalizedDays; + } + + $ordered = []; + + foreach (self::defaultFindingsSlaDays() as $severity => $_default) { + if (array_key_exists($severity, $normalized)) { + $ordered[$severity] = $normalized[$severity]; + } + } + + return $ordered; + } } diff --git a/database/factories/FindingFactory.php b/database/factories/FindingFactory.php index 635a16a..6abf42c 100644 --- a/database/factories/FindingFactory.php +++ b/database/factories/FindingFactory.php @@ -18,6 +18,8 @@ class FindingFactory extends Factory */ public function definition(): array { + $seenAt = now(); + return [ 'tenant_id' => Tenant::factory(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, @@ -31,6 +33,20 @@ public function definition(): array 'status' => Finding::STATUS_NEW, 'acknowledged_at' => null, 'acknowledged_by_user_id' => null, + 'first_seen_at' => $seenAt, + 'last_seen_at' => $seenAt, + 'times_seen' => 1, + 'sla_days' => 14, + 'due_at' => $seenAt->copy()->addDays(14), + 'owner_user_id' => null, + 'assignee_user_id' => null, + 'triaged_at' => null, + 'in_progress_at' => null, + 'reopened_at' => null, + 'closed_at' => null, + 'closed_by_user_id' => null, + 'closed_reason' => null, + 'recurrence_key' => null, 'evidence_jsonb' => [], ]; } diff --git a/database/migrations/2026_02_24_160000_add_finding_lifecycle_v2_fields_to_findings_table.php b/database/migrations/2026_02_24_160000_add_finding_lifecycle_v2_fields_to_findings_table.php new file mode 100644 index 0000000..3c31655 --- /dev/null +++ b/database/migrations/2026_02_24_160000_add_finding_lifecycle_v2_fields_to_findings_table.php @@ -0,0 +1,59 @@ +timestampTz('first_seen_at')->nullable(); + $table->timestampTz('last_seen_at')->nullable(); + $table->integer('times_seen')->nullable()->default(0); + + $table->smallInteger('sla_days')->nullable(); + $table->timestampTz('due_at')->nullable(); + + $table->foreignId('owner_user_id')->nullable()->constrained('users'); + $table->foreignId('assignee_user_id')->nullable()->constrained('users'); + + $table->timestampTz('triaged_at')->nullable(); + $table->timestampTz('in_progress_at')->nullable(); + $table->timestampTz('reopened_at')->nullable(); + + $table->timestampTz('closed_at')->nullable(); + $table->foreignId('closed_by_user_id')->nullable()->constrained('users'); + $table->string('closed_reason', 255)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('findings', function (Blueprint $table): void { + $table->dropConstrainedForeignId('closed_by_user_id'); + $table->dropConstrainedForeignId('assignee_user_id'); + $table->dropConstrainedForeignId('owner_user_id'); + + $table->dropColumn([ + 'first_seen_at', + 'last_seen_at', + 'times_seen', + 'sla_days', + 'due_at', + 'triaged_at', + 'in_progress_at', + 'reopened_at', + 'closed_at', + 'closed_reason', + ]); + }); + } +}; diff --git a/database/migrations/2026_02_24_160001_add_finding_recurrence_key_and_sla_indexes_to_findings_table.php b/database/migrations/2026_02_24_160001_add_finding_recurrence_key_and_sla_indexes_to_findings_table.php new file mode 100644 index 0000000..23dc803 --- /dev/null +++ b/database/migrations/2026_02_24_160001_add_finding_recurrence_key_and_sla_indexes_to_findings_table.php @@ -0,0 +1,38 @@ +string('recurrence_key', 64)->nullable(); + + $table->index(['tenant_id', 'status', 'due_at'], 'findings_tenant_status_due_at_idx'); + $table->index(['tenant_id', 'assignee_user_id'], 'findings_tenant_assignee_idx'); + $table->index(['tenant_id', 'recurrence_key'], 'findings_tenant_recurrence_key_idx'); + $table->index(['workspace_id', 'status', 'due_at'], 'findings_workspace_status_due_at_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('findings', function (Blueprint $table): void { + $table->dropIndex('findings_workspace_status_due_at_idx'); + $table->dropIndex('findings_tenant_recurrence_key_idx'); + $table->dropIndex('findings_tenant_assignee_idx'); + $table->dropIndex('findings_tenant_status_due_at_idx'); + + $table->dropColumn('recurrence_key'); + }); + } +}; diff --git a/database/migrations/2026_02_24_160002_enforce_not_null_on_finding_seen_fields.php b/database/migrations/2026_02_24_160002_enforce_not_null_on_finding_seen_fields.php new file mode 100644 index 0000000..6a966fe --- /dev/null +++ b/database/migrations/2026_02_24_160002_enforce_not_null_on_finding_seen_fields.php @@ -0,0 +1,82 @@ +whereNull('first_seen_at') + ->update(['first_seen_at' => DB::raw('created_at')]); + + DB::table('findings') + ->whereNull('last_seen_at') + ->update(['last_seen_at' => DB::raw('COALESCE(first_seen_at, created_at)')]); + + DB::table('findings') + ->whereNull('times_seen') + ->orWhere('times_seen', '<', 1) + ->update(['times_seen' => 1]); + + if ($driver === 'sqlite') { + return; + } + + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE findings ALTER COLUMN first_seen_at SET NOT NULL'); + DB::statement('ALTER TABLE findings ALTER COLUMN last_seen_at SET NOT NULL'); + DB::statement('ALTER TABLE findings ALTER COLUMN times_seen SET NOT NULL'); + + return; + } + + if ($driver === 'mysql') { + DB::statement('ALTER TABLE findings MODIFY first_seen_at TIMESTAMP NOT NULL'); + DB::statement('ALTER TABLE findings MODIFY last_seen_at TIMESTAMP NOT NULL'); + DB::statement('ALTER TABLE findings MODIFY times_seen INT NOT NULL'); + } + } + + public function down(): void + { + if (! Schema::hasTable('findings')) { + return; + } + + $driver = DB::getDriverName(); + + if ($driver === 'sqlite') { + return; + } + + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE findings ALTER COLUMN first_seen_at DROP NOT NULL'); + DB::statement('ALTER TABLE findings ALTER COLUMN last_seen_at DROP NOT NULL'); + DB::statement('ALTER TABLE findings ALTER COLUMN times_seen DROP NOT NULL'); + + return; + } + + if ($driver === 'mysql') { + DB::statement('ALTER TABLE findings MODIFY first_seen_at TIMESTAMP NULL'); + DB::statement('ALTER TABLE findings MODIFY last_seen_at TIMESTAMP NULL'); + DB::statement('ALTER TABLE findings MODIFY times_seen INT NULL'); + } + } +}; diff --git a/public/js/tenantpilot/filament-sidebar-store-fallback.js b/public/js/tenantpilot/filament-sidebar-store-fallback.js new file mode 100644 index 0000000..f9e6e71 --- /dev/null +++ b/public/js/tenantpilot/filament-sidebar-store-fallback.js @@ -0,0 +1,163 @@ +(() => { + if (typeof window === 'undefined') { + return; + } + + if (window.__tenantpilotSidebarStoreFallbackApplied) { + return; + } + + window.__tenantpilotSidebarStoreFallbackApplied = true; + + const ensureSidebarStore = () => { + const Alpine = window.Alpine; + + if (!Alpine || typeof Alpine.store !== 'function') { + return false; + } + + try { + const existing = Alpine.store('sidebar'); + + if (existing && typeof existing === 'object') { + return true; + } + } catch { + // Alpine.store('sidebar') can throw if stores aren't ready yet. + } + + const storeFactory = () => { + return { + isOpen: true, + isOpenDesktop: true, + collapsedGroups: [], + + resizeObserver: null, + + init() { + this.setUpResizeObserver(); + + document.addEventListener('livewire:navigated', () => { + this.setUpResizeObserver(); + }); + }, + + setUpResizeObserver() { + if (typeof ResizeObserver === 'undefined') { + return; + } + + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + + let previousWidth = window.innerWidth; + + this.resizeObserver = new ResizeObserver(() => { + const currentWidth = window.innerWidth; + + const wasDesktop = previousWidth >= 1024; + const isNowMobile = currentWidth < 1024; + const isNowDesktop = currentWidth >= 1024; + + if (wasDesktop && isNowMobile) { + this.isOpenDesktop = this.isOpen; + + if (this.isOpen) { + this.close(); + } + } else if (!wasDesktop && isNowDesktop) { + this.isOpen = this.isOpenDesktop; + } + + previousWidth = currentWidth; + }); + + this.resizeObserver.observe(document.body); + + if (window.innerWidth < 1024) { + if (this.isOpen) { + this.isOpenDesktop = true; + this.close(); + } + } else { + this.isOpenDesktop = this.isOpen; + } + }, + + groupIsCollapsed(label) { + if (!Array.isArray(this.collapsedGroups)) { + this.collapsedGroups = []; + } + + return this.collapsedGroups.includes(label); + }, + + collapseGroup(label) { + if (!this.groupIsCollapsed(label)) { + this.collapsedGroups = this.collapsedGroups.concat(label); + } + }, + + toggleCollapsedGroup(label) { + if (this.groupIsCollapsed(label)) { + this.collapsedGroups = this.collapsedGroups.filter((item) => item !== label); + } else { + this.collapsedGroups = this.collapsedGroups.concat(label); + } + }, + + close() { + this.isOpen = false; + + if (window.innerWidth >= 1024) { + this.isOpenDesktop = false; + } + }, + + open() { + this.isOpen = true; + + if (window.innerWidth >= 1024) { + this.isOpenDesktop = true; + } + }, + }; + }; + + try { + Alpine.store('sidebar', storeFactory()); + + return true; + } catch { + return false; + } + }; + + const tryEnsure = () => { + ensureSidebarStore(); + }; + + window.addEventListener('alpine:init', tryEnsure); + window.addEventListener('alpine:initialized', tryEnsure); + document.addEventListener('alpine:init', tryEnsure); + document.addEventListener('alpine:initialized', tryEnsure); + document.addEventListener('livewire:navigated', tryEnsure); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', tryEnsure, { once: true }); + } else { + tryEnsure(); + } + + let tries = 0; + const maxTries = 50; + + const timer = setInterval(() => { + tries += 1; + + if (ensureSidebarStore() || tries >= maxTries) { + clearInterval(timer); + } + }, 100); +})(); diff --git a/resources/views/filament/partials/livewire-intercept-shim.blade.php b/resources/views/filament/partials/livewire-intercept-shim.blade.php index 52372ef..ef95263 100644 --- a/resources/views/filament/partials/livewire-intercept-shim.blade.php +++ b/resources/views/filament/partials/livewire-intercept-shim.blade.php @@ -1 +1,2 @@ + diff --git a/specs/111-findings-workflow-sla/checklists/requirements.md b/specs/111-findings-workflow-sla/checklists/requirements.md new file mode 100644 index 0000000..11b3195 --- /dev/null +++ b/specs/111-findings-workflow-sla/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Findings Workflow V2 + SLA + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-24 +**Feature**: [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 + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` +- Validation pass: 2026-02-24 diff --git a/specs/111-findings-workflow-sla/contracts/api-contracts.md b/specs/111-findings-workflow-sla/contracts/api-contracts.md new file mode 100644 index 0000000..dcd9cf0 --- /dev/null +++ b/specs/111-findings-workflow-sla/contracts/api-contracts.md @@ -0,0 +1,122 @@ +# API Contracts: 111 — Findings Workflow V2 + SLA + +**Date**: 2026-02-24 +**Branch**: `111-findings-workflow-sla` + +--- + +## Overview + +This feature does not introduce new external REST endpoints. User interaction is through Filament/Livewire actions on the Findings Resource and Alert Rules configuration. The only new cross-module “contract” is the `sla_due` alert event produced during scheduled alert evaluation and consumed by the Alerts dispatch pipeline. + +--- + +## 1. Alert Event Contract: `sla_due` + +**Producer:** `App\Jobs\Alerts\EvaluateAlertsJob` +**Event Type:** `AlertRule::EVENT_SLA_DUE` (`sla_due`) +**Event Cardinality:** At most 1 event per tenant per evaluation window (when newly-overdue findings exist) + +### Eligibility (Per Tenant) + +An event is produced when a tenant has one or more **newly-overdue** open findings since the previous evaluation window: +- `status IN (new, triaged, in_progress, reopened)` +- `due_at <= now()` +- `due_at > windowStart` + +Terminal statuses (`resolved`, `closed`, `risk_accepted`) never contribute to overdue evaluation. + +### Event Payload Shape + +```json +{ + "event_type": "sla_due", + "tenant_id": 123, + "severity": "high", + "fingerprint_key": "sla_due:tenant:123", + "title": "SLA overdue findings detected", + "body": "Tenant Contoso has 5 overdue open findings (critical: 1, high: 2, medium: 2, low: 0).", + "metadata": { + "overdue_total": 5, + "overdue_by_severity": { + "critical": 1, + "high": 2, + "medium": 2, + "low": 0 + } + } +} +``` + +### Severity Semantics + +`severity` is the maximum severity among overdue open findings for that tenant at evaluation time. + +Rationale: alert rules can use `minimum_severity`; a critical-overdue case can bypass a “high-only” rule. + +--- + +## 2. Filament Findings Resource: Workflow Actions Contract + +All workflow actions: +- enforce tenant membership as deny-as-not-found (404) for non-members +- enforce capability checks (403 for members lacking capability) +- write an audit log entry with before/after and any reason fields + +### List Defaults + +Default list shows: +- all finding types (no drift-only default) +- open statuses only: `new`, `triaged`, `in_progress`, `reopened` + +Quick filters: +- Open +- Overdue (`due_at < now()` and open statuses) +- High severity (high + critical) +- My assigned (`assignee_user_id = current user`) + +### Row Actions (More menu) + +| Action | Allowed From Status | To Status | Capability | Confirmation | Notes | +|--------|---------------------|----------|------------|--------------|------| +| Triage | `new`, `reopened` | `triaged` | `TENANT_FINDINGS_TRIAGE` (or legacy `TENANT_FINDINGS_ACKNOWLEDGE` alias) | No | Sets `triaged_at` | +| Start progress | `triaged` | `in_progress` | `TENANT_FINDINGS_TRIAGE` (or legacy alias) | No | Sets `in_progress_at` | +| Assign | open statuses | unchanged | `TENANT_FINDINGS_ASSIGN` | No | Sets `assignee_user_id` and optional `owner_user_id` (picker limited to tenant members) | +| Resolve | open statuses | `resolved` | `TENANT_FINDINGS_RESOLVE` | Yes | Requires `resolved_reason`; sets `resolved_at` | +| Close | any status | `closed` | `TENANT_FINDINGS_CLOSE` | Yes | Requires `closed_reason`; sets `closed_at` + `closed_by_user_id` | +| Risk accept | any status | `risk_accepted` | `TENANT_FINDINGS_RISK_ACCEPT` | Yes | Requires reason (stored as `closed_reason`); sets `closed_at` + `closed_by_user_id` | +| Reopen | `resolved`, `closed`, `risk_accepted` | `reopened` | `TENANT_FINDINGS_TRIAGE` (or legacy alias) | Yes | Manual reopen only. Automatic reopen is allowed only from `resolved` during detection. | + +### Bulk Actions + +Bulk actions are all-or-nothing (if any record is unauthorized for the current tenant context, the action is disabled): + +| Action | Capability | Confirmation | Notes | +|--------|------------|--------------|------| +| Bulk triage | `TENANT_FINDINGS_TRIAGE` (or legacy alias) | Yes (typed confirm for large selections) | Moves `new|reopened → triaged` | +| Bulk assign | `TENANT_FINDINGS_ASSIGN` | Yes (typed confirm for large selections) | Assignee/owner pickers limited to tenant members | +| Bulk resolve | `TENANT_FINDINGS_RESOLVE` | Yes | Reason required | +| Bulk close | `TENANT_FINDINGS_CLOSE` | Yes | Reason required | +| Bulk risk accept | `TENANT_FINDINGS_RISK_ACCEPT` | Yes | Reason required | + +--- + +## 3. Backfill/Consolidation Operation Contract + +Backfill is a tenant-context operation that upgrades legacy findings to v2 lifecycle fields and consolidates drift duplicates. It MUST be OperationRun-backed and use OPS-UX feedback surfaces (queued toast, progress surfaces, initiator-only completion notification). + +**OperationRun type:** `findings.lifecycle.backfill` (label registered in OperationCatalog) +**Idempotency:** One active run per tenant (deduped by OperationRun identity) + +Summary counts use canonical numeric keys only (e.g., `total`, `processed`, `updated`, `failed`, `skipped`). + +--- + +## 4. Audit Contract (Tenant Scope) + +Every workflow mutation writes a tenant audit record (via `App\Services\Intune\AuditLogger`) with: +- `action` string (e.g., `finding.triaged`, `finding.resolved`, `finding.closed`, `finding.risk_accepted`, `finding.reopened`, `findings.lifecycle.backfill.started`) +- `metadata` including: `finding_id`, `before_status`, `after_status`, and any reason fields or assignment deltas + +Audit payloads must remain sanitized and must not include secrets/tokens. + diff --git a/specs/111-findings-workflow-sla/data-model.md b/specs/111-findings-workflow-sla/data-model.md new file mode 100644 index 0000000..7fcfb92 --- /dev/null +++ b/specs/111-findings-workflow-sla/data-model.md @@ -0,0 +1,98 @@ +# Data Model: 111 — Findings Workflow V2 + SLA + +**Date**: 2026-02-24 +**Branch**: `111-findings-workflow-sla` + +--- + +## Modified Entities + +### 1. `findings` Table (Lifecycle + SLA + Recurrence) + +This feature evolves `findings` from v1 (`new|acknowledged|resolved`) to a v2 workflow and adds lifecycle metadata and SLA fields. + +#### New Columns + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `first_seen_at` | `timestampTz` | nullable initially; NOT NULL after backfill | Set on first observation; backfill from `created_at` where possible | +| `last_seen_at` | `timestampTz` | nullable initially; NOT NULL after backfill | Updated on every observation (including terminal findings) | +| `times_seen` | `integer` | default `0`, NOT NULL (after backfill enforce) | Incremented on every observation | +| `sla_days` | `smallint` | nullable | SLA policy value applied when `due_at` was set/reset | +| `due_at` | `timestampTz` | nullable | Only “open” findings participate in SLA due evaluation | +| `owner_user_id` | `bigint` | FK → users, nullable | Retained even if user is no longer a tenant member | +| `assignee_user_id` | `bigint` | FK → users, nullable | Retained even if user is no longer a tenant member | +| `triaged_at` | `timestampTz` | nullable | Set on `new|reopened → triaged` | +| `in_progress_at` | `timestampTz` | nullable | Set on `triaged → in_progress` | +| `reopened_at` | `timestampTz` | nullable | Set when transitioning into `reopened` (manual or automatic) | +| `closed_at` | `timestampTz` | nullable | Used for both `closed` and `risk_accepted` terminal outcomes | +| `closed_by_user_id` | `bigint` | FK → users, nullable | Actor for `closed` / `risk_accepted` | +| `closed_reason` | `string` | nullable | Reason required for `closed` and `risk_accepted` | +| `recurrence_key` | `string(64)` | nullable; indexed | Stable identity for drift recurrence (v2) | + +#### Existing Columns Used/Extended + +| Column | Notes | +|--------|------| +| `status` | Extended v2 statuses (see below). Legacy `acknowledged` is mapped to v2 `triaged` in the UI and migrated during backfill. | +| `resolved_at` / `resolved_reason` | Remains the terminal “resolved” record with reason. | +| `acknowledged_at` / `acknowledged_by_user_id` | Retained for historical reference; `acknowledged` status is legacy. | +| `fingerprint` | Remains unique per tenant. For canonical drift rows going forward, the fingerprint is stable (aligned to recurrence identity). | + +#### Status Values (Canonical) + +Open statuses: +- `new` +- `triaged` +- `in_progress` +- `reopened` + +Terminal statuses: +- `resolved` +- `closed` +- `risk_accepted` + +Legacy status (migration window): +- `acknowledged` (treated as `triaged` in v2 surfaces) + +#### Indexes + +New/updated indexes to support list filters and alert evaluation: + +| Index | Type | Purpose | +|------|------|---------| +| `(tenant_id, status, due_at)` | btree | Open/overdue filtering in tenant UI | +| `(tenant_id, assignee_user_id)` | btree | “My assigned” filter | +| `(tenant_id, recurrence_key)` | btree | Drift recurrence lookups and consolidation | +| `(workspace_id, status, due_at)` | btree | Workspace-scoped SLA due producer query | + +Existing index `(tenant_id, status)` remains valid. + +--- + +## Configuration Keys (No Schema Change) + +### Settings: Findings SLA policy + +Add a SettingsRegistry entry (workspace-resolvable): +- `findings.sla_days` (JSON object): severity → days +- Default: + - critical: 3 + - high: 7 + - medium: 14 + - low: 30 + +Stored in existing `workspace_settings` / `tenant_settings` tables; no new tables required. + +--- + +## State Machine (High-Level) + +``` +new ──triage──> triaged ──start──> in_progress ──resolve──> resolved + └───────────────────────────────close/risk_accept──────────────> closed|risk_accepted + +resolved ──(auto or manual)──> reopened ──triage──> triaged ... +closed|risk_accepted ──(manual only)──> reopened +``` + diff --git a/specs/111-findings-workflow-sla/plan.md b/specs/111-findings-workflow-sla/plan.md new file mode 100644 index 0000000..525838a --- /dev/null +++ b/specs/111-findings-workflow-sla/plan.md @@ -0,0 +1,180 @@ +# Implementation Plan: Findings Workflow V2 + SLA + +**Branch**: `111-findings-workflow-sla` | **Date**: 2026-02-24 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/111-findings-workflow-sla/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/111-findings-workflow-sla/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Standardize the Findings lifecycle across all finding types (drift, permission posture, Entra admin roles) by introducing a v2 workflow (`new → triaged → in_progress → resolved/closed/risk_accepted`, plus `reopened`), ownership/assignment, recurrence tracking, and due-date (SLA) behavior. Drift findings will stop creating “new row per re-drift” noise by using a stable recurrence identity and reopening the canonical record when a resolved issue reappears. SLA due alerting will be re-enabled by implementing a producer that emits a single tenant-level SLA due event (summarizing overdue counts) when newly-overdue open findings exist (at most one per tenant per evaluation window), and by re-adding the event type to AlertRule configuration once the producer exists. Review Pack “open findings” selection and fingerprinting will be updated to use the v2 open-status set. A one-time OperationRun-backed backfill/consolidation operation upgrades legacy findings (acknowledged → triaged, lifecycle fields populated, due dates assigned from backfill time + SLA days, drift duplicates consolidated). + +## Technical Context + +**Language/Version**: PHP 8.4.15 (Laravel 12.52.0) +**Primary Dependencies**: Filament v5.2.1, Livewire v4.1.4 +**Storage**: PostgreSQL (JSONB used for evidence and settings values) +**Testing**: Pest v4.3.1 (PHPUnit 12.5.4) +**Target Platform**: Docker via Laravel Sail (local); Dokploy (staging/production) +**Project Type**: Web application (Laravel monolith with Filament admin panel) +**Performance Goals**: Findings list remains performant at 10k+ rows/tenant by relying on pagination and index-backed filters (status/severity/due date/assignee); bulk workflow actions handle 100 records per action; scheduled alert evaluation relies on index-backed queries over `(workspace_id, tenant_id, status, due_at)` and avoids full-table scans +**Constraints**: No new external API calls; server-side enforcement for workflow transitions and RBAC; all long-running backfill/consolidation runs are OperationRun-backed with OPS-UX 3-surface feedback; Monitoring/Alerts evaluation remains DB-only at render time +**Scale/Scope**: Multi-workspace, multi-tenant; findings volumes can grow continuously (recurrence reduces drift row churn); SLA due event is tenant-level (one per tenant per evaluation window) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Rule | Status | Notes | +|------|--------|-------| +| **Inventory-first** | PASS | No change to inventory semantics. Findings remain operational artifacts derived from inventory-backed runs. | +| **Read/write separation** | PASS | Workflow mutations (triage/assign/resolve/close/risk accept/reopen) are explicit user actions with confirmation (where required), server-side authorization, audit logs, and tests. | +| **Graph contract path** | PASS | No new Graph calls. All changes are DB + UI + queued jobs. | +| **Deterministic capabilities** | PASS | New capabilities are added to the canonical registry (`app/Support/Auth/Capabilities.php`) and mapped in `RoleCapabilityMap`. Legacy `TENANT_FINDINGS_ACKNOWLEDGE` remains as a deprecated alias for v2 triage permission. | +| **RBAC-UX: two planes** | PASS | Only `/admin` plane involved. Tenant-context findings remain under `/admin/t/{tenant}/...`. | +| **RBAC-UX: non-member = 404** | PASS | UI uses `UiEnforcement`; server-side policies/guards return deny-as-not-found for non-members. | +| **RBAC-UX: member missing capability = 403** | PASS | UI disables with tooltips; server-side policies/guards deny with 403 for members missing capability. | +| **RBAC-UX: destructive confirmation** | PASS | Resolve/Close/Risk accept/Reopen require confirmation; bulk actions use confirmation and typed confirmation when large. | +| **RBAC-UX: global search** | N/A | Findings are tenant-context and already have a View page; no new global-search surfaces are introduced in this feature. | +| **Workspace isolation** | PASS | Findings queries remain workspace+tenant safe; overdue evaluation operates on findings scoped by `workspace_id` and `tenant_id`. | +| **Tenant isolation** | PASS | All finding reads/writes are tenant-scoped; bulk actions and backfill are tenant-context operations. | +| **Run observability** | PASS | Backfill/consolidation is OperationRun-backed. Alerts evaluation already uses OperationRun. | +| **Ops-UX 3-surface feedback** | PASS | Backfill uses `OperationUxPresenter` queued/dedupe toasts, standard progress surfaces, and `OperationRunCompleted` terminal DB notification (initiator-only). | +| **Ops-UX lifecycle/service-owned** | PASS | Any new run transitions use `OperationRunService` (no direct status/outcome writes). | +| **Ops-UX summary counts contract** | PASS | Backfill and generator updates use only keys allowed by `OperationSummaryKeys::all()` and numeric-only values. | +| **Ops-UX system runs** | PASS | Scheduled alert evaluation remains system-run (no initiator DB notification). Any tenant-wide escalation uses Alerts, not OperationRun notifications. | +| **Automation / idempotency** | PASS | Backfill runs are deduped per tenant + scope identity; queued jobs are idempotent and lock-protected where needed. | +| **Data minimization** | PASS | Evidence remains sanitized; audit logs store before/after at a metadata level and avoid secrets/tokens. | +| **BADGE-001** | PASS | Finding status badge mapping is extended to include all v2 statuses with tests. | +| **Filament Action Surface Contract** | PASS | Findings Resource already declares ActionSurface; will be updated to cover v2 actions with “More” grouping + bulk groups and empty-state exemption retained. | +| **UX-001** | PASS | Findings View already uses Infolist. Workflow actions are surfaced via header/row/bulk actions with safety and grouping conventions. | + +**Post-design re-evaluation**: All checks PASS. No constitution violations expected after Phase 1 outputs below. + +## Project Structure + +### Documentation (this feature) + +```text +/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/111-findings-workflow-sla/ +├── plan.md # This file (/speckit.plan) +├── spec.md # Feature spec (/speckit.specify + /speckit.clarify) +├── checklists/ +│ └── requirements.md # Spec quality checklist +├── research.md # Phase 0 output (/speckit.plan) +├── data-model.md # Phase 1 output (/speckit.plan) +├── quickstart.md # Phase 1 output (/speckit.plan) +├── contracts/ # Phase 1 output (/speckit.plan) +│ └── api-contracts.md +└── tasks.md # Phase 2 output (/speckit.tasks - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +app/ +├── Console/Commands/ +│ └── TenantpilotBackfillFindingLifecycle.php # New: OperationRun-backed backfill entrypoint +├── Filament/Pages/Settings/ +│ └── WorkspaceSettings.php # Updated: Findings SLA policy setting UI +├── Filament/Resources/ +│ ├── FindingResource.php # Updated: defaults + actions + filters + workflow actions +│ └── AlertRuleResource.php # Updated: re-enable sla_due event type (after producer exists) +├── Jobs/ +│ ├── BackfillFindingLifecycleJob.php # New: queued worker for backfill + consolidation +│ └── Alerts/EvaluateAlertsJob.php # Updated: add SLA due producer +├── Models/Finding.php # Updated: v2 statuses + workflow helpers + relationships +├── Policies/FindingPolicy.php # Updated: per-action capabilities +├── Services/ +│ ├── Findings/ +│ │ ├── FindingSlaPolicy.php # New: SLA policy resolver +│ │ └── FindingWorkflowService.php # New: workflow transitions + audit +│ ├── Drift/DriftFindingGenerator.php # Updated: recurrence_key + lifecycle fields + auto-resolve stale +│ ├── PermissionPosture/PermissionPostureFindingGenerator.php # Updated: lifecycle fields + due_at semantics +│ └── EntraAdminRoles/EntraAdminRolesFindingGenerator.php # Updated: lifecycle fields + due_at semantics +└── Support/ + ├── Auth/Capabilities.php # Updated: new tenant findings capabilities + ├── Badges/Domains/FindingStatusBadge.php # Updated: v2 status mapping + ├── OperationCatalog.php # Updated: label + expected duration for backfill run type + ├── OpsUx/OperationSummaryKeys.php # Possibly updated if new summary keys are required + └── Settings/SettingsRegistry.php # Updated: findings.sla_days registry + validation + +database/migrations/ +├── 2026_02_24_160000_add_finding_lifecycle_v2_fields_to_findings_table.php +├── 2026_02_24_160001_add_finding_recurrence_key_and_sla_indexes_to_findings_table.php +└── 2026_02_24_160002_enforce_not_null_on_finding_seen_fields.php + +tests/ +├── Feature/Findings/ +│ ├── FindingsListDefaultsTest.php # Default list + open statuses across all types +│ ├── FindingsListFiltersTest.php # Quick filters (Open/Overdue/High severity/My assigned) +│ ├── FindingWorkflowRowActionsTest.php # Row workflow actions + confirmations +│ ├── FindingWorkflowViewActionsTest.php # View header workflow actions +│ ├── FindingRbacTest.php # 404/403 matrix per capability +│ ├── FindingAuditLogTest.php # Audit before/after + reasons + actor +│ ├── FindingRecurrenceTest.php # drift recurrence_key + reopen + concurrency gating +│ ├── DriftStaleAutoResolveTest.php # drift stale auto-resolve reason +│ ├── FindingBackfillTest.php # backfill + consolidation behavior +│ └── FindingBulkActionsTest.php # bulk actions + audit +├── Feature/Alerts/ +│ └── SlaDueAlertTest.php # tenant-level sla_due event producer + rule selection +└── Unit/ + ├── Findings/FindingWorkflowServiceTest.php # transition enforcement + due_at semantics + └── Settings/FindingsSlaDaysSettingTest.php # validation + normalization +``` + +**Structure Decision**: Standard Laravel monolith. Changes are concentrated in the Findings model/generators, Alerts evaluation, Filament resources, migrations, and tests. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | N/A | N/A | + +## Filament v5 Agent Output Contract + +1. **Livewire v4.0+ compliance**: Yes (Filament v5 requires Livewire v4; project is on Livewire v4.1.4). +2. **Provider registration**: No new panel required. Existing panel providers remain registered under `bootstrap/providers.php`. +3. **Global search**: FindingResource already has a View page. No new globally-searchable resources are introduced by this feature. +4. **Destructive actions**: Resolve/Close/Risk accept/Reopen and bulk equivalents use `->action(...)` + `->requiresConfirmation()` and server-side authorization. +5. **Asset strategy**: No new frontend asset pipeline requirements; standard Filament components only. +6. **Testing plan**: Pest coverage for workflow transitions, RBAC 404/403 semantics, drift recurrence_key behavior (reopen + stale auto-resolve), SLA due event producer, and backfill/consolidation. + +## Phase 0 — Outline & Research (output: research.md) + +Phase 0 resolves the key design decisions needed for implementation consistency: +- v2 status model + timestamp semantics +- SLA policy storage and defaults +- drift recurrence_key strategy (stable identity) +- SLA due event contract (tenant-level, throttling-friendly) +- backfill/consolidation approach (OperationRun-backed, idempotent) + +Outputs: +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/111-findings-workflow-sla/research.md` + +## Phase 1 — Design & Contracts (outputs: data-model.md, contracts/*, quickstart.md) + +Design deliverables: +- Data model changes to `findings` and supporting settings/capabilities/badges +- Contracts for workflow actions and SLA due events (alerts) +- Implementation quickstart to validate locally with Sail + +Outputs: +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/111-findings-workflow-sla/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/111-findings-workflow-sla/contracts/api-contracts.md` +- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/111-findings-workflow-sla/quickstart.md` + +## Phase 2 — Planning (implementation outline; detailed tasks live in tasks.md) + +- Migrations: add lifecycle + workflow columns + recurrence_key + indexes (two-phase: nullable → backfill → enforce not-null where appropriate). +- Workflow enforcement: server-side transition validation + timestamps + reasons; audit logs for every user-initiated workflow mutation. +- SLA policy: add `findings.sla_days` to SettingsRegistry + Workspace Settings UI; due_at set on create + reset on reopen. +- Generator updates: + - Drift: stable recurrence_key upsert + reopen semantics + stale auto-resolve reason. + - Permission posture + Entra roles: lifecycle fields + due_at semantics, keep reopen/auto-resolve behavior. +- Alerts: implement SLA due producer in EvaluateAlertsJob; re-enable `sla_due` option in AlertRuleResource event types. +- Filament UI: remove drift-only default filters; default to Open across all types; quick filters: Open, Overdue, High severity, My assigned; row + bulk actions for v2 workflow and assignment. +- Backfill operation: tenant-scoped backfill entrypoint and job with OperationRun observability + dedupe; consolidate drift duplicates into a canonical recurrence_key record and mark old duplicates terminal. +- Tests: workflow transition matrix + RBAC 404/403 behavior; recurrence + stale resolve; SLA due alert producer contract; backfill/consolidation correctness and idempotency. diff --git a/specs/111-findings-workflow-sla/quickstart.md b/specs/111-findings-workflow-sla/quickstart.md new file mode 100644 index 0000000..65df822 --- /dev/null +++ b/specs/111-findings-workflow-sla/quickstart.md @@ -0,0 +1,110 @@ +# Quickstart: 111 — Findings Workflow V2 + SLA + +**Date**: 2026-02-24 +**Branch**: `111-findings-workflow-sla` + +--- + +## Prerequisites + +- Sail services running (`vendor/bin/sail up -d`) +- Database migrated to latest +- At least one workspace with at least one tenant +- Queue worker running for queued jobs (e.g., `vendor/bin/sail artisan queue:work`) + +--- + +## Implementation Phases + +### Phase 1: Data Layer (Findings v2 Columns + Badges + Settings) + +**Goal:** Extend `findings` to v2 lifecycle fields and add SLA policy setting. + +1. Migrations (add v2 columns + indexes; two-phase if enforcing NOT NULL after backfill) +2. Update `App\Models\Finding` constants for v2 statuses and severities +3. BADGE-001: extend Finding status badge mapping to include v2 statuses (and legacy `acknowledged` mapping) +4. Settings: + - Add `findings.sla_days` to `SettingsRegistry` + - Expose in Workspace Settings UI (JSON textarea), validate via registry rules + +**Run:** `vendor/bin/sail artisan migrate && vendor/bin/sail artisan test --compact --filter=SettingsRegistry` + +--- + +### Phase 2: Workflow + RBAC (Server-Side Enforcement + Filament Actions) + +**Goal:** Enforce transition rules, write timestamps, and provide safe UI actions. + +1. Capabilities: + - Add new `TENANT_FINDINGS_*` constants + - Keep `TENANT_FINDINGS_ACKNOWLEDGE` as triage alias (migration window) + - Update `RoleCapabilityMap` +2. Policy/service layer: + - Enforce allowed transitions server-side + - Require reasons for resolve/close/risk accept + - Audit log every mutation (before/after + reason fields) +3. Filament `FindingResource`: + - Remove drift-only default filters + - Default to Open statuses across all finding types + - Add quick filters (Open/Overdue/High severity/My assigned) + - Add row actions + bulk actions per spec (grouped under “More”) + +**Run:** `vendor/bin/sail artisan test --compact --filter=FindingWorkflow` + +--- + +### Phase 3: Generators (Lifecycle Fields + Drift Recurrence + Stale Resolve) + +**Goal:** Ensure findings lifecycle fields and recurrence behavior are maintained automatically. + +1. Drift: + - Compute `recurrence_key` and upsert by it + - Auto-reopen only from `resolved` into `reopened` + - Auto-resolve stale drift for a scope when no longer detected (`resolved_reason=no_longer_detected`) +2. Permission posture + Entra roles: + - Preserve existing reopen/auto-resolve behavior + - Add lifecycle fields: first/last seen, times_seen, due_at/sla_days + +**Run:** `vendor/bin/sail artisan test --compact --filter=FindingRecurrence` + +--- + +### Phase 4: Alerts (SLA Due Producer + UI Re-Enable) + +**Goal:** Make `sla_due` alert rules functional. + +1. Add SLA due producer to `EvaluateAlertsJob` that emits one tenant-level event summarizing overdue counts. +2. Re-enable `sla_due` in AlertRuleResource event type options (only after producer exists). + +**Manual verification:** +1. Create (or backfill) a finding with `due_at` in the past and open status. +2. Run `vendor/bin/sail artisan tenantpilot:alerts:dispatch --workspace={id}` +3. Confirm an `alert_deliveries` row is created for matching enabled rules. + +**Run:** `vendor/bin/sail artisan test --compact --filter=FindingSlaDue` + +--- + +### Phase 5: Backfill/Consolidation (OperationRun-Backed) + +**Goal:** Upgrade legacy findings and consolidate drift duplicates. + +1. Trigger backfill from tenant context (Filament action and/or an artisan command entrypoint). +2. Verify OPS-UX: + - queued toast intent-only + - progress visible in active ops widget + OperationRun detail + - exactly one terminal completion notification (initiator-only) +3. Verify data outcomes: + - `acknowledged → triaged` + - lifecycle fields populated + - due dates set from backfill time + SLA days for legacy open findings + - drift duplicates consolidated (one canonical open row per recurrence identity) + +**Run:** `vendor/bin/sail artisan test --compact --filter=FindingBackfill` + +--- + +## Formatting / Hygiene + +- Run `vendor/bin/sail bin pint --dirty` before finalizing. + diff --git a/specs/111-findings-workflow-sla/research.md b/specs/111-findings-workflow-sla/research.md new file mode 100644 index 0000000..91c0f14 --- /dev/null +++ b/specs/111-findings-workflow-sla/research.md @@ -0,0 +1,193 @@ +# Research: 111 — Findings Workflow V2 + SLA + +**Date**: 2026-02-24 +**Branch**: `111-findings-workflow-sla` + +--- + +## 1. Status Model + Legacy Mapping + +### Decision +Keep `findings.status` as a string column and expand allowed v2 values. Preserve legacy `acknowledged` rows for compatibility, but treat `acknowledged` as `triaged` in the v2 workflow surface and migrate it during backfill. + +### Rationale +- Existing findings already use `new|acknowledged|resolved` with `acknowledged_at/by` fields. +- Mapping `acknowledged → triaged` preserves intent while enabling the new workflow. +- Avoids high-risk data migrations that try to rewrite history beyond what the spec requires. + +### Alternatives Considered +- Dropping the legacy `acknowledged` status immediately and forcing a hard migration in one deploy: rejected due to rollout risk. + +--- + +## 2. Workflow Enforcement + Timestamps + +### Decision +Enforce transitions server-side via a dedicated workflow service (single entrypoint used by Filament actions and any future API surfaces). Update timestamps on state changes: +- `triaged_at` set on `new|reopened → triaged` +- `in_progress_at` set on `triaged → in_progress` +- `resolved_at` + `resolved_reason` set on resolve +- `closed_at` + `closed_reason` + `closed_by_user_id` set on close and risk accept +- `reopened_at` set on reopen + +When reopening, clear terminal state fields relevant to the previous terminal status (e.g., clear `resolved_at/reason` when moving to `reopened`). + +### Rationale +- Keeps status validation consistent across UI and background jobs. +- Timestamp fields provide direct auditability for “when did we triage/close”. +- Clearing terminal fields prevents inconsistent states (e.g., `status=reopened` with `resolved_at` still set). + +### Alternatives Considered +- Implementing all transitions as ad-hoc model mutations across multiple resources: rejected (harder to test and easy to drift). + +--- + +## 3. SLA Policy Storage (SettingsRegistry) + +### Decision +Add a workspace-resolvable setting: +- `domain = findings` +- `key = sla_days` +- `type = json` +- `systemDefault`: + - critical: 3 + - high: 7 + - medium: 14 + - low: 30 + +Expose it via Workspace Settings UI as a JSON textarea, following the existing `drift.severity_mapping` pattern. + +### Rationale +- Existing code already uses `SettingsResolver` + `SettingsRegistry` for drift severity mapping. +- Keeps SLA policy queryable and adjustable without code deploys. + +### Alternatives Considered +- Hardcoding SLA days in code/config: rejected (non-configurable and harder to tune per workspace). + +--- + +## 4. due_at Computation Semantics + +### Decision +- On create (new finding): set `sla_days` (resolved from settings) and set `due_at = first_seen_at + sla_days`. +- On reopen (manual or automatic): reset `due_at = now + sla_days(current severity)` and update `sla_days` to the current policy value. +- Severity changes while a finding remains open do not retroactively change `due_at` unless the finding is reopened (matches spec assumptions). + +### Rationale +- Allows stable deadlines during remediation while still resetting on recurrence/reopen. +- Reduces surprising “deadline moved” behavior during open triage. + +### Alternatives Considered +- Recomputing `due_at` on every detection run for open findings: rejected (deadlines drift and become hard to reason about). + +--- + +## 5. SLA Due Alert Event (Tenant-Level Summary) + +### Decision +Implement an SLA due producer in `EvaluateAlertsJob` that emits **one event per tenant** when a tenant has **newly-overdue** open findings in the evaluation window: +- Eligibility for producing an event (per tenant): + - `due_at <= now()` + - `due_at > windowStart` (newly overdue since last evaluation) + - `status IN (new, triaged, in_progress, reopened)` +- Event summarizes **current** overdue counts for that tenant (not just newly overdue), so the alert body reflects the real state at emission time. + +Event fields: +- `event_type = sla_due` +- `fingerprint_key = sla_due:tenant:{tenant_id}` +- `severity = max severity among overdue open findings` (critical if any critical overdue exists) +- `metadata` contains counts only (no per-finding payloads) + +### Rationale +- Avoids creating suppressed `alert_deliveries` every minute for persistently overdue tenants (no prune job exists for `alert_deliveries` today). +- Aligns “due” semantics with the due moment: a tenant produces an event when something crosses due. + +### Alternatives Considered +- Emitting an SLA due event on every evaluation run when overdue exists: rejected due to `alert_deliveries` table growth and suppressed-delivery noise. +- Tracking last-emitted state per tenant in a new table: rejected for v1 (adds schema and state complexity). + +--- + +## 6. Drift Recurrence: Stable recurrence_key + Canonical Row + +### Decision +Add `recurrence_key` (64-char hex) to `findings` and treat it as the stable identity for drift recurrence. For drift findings: +- Compute `recurrence_key = sha256("drift:{tenant_id}:{scope_key}:{subject_type}:{subject_external_id}:{dimension}")` +- Upsert drift findings by `(tenant_id, recurrence_key)` +- Set drift finding `fingerprint = recurrence_key` for canonical drift rows going forward + +`dimension` is stable and derived from evidence kind and change type: +- Policy snapshot drift: `policy_snapshot:{change_type}` +- Assignments drift: `policy_assignments` +- Scope tags drift: `policy_scope_tags` +- Baseline compare drift: `baseline_compare:{change_type}` + +### Rationale +- Prevents “new row per re-drift” even when baseline/current hashes change. +- Avoids conflicts with legacy drift fingerprints during consolidation because new canonical drift fingerprints are stable and distinct. + +### Alternatives Considered +- Keeping drift fingerprint as baseline/current hash-based and updating it on the canonical row: rejected because it can collide with existing legacy rows (unique `(tenant_id, fingerprint)` constraint). + +--- + +## 7. Drift Stale Auto-Resolve + +### Decision +When generating drift findings for a scope/run, auto-resolve drift findings that were previously open for that scope but are not detected in the latest run: +- Filter: `finding_type=drift`, `scope_key=...`, `status IN (new, triaged, in_progress, reopened)` +- Not seen in run’s `recurrence_key` set +- Resolve reason: `no_longer_detected` + +### Rationale +- Keeps “Open” findings aligned with current observed state. +- Matches existing generator patterns (permission posture / Entra roles resolve stale records). + +### Alternatives Considered +- Leaving stale findings open indefinitely: rejected (increases noise and breaks trust in “Open” list). + +--- + +## 8. Backfill + Consolidation (OperationRun-Backed) + +### Decision +Implement a tenant-scoped backfill/consolidation operation backed by `OperationRun`: +- Maps `acknowledged → triaged` +- Populates lifecycle fields (`first_seen_at`, `last_seen_at`, `times_seen`, `due_at`, `sla_days`, timestamps) +- Computes `recurrence_key` for drift and consolidates duplicates so only one canonical open finding remains per `(tenant_id, recurrence_key)` +- Due dates for legacy open findings: `due_at = backfill_started_at + sla_days` (prevents immediate overdue surge) + +Duplicates strategy: +- Choose one canonical row per `(tenant_id, recurrence_key)` (prefer open, else most recently seen) +- Non-canonical duplicates become terminal (`resolved` with `resolved_reason=consolidated_duplicate`) and have `recurrence_key` cleared to keep canonical uniqueness simple + +### Rationale +- Meets OPS-UX requirements (queued toast, progress surfaces, initiator-only terminal notification). +- Makes legacy data usable without requiring manual cleanup. + +### Alternatives Considered +- Deleting duplicate rows: rejected because the spec explicitly allows legacy rows to remain (and deletions are harder to justify operationally). + +--- + +## 9. Capabilities + RBAC Enforcement + +### Decision +Add tenant-context capabilities: +- `TENANT_FINDINGS_VIEW` +- `TENANT_FINDINGS_TRIAGE` +- `TENANT_FINDINGS_ASSIGN` +- `TENANT_FINDINGS_RESOLVE` +- `TENANT_FINDINGS_CLOSE` +- `TENANT_FINDINGS_RISK_ACCEPT` + +Keep `TENANT_FINDINGS_ACKNOWLEDGE` as a deprecated alias for v2 triage permission: +- UI enforcement and server-side policy checks treat `ACKNOWLEDGE` as sufficient for triage during the migration window. + +### Rationale +- Aligns with RBAC-UX constitution requirements (registry-only strings, 404/403 semantics). +- Allows incremental rollout without breaking existing role mappings. + +### Alternatives Considered +- Forcing all tenants to update role mappings at deploy time: rejected (operationally brittle). + diff --git a/specs/111-findings-workflow-sla/spec.md b/specs/111-findings-workflow-sla/spec.md new file mode 100644 index 0000000..c1448d7 --- /dev/null +++ b/specs/111-findings-workflow-sla/spec.md @@ -0,0 +1,269 @@ +# Feature Specification: Findings Workflow V2 + SLA + +**Feature Branch**: `111-findings-workflow-sla` +**Created**: 2026-02-24 +**Status**: Draft +**Depends On**: `specs/104-provider-permission-posture/spec.md`, `specs/105-entra-admin-roles-evidence-findings/spec.md`, `specs/109-review-pack-export/spec.md` +**Input**: Standardize the Findings lifecycle (workflow, ownership, recurrence, SLA due dates, and alerting) so findings management is enterprise-usable and not “noise”. + +## Clarifications + +### Session 2026-02-24 + +- Q: What should happen when the same finding is detected again, but its current status is terminal? → A: Auto-reopen only from `resolved`; `closed` and `risk_accepted` remain terminal (still update seen tracking fields). +- Q: When backfilling legacy open findings, how should the initial due date be set? → A: Compute from the backfill operation time (backfill time + SLA days). +- Q: When SLA due alerts fire, what should a single alert event represent? → A: At most one event per tenant per alert-evaluation window, emitted only when newly-overdue open findings exist; the event summarizes current overdue counts. +- Q: Which statuses should count as “Open” for the default Findings list and for SLA due evaluation? → A: Open = `new`, `triaged`, `in_progress`, `reopened`. +- Q: From which statuses should a user be able to manually “Reopen” a finding (into `reopened` status)? → A: Allow manual reopen from `resolved`, `closed`, and `risk_accepted`. +- Q: Where is the SLA policy configured, and what scope does it apply to? → A: Workspace-scoped setting (`findings.sla_days`) in Workspace Settings; applies to all tenants in the workspace. +- Q: How is the “alert-evaluation window” defined for SLA due gating? → A: Use the Alerts evaluation window start time (previous completed `alerts.evaluate` OperationRun `completed_at`; fallback to initial lookback). “Newly overdue” means `due_at` in `(window_start, now]` for open findings. +- Q: What must an `sla_due` event contain? → A: One event per tenant per evaluation window; `metadata` includes `overdue_total` and `overdue_by_severity` (critical/high/medium/low) for currently overdue open findings; fingerprint is stable per tenant+window. +- Q: If severity changes while a finding remains open, should `due_at` be recalculated? → A: No — `due_at` is set on create and reset only on reopen/backfill. +- Q: If a user resolves a finding while a detection run is processing, how is consistency maintained? → A: Detection updates may still advance seen counters, but automatic reopen MUST occur only when the observation time is after `resolved_at`. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant (Findings management) + workspace (SLA policy + Alert rules configuration) +- **Primary Routes**: + - Tenant-context: Findings list + view (`/admin/t/{tenant}/...`) + - Workspace-context Monitoring: Alert rules list + edit (`/admin/...`) + - Workspace-context Settings: Workspace Settings (Findings SLA policy) (`/admin/...`) +- **Data Ownership**: + - Tenant-owned: Findings and their lifecycle metadata + - Workspace-owned: SLA policy settings (`findings.sla_days`) + - Workspace-owned: Alert rules configuration (event types) +- **RBAC**: + - Findings view + workflow actions are tenant-context capability-gated + - Workspace Settings + Alert rules remain workspace capability/policy-gated (existing behavior) + +*Canonical-view fields not applicable — this spec updates tenant-context Findings and workspace-scoped Alert Rules.* + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See Open Findings (Priority: P1) + +As a tenant operator, I can open the Findings page and immediately see the current open findings across all finding types, so I don’t miss non-drift issues and can focus on what needs attention now. + +**Why this priority**: If open findings are hidden by default filters or type assumptions, findings become unreliable as an operational surface. + +**Independent Test**: Seed a tenant with findings across multiple types and statuses, then verify the default list shows open workflow statuses across all types without adjusting filters. + +**Acceptance Scenarios**: + +1. **Given** a tenant has findings of types drift, permission posture, and Entra admin roles, **When** I open the Findings list, **Then** I can see open findings from all types without changing any filters. +2. **Given** a tenant has a mix of open and terminal findings, **When** I open the Findings list, **Then** the default list shows only open workflow statuses. +3. **Given** a tenant has overdue findings, **When** I use the “Overdue” quick filter, **Then** only findings past their due date are shown. +4. **Given** a tenant has open findings, **When** I view the list, **Then** I can see each finding’s status, severity, due date, and assignee (when set). + +--- + +### User Story 2 - Triage, Assign, And Resolve (Priority: P1) + +As a tenant manager, I can triage findings, assign ownership, and move findings through a consistent workflow (including reasons and auditability), so the team can reliably manage remediation. + +**Why this priority**: Without a consistent workflow and ownership, findings degrade into noisy, un-actioned rows with unclear accountability. + +**Independent Test**: Create an open finding, execute each allowed status transition, and verify transitions are enforced server-side, recorded with timestamps/actors, and audited. + +**Acceptance Scenarios**: + +1. **Given** a finding in `new` (or `reopened`) status, **When** I triage it, **Then** the status becomes `triaged` and the triage timestamp is recorded. +2. **Given** a finding in `triaged` status, **When** I start progress, **Then** the status becomes `in_progress` and the progress timestamp is recorded. +3. **Given** a finding in an open status, **When** I assign an assignee (and optional owner), **Then** those fields are saved and displayed on the finding. +4. **Given** a finding in an open status, **When** I resolve it with a resolution reason, **Then** it becomes `resolved` and the resolution reason is persisted. +5. **Given** a finding in any status, **When** I close it with a close reason, **Then** it becomes `closed` and the close reason is persisted. +6. **Given** a finding in any status, **When** I mark it as risk accepted with a reason, **Then** it becomes `risk_accepted` and the reason is persisted. +7. **Given** a user without the relevant capability, **When** they attempt any workflow mutation, **Then** the server denies it (403 for members lacking capability; 404 for non-members / not entitled). + +--- + +### User Story 3 - SLA Due Visibility And Alerts (Priority: P1) + +As a workspace operator, I can configure alerting for findings that are past their due date (SLA due), so overdue findings reliably escalate beyond the Findings page. + +**Why this priority**: An SLA without alerting becomes “best effort” and is easy to ignore in busy operations. + +**Independent Test**: Create newly-overdue open findings for a tenant, run alert evaluation, and verify a single tenant-level SLA due event is produced and can match an enabled alert rule. + +**Acceptance Scenarios**: + +1. **Given** a tenant has one or more newly-overdue open findings since the previous evaluation window, **When** alert evaluation runs, **Then** exactly one SLA due event is produced for that tenant and can trigger an enabled alert rule. +2. **Given** a tenant has no overdue open findings (including when only terminal findings have past due dates), **When** alert evaluation runs, **Then** no SLA due event is produced for that tenant. +3. **Given** I edit an alert rule, **When** I choose the event type, **Then** “SLA due” is available as a selectable event type. +4. **Given** a tenant has overdue open findings but no newly-overdue open findings since the previous evaluation window, **When** alert evaluation runs, **Then** no additional SLA due event is produced for that tenant. +5. **Given** an SLA due event is produced, **When** I inspect the event payload, **Then** it includes overdue counts total and by severity. + +--- + +### User Story 4 - Recurrence Reopens (Priority: P2) + +As a tenant operator, when a previously resolved finding reappears in later detection runs, it reopens the original finding (instead of creating a new duplicate), so recurrence is visible and manageable. + +**Why this priority**: Recurrence is operationally important, and duplicate rows create confusion and reporting noise. + +**Independent Test**: Simulate a finding being resolved and then being detected again, verifying it transitions to `reopened`, counters update, and due date resets. + +**Acceptance Scenarios**: + +1. **Given** a finding was `resolved`, **When** it is detected again, **Then** the same finding transitions to `reopened` and records a reopened timestamp. +2. **Given** a finding is detected in successive runs, **When** it appears again, **Then** the last-seen timestamp updates and the seen counter increases. +3. **Given** a drift finding is no longer detected in the latest run, **When** stale detection is evaluated, **Then** the drift finding is auto-resolved with reason “no longer detected”. +4. **Given** a finding is `closed` or `risk_accepted`, **When** it is detected again, **Then** it remains terminal and only its seen tracking fields update. + +--- + +### User Story 5 - Bulk Manage Findings (Priority: P3) + +As a tenant manager, I can triage/assign/resolve/close findings in bulk, so I can manage high volumes efficiently while preserving auditability and safety. + +**Why this priority**: Bulk workflow reduces operational load, but can ship after the single-record workflow is correct. + +**Independent Test**: Select multiple findings and run each bulk action, verifying that all selected findings update consistently and each change is audited. + +**Acceptance Scenarios**: + +1. **Given** I select multiple open findings, **When** I bulk triage them, **Then** all selected findings become `triaged`. +2. **Given** I select multiple open findings, **When** I bulk assign an assignee, **Then** all selected findings are assigned. +3. **Given** I select multiple open findings, **When** I bulk resolve them with a reason, **Then** all selected findings become `resolved` and record the reason. +4. **Given** I select multiple open findings, **When** I bulk close them with a reason, **Then** all selected findings become `closed` and record the close reason. +5. **Given** I select multiple open findings, **When** I bulk risk accept them with a reason, **Then** all selected findings become `risk_accepted` and record the reason. +6. **Given** more than 100 open findings match my current filters, **When** I run “Triage all matching”, **Then** the action requires typed confirmation, updates all matching findings safely, and audits each change. + +--- + +### User Story 6 - Backfill Existing Findings (Priority: P2) + +As a tenant operator, I can run a one-time backfill/consolidation operation to upgrade existing findings into the v2 workflow model, so older data is usable (due dates, counters, recurrence) without manual cleanup. + +**Why this priority**: Without backfill, existing tenants keep legacy/incomplete findings and the new workflow appears inconsistent or broken. + +**Independent Test**: Seed legacy findings (missing lifecycle fields, `acknowledged` status, drift duplicates), run the backfill operation, and verify fields are populated, statuses are mapped, and duplicates are consolidated. + +**Acceptance Scenarios**: + +1. **Given** legacy open findings exist without due dates or lifecycle timestamps, **When** I run the backfill operation, **Then** open findings receive due dates set to the backfill operation time plus the SLA days for their severity, and lifecycle metadata is populated. +2. **Given** legacy findings in `acknowledged` status exist, **When** I run the backfill operation, **Then** they appear as `triaged` in the v2 workflow surface. +3. **Given** duplicate drift findings exist for the same recurring issue, **When** I run the backfill operation, **Then** duplicates are consolidated so only one canonical open finding remains. + +--- + +### Edge Cases + +- Legacy findings exist without lifecycle timestamps or due dates (backfill required). +- A previously assigned/owned user is no longer a tenant member (retain historical assignment, but prevent selecting non-members for new assignments). +- A finding’s severity changes while it remains open (assumption on due date recalculation documented below). +- An SLA due alert rule exists from earlier versions (should begin working once the producer exists; no data loss). +- Concurrent actions: a user resolves a finding while a detection run marks it seen again (system remains consistent and auditable). + +## Requirements *(mandatory)* + +### Governance And Safety Requirements + +- This feature introduces no new external API calls. +- All user-initiated workflow mutations (triage/assign/resolve/close/risk accept/reopen) MUST be audited with actor, tenant, action, target, before/after, and timestamp. + - Audit before/after MUST be limited to workflow/assignment metadata (e.g., `status`, `severity`, `due_at`, `assignee_id`, `owner_id`, `triaged_at`, `in_progress_at`, `resolved_at`, `closed_at`, `resolution_reason`, `close_reason`, `risk_accepted_reason`) and MUST NOT include raw evidence payloads or secrets/tokens. +- The lifecycle backfill/consolidation operation MUST be observable as an operation with: + - clear start feedback (accepted/queued), + - progress visibility while running, and + - a single terminal outcome notification for the initiator. +- Authorization MUST be enforced server-side for every mutation with deny-as-not-found semantics: + - non-members or users not entitled to the tenant scope → 404 + - members missing capability → 403 +- Destructive-like actions (resolve/close/risk accept) MUST require explicit confirmation. +- Findings status badge semantics MUST remain centralized and cover every allowed status. + +### Functional Requirements + +- **FR-001**: System MUST support a Findings lifecycle with statuses: `new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, `risk_accepted`. +- **FR-002**: System MUST enforce allowed status transitions server-side: + - `new|reopened` → `triaged` + - `triaged` → `in_progress` + - `new|reopened|triaged|in_progress` → `resolved` (resolution reason required) + - `resolved|closed|risk_accepted` → `reopened` (manual allowed; requires confirmation; automatic only when detected again from `resolved`) + - `*` → `closed` (close reason required) + - `*` → `risk_accepted` (reason required) +- **FR-003**: Each finding MUST track lifecycle metadata: owner, assignee, first-seen time, last-seen time, seen count, and (when open) an SLA due date. +- **FR-004**: The system MUST assign an SLA due date to open findings using a configurable severity-based policy with defaults: + - critical: 3 days + - high: 7 days + - medium: 14 days + - low: 30 days +- **FR-005**: When a finding reopens (automatic or manual), the system MUST reset the SLA due date based on the current severity-based SLA policy. +- **FR-006**: SLA due alerting MUST exist: + - “SLA due” MUST be available as an alert rule event type (`sla_due`). + - The SLA due producer MUST use the same alert-evaluation window start time (`window_start`) used by Alerts evaluation (previous completed `alerts.evaluate` OperationRun `completed_at`; fallback to initial lookback). + - “Newly overdue” means: an open finding with `due_at` in `(window_start, now]`. + - The system MUST emit exactly one SLA due event per tenant per alert-evaluation window when that tenant has one or more newly-overdue open findings since `window_start`. + - Each SLA due event MUST summarize current overdue open findings for the tenant and include: + - `overdue_total` (count) + - `overdue_by_severity` (`critical`, `high`, `medium`, `low`) + - A tenant with persistently overdue open findings MUST NOT emit repeated SLA due events on every evaluation run unless additional findings become newly overdue. + - Terminal statuses (`resolved`, `closed`, `risk_accepted`) MUST NOT contribute to the overdue counts. + - Open workflow statuses are `new`, `triaged`, `in_progress`, `reopened`. + - The event’s `fingerprint_key` MUST be stable per tenant + alert-evaluation window for idempotency. +- **FR-007**: The system MUST track recurrence: + - When a previously `resolved` finding is detected again, it MUST transition to `reopened` (not create a duplicate open finding for the same recurring issue). + - When a `closed` or `risk_accepted` finding is detected again, it MUST NOT change status automatically; it only updates seen tracking fields. + - Each detection run where the finding is observed MUST update last-seen time and increment seen count. + - Concurrency safety: automatic reopen MUST occur only when the observation time is after the finding’s `resolved_at`. +- **FR-008**: Drift findings MUST avoid “new row per re-drift” noise by using a stable recurrence identity so recurring drift reopens the canonical finding. +- **FR-009**: Drift findings MUST auto-resolve when they are no longer detected in the latest run, with a consistent resolved reason (e.g., “no longer detected”). +- **FR-010**: Findings list defaults MUST be safe and visible: + - Default list shows open statuses (`new`, `triaged`, `in_progress`, `reopened`) across all finding types (no drift-only default). + - Quick filters exist for: Open, Overdue, High severity, My assigned. +- **FR-011**: Findings UI MUST provide safe workflow actions: + - Single-record actions: triage, start progress, assign (assignee and optional owner), resolve (reason required), close (reason required), risk accept (reason required), reopen (where allowed). + - Bulk actions: bulk triage, bulk assign, bulk resolve, bulk close, bulk risk accept. +- **FR-012**: The system MUST introduce tenant-context capabilities for Findings management: + - `TENANT_FINDINGS_VIEW` + - `TENANT_FINDINGS_TRIAGE` + - `TENANT_FINDINGS_ASSIGN` + - `TENANT_FINDINGS_RESOLVE` + - `TENANT_FINDINGS_CLOSE` + - `TENANT_FINDINGS_RISK_ACCEPT` +- **FR-013**: Assignment/ownership selection MUST be limited to users who are currently tenant members, while preserving historical assignment/ownership values for already-assigned findings. +- **FR-014**: Legacy compatibility MUST be maintained: + - Existing `acknowledged` status MUST be treated as `triaged` in the v2 workflow surface. + - Existing `TENANT_FINDINGS_ACKNOWLEDGE` capability MUST act as a deprecated alias for v2 triage permission. +- **FR-015**: A backfill/consolidation operation MUST exist to migrate existing findings to the v2 lifecycle model, including: + - mapping `acknowledged` → `triaged` + - populating lifecycle timestamps and seen counters for existing data + - setting due dates for legacy open findings based on the backfill operation time (backfill time + SLA days) + - consolidating duplicates where recurrence identity indicates the same recurring finding (canonical record retained; duplicates marked terminal with a consistent reason, e.g. `consolidated_duplicate`) +- **FR-016**: Severity changes while a finding remains open MUST NOT retroactively change `due_at`. `due_at` is assigned on create and reset only on reopen/backfill. +- **FR-017**: Review pack generation MUST treat “open findings” using the v2 open-status set (not drift-only defaults) to keep existing exports/review packs consistent. + +## UI Action Matrix *(mandatory when Filament is changed)* + +Action Surface Contract: Satisfied for Findings and Alert Rules (explicit exemptions noted). + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Findings Resource | Admin UI: Findings | Optional: “Triage all matching” (capability-gated) | View action | View, More | Bulk triage, bulk assign, bulk resolve, bulk close, bulk risk accept (under More) | None | Triage, Start progress, Assign, Resolve, Close, Risk accept, Reopen (where allowed) | N/A | Yes | Empty-state exemption: findings are system-generated; no create CTA | +| Alert Rules Resource | Monitoring UI: Alert rules | Create (capability/policy-gated) | Clickable row | Edit, More | None (exempt) | Create alert rule | N/A (edit surface) | Save/Cancel | Yes | “SLA due” event type is available once the producer exists | + +### Key Entities *(include if feature involves data)* + +- **Finding**: Represents a detected issue for a tenant, including type, severity, lifecycle status, recurrence behavior, and lifecycle metadata (ownership, due date, seen tracking). +- **SLA policy**: Severity-based due-date expectations applied to open findings, with configurable defaults. +- **Alert rule**: Workspace-defined routing rules that can trigger delivery when an SLA due event occurs. +- **Audit entry**: Immutable record of user-initiated workflow changes for accountability and compliance. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of open findings have a computed due date (SLA) at creation and after any reopen event. +- **SC-002**: Recurring findings reopen instead of creating duplicate open rows for the same recurring issue. +- **SC-003**: The default Findings list shows open findings across all finding types without requiring users to remove type-specific filters. +- **SC-004**: SLA due alerting is functional: tenants with newly-overdue open findings since the previous evaluation window can trigger alert rules and produce at most one SLA due event per tenant per evaluation window; terminal findings never contribute to SLA due alerts. +- **SC-005**: Authorization behavior is correct and non-enumerable: non-members receive 404; members missing capability receive 403. +- **SC-006**: Admins can triage/assign/resolve/close findings in bulk for at least 100 findings in a single action without needing per-record manual updates. + +## Assumptions + +- `risk_accepted` is a workflow status only in v2 (no expiry model in this feature). +- SLA due dates are set on create and on reopen. Severity changes while a finding remains open do not retroactively change the existing due date unless the finding is reopened. +- Backfill sets due dates for legacy open findings from the backfill operation time (backfill time + SLA days) to avoid an immediate “overdue” surge on rollout. +- Assignment/ownership pickers show only current tenant members, but historical assignments remain visible for audit/history even if membership is later removed. +- Existing alert rules with `event_type = sla_due` are preserved and should become effective once the SLA due producer is implemented (no destructive data migration of workspace-owned alert rules). diff --git a/specs/111-findings-workflow-sla/tasks.md b/specs/111-findings-workflow-sla/tasks.md new file mode 100644 index 0000000..fc2d080 --- /dev/null +++ b/specs/111-findings-workflow-sla/tasks.md @@ -0,0 +1,339 @@ +--- + +description: "Task list for Findings Workflow V2 + SLA (111)" +--- + +# Tasks: Findings Workflow V2 + SLA (111) + +**Input**: Design documents from `specs/111-findings-workflow-sla/` +**Prerequisites**: `specs/111-findings-workflow-sla/plan.md` (required), `specs/111-findings-workflow-sla/spec.md` (required for user stories), `specs/111-findings-workflow-sla/research.md`, `specs/111-findings-workflow-sla/data-model.md`, `specs/111-findings-workflow-sla/contracts/api-contracts.md`, `specs/111-findings-workflow-sla/quickstart.md` + +**Tests**: REQUIRED (Pest) — runtime behavior + UX contract enforcement. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm a clean baseline before large schema + workflow changes. + +- [X] T001 [P] Start local environment (Sail) and confirm containers are healthy via `vendor/bin/sail` +- [X] T002 [P] Run baseline Pest suite for current Findings/Drift flows and record failures (if any) in `tests/Feature/Drift/` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core data model + RBAC + settings + badges that MUST be complete before ANY user story can ship. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T003 Add v2 lifecycle/SLA/ownership columns to findings in `database/migrations/2026_02_24_160000_add_finding_lifecycle_v2_fields_to_findings_table.php` +- [X] T004 Add recurrence_key + SLA/query indexes for findings in `database/migrations/2026_02_24_160001_add_finding_recurrence_key_and_sla_indexes_to_findings_table.php` +- [X] T005 Update v2 Finding model (statuses, casts, relationships, open/terminal status helpers, legacy acknowledged mapping) in `app/Models/Finding.php` +- [X] T006 Update Finding status badge mapping for v2 statuses (incl legacy `acknowledged` → `triaged` surface) in `app/Support/Badges/Domains/FindingStatusBadge.php` +- [X] T007 [P] Update status badge unit tests for v2 mapping in `tests/Unit/Badges/FindingBadgesTest.php` +- [X] T008 [P] Update status badge feature tests for v2 mapping in `tests/Feature/Support/Badges/FindingBadgeTest.php` +- [X] T009 Update Finding factory defaults/states to populate new lifecycle fields for tests in `database/factories/FindingFactory.php` +- [X] T010 Add configurable Findings SLA policy setting (`findings.sla_days`) with defaults + validation + normalizer in `app/Support/Settings/SettingsRegistry.php` +- [X] T011 Expose Findings SLA policy setting in Workspace Settings UI in `app/Filament/Pages/Settings/WorkspaceSettings.php` +- [X] T012 [P] Add settings tests for findings SLA policy validation/normalization in `tests/Unit/Settings/FindingsSlaDaysSettingTest.php` +- [X] T013 Add v2 Findings capabilities (view/triage/assign/resolve/close/risk_accept) to registry in `app/Support/Auth/Capabilities.php` +- [X] T014 Map v2 Findings capabilities to tenant roles and keep `TENANT_FINDINGS_ACKNOWLEDGE` as deprecated alias for triage in `app/Services/Auth/RoleCapabilityMap.php` +- [X] T015 Update FindingPolicy to require `TENANT_FINDINGS_VIEW` and expose per-action authorization for workflow mutations in `app/Policies/FindingPolicy.php` +- [X] T016 Update review pack open-finding selection to use v2 open statuses helper in `app/Jobs/GenerateReviewPackJob.php` +- [X] T017 Update review pack fingerprint computation to use v2 open statuses helper in `app/Services/ReviewPackService.php` + +**Checkpoint**: Foundation ready; user story implementation can now begin. + +--- + +## Phase 3: User Story 1 - See Open Findings (Priority: P1) 🎯 MVP + +**Goal**: Default Findings list shows open findings across all finding types (not drift-only), with quick filters and due/assignee visibility. + +**Independent Test**: Seed a tenant with findings across multiple types and statuses, then verify the default list shows open workflow statuses across all types without adjusting filters. + +### Tests for User Story 1 + +- [X] T018 [P] [US1] Add default list visibility test (open statuses across all types) in `tests/Feature/Findings/FindingsListDefaultsTest.php` +- [X] T019 [P] [US1] Add quick filter tests (Overdue, High severity, My assigned) in `tests/Feature/Findings/FindingsListFiltersTest.php` + +### Implementation for User Story 1 + +- [X] T020 [US1] Update FindingResource access gating to use `TENANT_FINDINGS_VIEW` in `app/Filament/Resources/FindingResource.php` +- [X] T021 [US1] Remove drift-only + status=new default filters and default to open v2 statuses in `app/Filament/Resources/FindingResource.php` +- [X] T022 [US1] Add quick filters (Open/Overdue/High severity/My assigned) and due_at/assignee columns in `app/Filament/Resources/FindingResource.php` +- [X] T023 [US1] Update ListFindings filter helpers to match new filter shapes/defaults in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` + +**Checkpoint**: User Story 1 is functional and independently testable. + +--- + +## Phase 4: User Story 2 - Triage, Assign, And Resolve (Priority: P1) + +**Goal**: Consistent v2 workflow actions (single-record) with server-side enforcement, reasons, timestamps, audit logging, and correct 404/403 semantics. + +**Independent Test**: Create an open finding, execute each allowed status transition, and verify transitions are enforced server-side, recorded with timestamps/actors, and audited. + +### Tests for User Story 2 + +- [X] T024 [P] [US2] Add workflow service unit tests (transition validation, reasons required, due_at reset on reopen, due_at stability across severity changes while open, assignment targets must be current tenant members) in `tests/Unit/Findings/FindingWorkflowServiceTest.php` +- [X] T025 [P] [US2] Add Livewire row-action workflow tests (triage/start/assign/resolve/close/risk accept/reopen; assignee/owner picker limited to current tenant members; non-member IDs rejected) in `tests/Feature/Findings/FindingWorkflowRowActionsTest.php` +- [X] T026 [P] [US2] Add Livewire view-header workflow tests (same action set; assignee/owner picker limited to current tenant members) in `tests/Feature/Findings/FindingWorkflowViewActionsTest.php` +- [X] T027 [P] [US2] Add RBAC 404/403 matrix tests for workflow mutations (non-member 404; member missing cap 403) in `tests/Feature/Findings/FindingRbacTest.php` +- [X] T028 [P] [US2] Add audit log tests for workflow mutations (before/after + reasons + actor; assert evidence payloads are never included) in `tests/Feature/Findings/FindingAuditLogTest.php` + +### Implementation for User Story 2 + +- [X] T029 [US2] Implement SLA resolver service (reads `findings.sla_days` via SettingsResolver) in `app/Services/Findings/FindingSlaPolicy.php` +- [X] T030 [US2] Implement workflow transition service (enforced map + timestamps + reason validation + due_at semantics + assignee/owner tenant membership validation + AuditLogger) in `app/Services/Findings/FindingWorkflowService.php` +- [X] T031 [US2] Update Finding model legacy methods/compat helpers for v2 workflow (keep legacy `acknowledged` readable) in `app/Models/Finding.php` +- [X] T032 [US2] Replace acknowledge row action with v2 workflow row actions (UiEnforcement, confirmations for destructive, assignee/owner options limited to current tenant members) in `app/Filament/Resources/FindingResource.php` +- [X] T033 [US2] Add v2 workflow actions to ViewFinding header actions (same capability gates + confirmations; assignee/owner options limited to current tenant members) in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php` +- [X] T034 [US2] Expand ViewFinding infolist to show lifecycle + assignment + SLA fields (first/last seen, times_seen, due_at, assignee/owner, timestamps, reasons; preserve historical assignee/owner display even if membership is later removed) in `app/Filament/Resources/FindingResource.php` +- [X] T035 [US2] Update FindingResource ActionSurface declaration to satisfy v2 UI Action Matrix (detail header actions now present) in `app/Filament/Resources/FindingResource.php` +- [X] T036 [US2] Update legacy drift row-action test from acknowledge to triage in `tests/Feature/Drift/DriftAcknowledgeTest.php` +- [X] T037 [US2] Update legacy drift row-action authorization test from acknowledge to triage capability semantics in `tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php` +- [X] T038 [US2] Update Finding model behavior tests for v2 workflow semantics in `tests/Feature/Models/FindingResolvedTest.php` + +**Checkpoint**: Single-record workflow is functional, enforced, and audited. + +--- + +## Phase 5: User Story 3 - SLA Due Visibility And Alerts (Priority: P1) + +**Goal**: SLA due alert producer emits one tenant-level event when newly-overdue open findings exist; AlertRule UI allows selecting “SLA due”. + +**Independent Test**: Create newly-overdue open findings for a tenant, run alert evaluation, and verify a single tenant-level SLA due event is produced and can match an enabled alert rule. + +### Tests for User Story 3 + +- [X] T039 [P] [US3] Add SLA due producer tests (tenant-level aggregation + newly overdue gating using `window_start`; payload includes overdue_total + overdue_by_severity; idempotent per tenant+window) in `tests/Feature/Alerts/SlaDueAlertTest.php` +- [X] T040 [P] [US3] Update test asserting sla_due is hidden to now assert it is selectable in `tests/Feature/ReviewPack/ReviewPackPruneTest.php` +- [X] T041 [P] [US3] Extend event type options test coverage to include sla_due label in `tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php` + +### Implementation for User Story 3 + +- [X] T042 [US3] Implement `slaDueEvents()` producer and include in EvaluateAlertsJob event list in `app/Jobs/Alerts/EvaluateAlertsJob.php` (use `window_start` from alert evaluation; `fingerprint_key` stable per tenant+window; event metadata contains overdue_total + overdue_by_severity) +- [X] T043 [US3] Re-enable `sla_due` option in AlertRuleResource event type options in `app/Filament/Resources/AlertRuleResource.php` + +**Checkpoint**: SLA due alerting is end-to-end functional. + +--- + +## Phase 6: User Story 4 - Recurrence Reopens (Priority: P2) + +**Goal**: Recurring findings reopen (from resolved only), lifecycle counters update, drift uses stable recurrence identity, and stale drift auto-resolves. + +**Independent Test**: Simulate a finding being resolved and then being detected again, verifying it transitions to `reopened`, counters update, and due date resets. + +### Tests for User Story 4 + +- [X] T044 [P] [US4] Add drift recurrence tests (resolved→reopened; closed/risk_accepted stays terminal; times_seen increments; due_at resets; concurrency: do not auto-reopen if `resolved_at` is after observation time) in `tests/Feature/Findings/FindingRecurrenceTest.php` +- [X] T045 [P] [US4] Add stale drift auto-resolve test (not detected in latest run → resolved_reason=no_longer_detected) in `tests/Feature/Findings/DriftStaleAutoResolveTest.php` +- [X] T046 [P] [US4] Update permission posture generator tests for reopened + lifecycle fields in `tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [X] T047 [P] [US4] Update Entra admin roles generator tests for reopened + lifecycle fields in `tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- [X] T048 [P] [US4] Update alert evaluation tests to include status=reopened where appropriate in `tests/Feature/Alerts/PermissionMissingAlertTest.php` +- [X] T049 [P] [US4] Update alert evaluation tests to include status=reopened where appropriate in `tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php` + +### Implementation for User Story 4 + +- [X] T050 [US4] Add recurrence_key computation helpers (dimension + subject identity) for drift findings in `app/Services/Drift/DriftFindingGenerator.php` +- [X] T051 [US4] Upsert drift findings by `(tenant_id, recurrence_key)` and set canonical `fingerprint = recurrence_key` in `app/Services/Drift/DriftFindingGenerator.php` +- [X] T052 [US4] Maintain lifecycle fields (first/last seen, times_seen) and set due_at on create/reset on reopen for drift findings in `app/Services/Drift/DriftFindingGenerator.php` (do not retroactively change due_at on severity changes) +- [X] T053 [US4] Implement drift auto-reopen only from resolved → reopened (closed/risk_accepted remain terminal; still update seen fields; do not reopen if `resolved_at` is after observation time) in `app/Services/Drift/DriftFindingGenerator.php` +- [X] T054 [US4] Implement stale drift auto-resolve for open drift findings not seen in run (resolved_reason=no_longer_detected) in `app/Services/Drift/DriftFindingGenerator.php` +- [X] T055 [US4] Update permission posture generator to set lifecycle fields + due_at semantics + reopened status handling in `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` (do not retroactively change due_at on severity changes) +- [X] T056 [US4] Update Entra admin roles generator to set lifecycle fields + due_at semantics + reopened status handling in `app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` (do not retroactively change due_at on severity changes) +- [X] T057 [US4] Update EvaluateAlertsJob existing producers to include status `reopened` where “new/re-detected” should alert in `app/Jobs/Alerts/EvaluateAlertsJob.php` + +**Checkpoint**: Recurrence behavior is correct and drift stops creating “new row per re-drift” noise. + +--- + +## Phase 7: User Story 6 - Backfill Existing Findings (Priority: P2) + +**Goal**: One-time backfill/consolidation operation upgrades legacy findings to v2 fields, maps acknowledged→triaged, sets due_at from backfill time, and consolidates drift duplicates. + +**Independent Test**: Seed legacy findings (missing lifecycle fields, `acknowledged` status, drift duplicates), run the backfill operation, and verify fields are populated, statuses are mapped, and duplicates are consolidated. + +### Tests for User Story 6 + +- [X] T058 [P] [US6] Add backfill tests (ack→triaged, lifecycle fields, due_at from backfill time, drift duplicate consolidation) in `tests/Feature/Findings/FindingBackfillTest.php` + +### Implementation for User Story 6 + +- [X] T059 [US6] Register operation type `findings.lifecycle.backfill` in OperationCatalog (label + duration) in `app/Support/OperationCatalog.php` +- [X] T060 [US6] Add backfill artisan command entrypoint (OperationRun-backed; deduped; dispatches job) in `app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` +- [X] T061 [US6] Implement backfill job skeleton (lock + chunking + summary_counts updates via OperationRunService) in `app/Jobs/BackfillFindingLifecycleJob.php` +- [X] T062 [US6] Implement backfill mapping (acknowledged→triaged; set first_seen_at/last_seen_at/times_seen; due_at from backfill time + SLA days for legacy open) in `app/Jobs/BackfillFindingLifecycleJob.php` +- [X] T063 [US6] Implement drift recurrence_key computation for legacy drift evidence in `app/Jobs/BackfillFindingLifecycleJob.php` +- [X] T064 [US6] Implement drift duplicate consolidation (canonical row; duplicates resolved_reason=consolidated_duplicate; clear recurrence_key) in `app/Jobs/BackfillFindingLifecycleJob.php` +- [X] T065 [US6] Add tenant-context Filament action to trigger backfill with Ops-UX queued toast + View run link in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` + +**Checkpoint**: Backfill operation is observable, safe, and upgrades legacy data correctly. + +--- + +## Phase 8: User Story 5 - Bulk Manage Findings (Priority: P3) + +**Goal**: Bulk workflow actions (triage/assign/resolve/close/risk accept) are safe, audited, and efficient for high volumes. + +**Independent Test**: Select multiple findings and run each bulk action, verifying that all selected findings update consistently and each change is audited. + +### Tests for User Story 5 + +- [X] T066 [P] [US5] Add bulk workflow action tests (bulk triage/assign/resolve/close/risk accept + audit per record; cover >=100 records for at least one bulk action) in `tests/Feature/Findings/FindingBulkActionsTest.php` +- [X] T067 [P] [US5] Update legacy bulk acknowledge selected test to bulk triage selected in `tests/Feature/Drift/DriftBulkAcknowledgeTest.php` +- [X] T068 [P] [US5] Update legacy “acknowledge all matching” test to “triage all matching” in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php` +- [X] T069 [P] [US5] Update legacy bulk authorization tests to new bulk action names/capabilities in `tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php` +- [X] T070 [P] [US5] Update legacy “all matching requires typed confirmation >100” test to triage-all-matching in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php` + +### Implementation for User Story 5 + +- [X] T071 [US5] Replace bulk acknowledge actions with bulk workflow actions (UiEnforcement + confirmations + AuditLogger) in `app/Filament/Resources/FindingResource.php` +- [X] T072 [US5] Implement “Triage all matching” header action (typed confirm >100; audited; respects current filters) in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` + +**Checkpoint**: Bulk management is safe and supports high-volume operations. + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +**Purpose**: Repo hygiene and end-to-end validation. + +- [X] T073 [P] Run Pint on touched files and fix formatting via `./vendor/bin/sail bin pint --dirty` +- [X] T074 Run full test suite via Sail and fix failures in `tests/` +- [X] T075 Run quickstart validation for feature 111 in `specs/111-findings-workflow-sla/quickstart.md` +- [X] T076 Add post-backfill hardening migration to enforce NOT NULL on finding seen fields (`first_seen_at`, `last_seen_at`, `times_seen`) in `database/migrations/2026_02_24_160002_enforce_not_null_on_finding_seen_fields.php` +- [X] T077 [P] Verify no new external API calls were introduced (Graph/HTTP) by scanning touched files for `GraphClientInterface` usage and direct HTTP clients (e.g., `Http::`, `Guzzle`) and confirming all Findings/Alerts logic remains DB-only + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies +- **Foundational (Phase 2)**: Depends on Setup completion; BLOCKS all user stories +- **User Stories (Phase 3+)**: + - US1/US2/US3 can proceed in parallel after Foundational + - US4 depends on Foundational (and should align with US2 status model) + - US6 depends on Foundational and should follow US4’s recurrence_key definition for drift + - US5 depends on US2 (bulk actions reuse the workflow service + action builders) +- **Polish (Phase 9)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **US1 (P1)**: No dependencies besides Foundational +- **US2 (P1)**: No dependencies besides Foundational +- **US3 (P1)**: Depends on Foundational (due_at exists + open status semantics defined) +- **US4 (P2)**: Depends on Foundational (v2 statuses + lifecycle fields exist) +- **US6 (P2)**: Depends on Foundational; recommended after US4 so drift recurrence_key matches generator semantics +- **US5 (P3)**: Depends on US2 (workflow service + single-record semantics must be correct first) + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Core services/helpers before UI actions +- UI actions before bulk mutations +- Story complete before moving to next priority + +--- + +## Parallel Examples + +### Parallel Example: User Story 1 + +```bash +# Tests can run in parallel: +T018 # default list visibility test +T019 # quick filter tests + +# Implementation can be split by file: +T020 # resource access gating +T023 # list filter helper adjustments +``` + +### Parallel Example: User Story 2 + +```bash +# Tests can run in parallel: +T024 # workflow service unit tests +T025 # row action tests +T026 # view header action tests +T027 # RBAC semantics tests +T028 # audit log tests + +# Implementation can be split by file: +T029 # SLA resolver service +T033 # view infolist expansion +T036 # legacy drift test migration +``` + +### Parallel Example: User Story 3 + +```bash +# Tests can run in parallel: +T039 # SLA due producer tests +T040 # AlertRuleResource option visibility test update +T041 # alert options label assertions +``` + +### Parallel Example: User Story 4 + +```bash +# Generator test updates can run in parallel: +T046 # permission posture generator tests +T047 # entra admin roles generator tests +T048 # permission_missing alert evaluation tests +T049 # entra admin roles alert evaluation tests +``` + +### Parallel Example: User Story 6 + +```bash +# Backfill code and tests can be split: +T058 # backfill tests +T059 # OperationCatalog registration +T060 # artisan command entrypoint +``` + +### Parallel Example: User Story 5 + +```bash +# Bulk tests can run in parallel with legacy test migrations: +T066 # bulk actions tests +T067 # migrate bulk-selected test +T068 # migrate triage-all-matching test +T069 # migrate bulk auth tests +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. STOP and validate User Story 1 independently + +### Incremental Delivery + +1. Setup + Foundational +2. US1 → validate +3. US2 → validate +4. US3 → validate +5. US4 + US6 → validate +6. US5 → validate + +--- + +## Notes + +- `[P]` tasks = can run in parallel (different files, no dependencies) +- `[US#]` label maps tasks to user stories for traceability +- Destructive-like actions MUST use `->requiresConfirmation()` and be capability-gated (UiEnforcement) +- Operations MUST comply with Ops-UX 3-surface contract when OperationRun is used diff --git a/tests/Feature/Alerts/PermissionMissingAlertTest.php b/tests/Feature/Alerts/PermissionMissingAlertTest.php index b7c9e4e..1d45c7d 100644 --- a/tests/Feature/Alerts/PermissionMissingAlertTest.php +++ b/tests/Feature/Alerts/PermissionMissingAlertTest.php @@ -185,3 +185,30 @@ function createAlertRuleWithDestination(int $workspaceId, string $eventType, str expect($events)->toBe([]); }); + +it('reopened findings are included in permissionMissingEvents', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $finding = Finding::factory()->permissionPosture()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => now(), + ]); + + $job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId); + $reflection = new ReflectionMethod($job, 'permissionMissingEvents'); + + $events = $reflection->invoke( + $job, + $workspaceId, + \Carbon\CarbonImmutable::now('UTC')->subHours(1), + ); + + expect($events)->toHaveCount(1) + ->and($events[0]['event_type'])->toBe(AlertRule::EVENT_PERMISSION_MISSING) + ->and($events[0]['tenant_id'])->toBe((int) $tenant->getKey()) + ->and($events[0]['metadata']['finding_id'])->toBe((int) $finding->getKey()); +}); diff --git a/tests/Feature/Alerts/SlaDueAlertTest.php b/tests/Feature/Alerts/SlaDueAlertTest.php new file mode 100644 index 0000000..a0e21aa --- /dev/null +++ b/tests/Feature/Alerts/SlaDueAlertTest.php @@ -0,0 +1,196 @@ +> + */ +function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): array +{ + $job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId); + $reflection = new ReflectionMethod($job, 'slaDueEvents'); + + /** @var array> $events */ + $events = $reflection->invoke($job, $workspaceId, $windowStart); + + return $events; +} + +it('produces one sla due event per tenant and summarizes current overdue open findings', function (): void { + $now = CarbonImmutable::parse('2026-02-24T12:00:00Z'); + CarbonImmutable::setTestNow($now); + + [$user, $tenantA] = createUserWithTenant(role: 'owner'); + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $tenantB = Tenant::factory()->create(['workspace_id' => $workspaceId]); + $tenantC = Tenant::factory()->create(['workspace_id' => $workspaceId]); + + $windowStart = $now->subHour(); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantA->getKey(), + 'status' => Finding::STATUS_IN_PROGRESS, + 'severity' => Finding::SEVERITY_CRITICAL, + 'due_at' => $now->subMinutes(10), + ]); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantA->getKey(), + 'status' => Finding::STATUS_TRIAGED, + 'severity' => Finding::SEVERITY_HIGH, + 'due_at' => $now->subDays(1), + ]); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantA->getKey(), + 'status' => Finding::STATUS_NEW, + 'severity' => Finding::SEVERITY_MEDIUM, + 'due_at' => $windowStart, + ]); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantA->getKey(), + 'status' => Finding::STATUS_RESOLVED, + 'severity' => Finding::SEVERITY_LOW, + 'due_at' => $now->subMinutes(5), + ]); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantB->getKey(), + 'status' => Finding::STATUS_REOPENED, + 'severity' => Finding::SEVERITY_HIGH, + 'due_at' => $now->subDays(2), + ]); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantC->getKey(), + 'status' => Finding::STATUS_NEW, + 'severity' => Finding::SEVERITY_LOW, + 'due_at' => $now->subMinutes(20), + ]); + + $events = invokeSlaDueEvents($workspaceId, $windowStart); + + expect($events)->toHaveCount(2); + + $eventsByTenant = collect($events)->keyBy(static fn (array $event): int => (int) $event['tenant_id']); + + expect($eventsByTenant->keys()->all()) + ->toEqualCanonicalizing([(int) $tenantA->getKey(), (int) $tenantC->getKey()]); + + $tenantAEvent = $eventsByTenant->get((int) $tenantA->getKey()); + + expect($tenantAEvent) + ->not->toBeNull() + ->and($tenantAEvent['event_type'])->toBe(AlertRule::EVENT_SLA_DUE) + ->and($tenantAEvent['severity'])->toBe(Finding::SEVERITY_CRITICAL) + ->and($tenantAEvent['metadata'])->toMatchArray([ + 'overdue_total' => 3, + 'overdue_by_severity' => [ + 'critical' => 1, + 'high' => 1, + 'medium' => 1, + 'low' => 0, + ], + ]) + ->and($tenantAEvent['metadata'])->not->toHaveKey('finding_ids'); + + $tenantCEvent = $eventsByTenant->get((int) $tenantC->getKey()); + + expect($tenantCEvent) + ->not->toBeNull() + ->and($tenantCEvent['severity'])->toBe(Finding::SEVERITY_LOW) + ->and($tenantCEvent['metadata'])->toMatchArray([ + 'overdue_total' => 1, + 'overdue_by_severity' => [ + 'critical' => 0, + 'high' => 0, + 'medium' => 0, + 'low' => 1, + ], + ]); +}); + +it('gates sla due events to newly overdue open findings after window start', function (): void { + $now = CarbonImmutable::parse('2026-02-24T12:00:00Z'); + CarbonImmutable::setTestNow($now); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $windowStart = $now->subHour(); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'status' => Finding::STATUS_NEW, + 'severity' => Finding::SEVERITY_HIGH, + 'due_at' => $now->subDays(1), + ]); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'status' => Finding::STATUS_NEW, + 'severity' => Finding::SEVERITY_MEDIUM, + 'due_at' => $windowStart, + ]); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'status' => Finding::STATUS_CLOSED, + 'severity' => Finding::SEVERITY_CRITICAL, + 'due_at' => $now->subMinutes(5), + ]); + + expect(invokeSlaDueEvents($workspaceId, $windowStart))->toBe([]); +}); + +it('uses a stable fingerprint per tenant and alert window', function (): void { + $now = CarbonImmutable::parse('2026-02-24T12:00:00Z'); + CarbonImmutable::setTestNow($now); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + Finding::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'status' => Finding::STATUS_NEW, + 'severity' => Finding::SEVERITY_HIGH, + 'due_at' => $now->subMinute(), + ]); + + $windowA = $now->subMinutes(5); + $windowB = $now->subMinutes(2); + + $first = invokeSlaDueEvents($workspaceId, $windowA); + $second = invokeSlaDueEvents($workspaceId, $windowA); + $third = invokeSlaDueEvents($workspaceId, $windowB); + + expect($first)->toHaveCount(1) + ->and($second)->toHaveCount(1) + ->and($third)->toHaveCount(1) + ->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key']) + ->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']); +}); diff --git a/tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php b/tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php index 5ffa264..f518900 100644 --- a/tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php +++ b/tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php @@ -5,7 +5,7 @@ use Filament\Facades\Filament; use Livewire\Livewire; -test('readonly users cannot acknowledge findings', function () { +test('readonly users cannot triage findings', function () { [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -16,8 +16,8 @@ ]); Livewire::test(ListFindings::class) - ->assertTableActionDisabled('acknowledge', $finding) - ->callTableAction('acknowledge', $finding); + ->assertTableActionDisabled('triage', $finding) + ->callTableAction('triage', $finding); $finding->refresh(); expect($finding->status)->toBe(Finding::STATUS_NEW); diff --git a/tests/Feature/Drift/DriftAcknowledgeTest.php b/tests/Feature/Drift/DriftAcknowledgeTest.php index 05f8784..905439c 100644 --- a/tests/Feature/Drift/DriftAcknowledgeTest.php +++ b/tests/Feature/Drift/DriftAcknowledgeTest.php @@ -5,7 +5,7 @@ use Filament\Facades\Filament; use Livewire\Livewire; -test('a finding can be acknowledged via table action', function () { +test('a finding can be triaged via table action', function () { [$user, $tenant] = createUserWithTenant(role: 'manager'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -18,11 +18,10 @@ ]); Livewire::test(ListFindings::class) - ->callTableAction('acknowledge', $finding); + ->callTableAction('triage', $finding); $finding->refresh(); - expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED); - expect($finding->acknowledged_at)->not->toBeNull(); - expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey()); + expect($finding->status)->toBe(Finding::STATUS_TRIAGED); + expect($finding->triaged_at)->not->toBeNull(); }); diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php index 936ff40..b5d10c7 100644 --- a/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php @@ -5,7 +5,7 @@ use Filament\Facades\Filament; use Livewire\Livewire; -test('acknowledge all matching requires confirmation when acknowledging more than 100 findings', function () { +test('triage all matching requires typed confirmation when triaging more than 100 findings', function () { [$user, $tenant] = createUserWithTenant(role: 'manager'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -19,16 +19,16 @@ ]); Livewire::test(ListFindings::class) - ->mountAction('acknowledge_all_matching') + ->mountAction('triage_all_matching') ->callMountedAction(); $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); Livewire::test(ListFindings::class) - ->mountAction('acknowledge_all_matching') - ->setActionData(['confirmation' => 'ACKNOWLEDGE']) + ->mountAction('triage_all_matching') + ->setActionData(['confirmation' => 'TRIAGE']) ->callMountedAction() ->assertHasNoActionErrors(); - $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED)); + $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_TRIAGED)); }); diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php index b283d35..5de9ced 100644 --- a/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php @@ -5,7 +5,7 @@ use Filament\Facades\Filament; use Livewire\Livewire; -test('acknowledge all matching respects scope key filter', function () { +test('triage all matching respects scope key filter', function () { [$user, $tenant] = createUserWithTenant(role: 'manager'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -36,13 +36,13 @@ 'finding_type' => ['value' => Finding::FINDING_TYPE_DRIFT], 'scope_key' => ['scope_key' => $scopeA], ]) - ->callAction('acknowledge_all_matching'); + ->callAction('triage_all_matching'); - $matching->each(function (Finding $finding) use ($user): void { + $matching->each(function (Finding $finding): void { $finding->refresh(); - expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED); - expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey()); + expect($finding->status)->toBe(Finding::STATUS_TRIAGED); + expect($finding->triaged_at)->not->toBeNull(); }); $nonMatching->refresh(); diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php index da4d808..0e7653c 100644 --- a/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php @@ -5,7 +5,7 @@ use Filament\Facades\Filament; use Livewire\Livewire; -test('readonly users cannot bulk acknowledge selected findings', function () { +test('readonly users cannot bulk triage selected findings', function () { [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -19,11 +19,11 @@ ]); $component = Livewire::test(ListFindings::class) - ->assertTableBulkActionVisible('acknowledge_selected') - ->assertTableBulkActionDisabled('acknowledge_selected'); + ->assertTableBulkActionVisible('triage_selected') + ->assertTableBulkActionDisabled('triage_selected'); try { - $component->callTableBulkAction('acknowledge_selected', $findings); + $component->callTableBulkAction('triage_selected', $findings); } catch (Throwable) { // Filament actions may abort/throw when forced to execute. } @@ -31,7 +31,7 @@ $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW)); }); -test('readonly users cannot acknowledge all matching findings', function () { +test('readonly users cannot triage all matching findings', function () { [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -45,11 +45,11 @@ ]); $component = Livewire::test(ListFindings::class) - ->assertActionVisible('acknowledge_all_matching') - ->assertActionDisabled('acknowledge_all_matching'); + ->assertActionVisible('triage_all_matching') + ->assertActionDisabled('triage_all_matching'); try { - $component->callAction('acknowledge_all_matching'); + $component->callAction('triage_all_matching'); } catch (Throwable) { // Filament actions may abort/throw when forced to execute. } diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeTest.php index ea613bf..8c0b0df 100644 --- a/tests/Feature/Drift/DriftBulkAcknowledgeTest.php +++ b/tests/Feature/Drift/DriftBulkAcknowledgeTest.php @@ -21,14 +21,15 @@ ]); Livewire::test(ListFindings::class) - ->callTableBulkAction('acknowledge_selected', $findings) + ->callTableBulkAction('triage_selected', $findings) ->assertHasNoTableBulkActionErrors(); - $findings->each(function (Finding $finding) use ($user): void { + $findings->each(function (Finding $finding): void { $finding->refresh(); - expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED); - expect($finding->acknowledged_at)->not->toBeNull(); - expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey()); + expect($finding->status)->toBe(Finding::STATUS_TRIAGED); + expect($finding->triaged_at)->not->toBeNull(); + expect($finding->acknowledged_at)->toBeNull(); + expect($finding->acknowledged_by_user_id)->toBeNull(); }); }); diff --git a/tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php b/tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php index c6fa404..0c39c63 100644 --- a/tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php +++ b/tests/Feature/EntraAdminRoles/AdminRolesAlertIntegrationTest.php @@ -176,9 +176,38 @@ function createEntraAlertRuleWithDestination(int $workspaceId, string $minSeveri expect($events)->toBe([]); }); +it('reopened findings are included in entraAdminRolesHighEvents', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $finding = Finding::factory()->entraAdminRoles()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'severity' => Finding::SEVERITY_CRITICAL, + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => now(), + ]); + + $job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId); + $reflection = new ReflectionMethod($job, 'entraAdminRolesHighEvents'); + + $events = $reflection->invoke( + $job, + $workspaceId, + CarbonImmutable::now('UTC')->subHours(1), + ); + + expect($events)->toHaveCount(1) + ->and($events[0]['event_type'])->toBe(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH) + ->and($events[0]['tenant_id'])->toBe((int) $tenant->getKey()) + ->and($events[0]['metadata']['finding_id'])->toBe((int) $finding->getKey()); +}); + it('new event type appears in AlertRuleResource event type options', function (): void { $options = AlertRuleResource::eventTypeOptions(); - expect($options)->toHaveKey(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH) + expect($options)->toHaveKey(AlertRule::EVENT_SLA_DUE) + ->and($options[AlertRule::EVENT_SLA_DUE])->toBe('SLA due') + ->and($options)->toHaveKey(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH) ->and($options[AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH])->toBe('Entra admin roles (high privilege)'); }); diff --git a/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php b/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php index 5317e99..f3ba8f8 100644 --- a/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +++ b/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php @@ -88,12 +88,14 @@ function makeGenerator(): EntraAdminRolesFindingGenerator it('creates findings for high-privilege assignments with correct attributes', function (): void { [$user, $tenant] = createUserWithTenant(); + $measuredAt = '2026-02-24T10:00:00Z'; $payload = buildPayload( [gaRoleDef(), secAdminRoleDef()], [ makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Admin'), makeEntraAssignment('a2', 'def-secadmin', 'user-2', '#microsoft.graph.user', 'Bob SecAdmin'), ], + $measuredAt, ); $result = makeGenerator()->generate($tenant, $payload); @@ -116,10 +118,17 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ->and($gaFinding->subject_type)->toBe('role_assignment') ->and($gaFinding->subject_external_id)->toBe('user-1:def-ga') ->and($gaFinding->status)->toBe(Finding::STATUS_NEW); + expect($gaFinding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($gaFinding->last_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($gaFinding->times_seen)->toBe(1) + ->and($gaFinding->sla_days)->toBe(3) + ->and($gaFinding->due_at?->toIso8601String())->toBe('2026-02-27T10:00:00+00:00'); $secFinding = $findings->firstWhere('severity', Finding::SEVERITY_HIGH); expect($secFinding)->not->toBeNull() - ->and($secFinding->subject_external_id)->toBe('user-2:def-secadmin'); + ->and($secFinding->subject_external_id)->toBe('user-2:def-secadmin') + ->and($secFinding->sla_days)->toBe(7) + ->and($secFinding->due_at?->toIso8601String())->toBe('2026-03-03T10:00:00+00:00'); }); it('maps severity: GA is critical, others are high', function (): void { @@ -150,14 +159,21 @@ function makeGenerator(): EntraAdminRolesFindingGenerator it('is idempotent — same data produces no duplicates', function (): void { [$user, $tenant] = createUserWithTenant(); - $payload = buildPayload( + $payload1 = buildPayload( [gaRoleDef()], [makeEntraAssignment('a1', 'def-ga', 'user-1')], + '2026-02-24T10:00:00Z', ); $generator = makeGenerator(); - $first = $generator->generate($tenant, $payload); - $second = $generator->generate($tenant, $payload); + $first = $generator->generate($tenant, $payload1); + + $payload2 = buildPayload( + [gaRoleDef()], + [makeEntraAssignment('a1', 'def-ga', 'user-1')], + '2026-02-24T11:00:00Z', + ); + $second = $generator->generate($tenant, $payload2); expect($first->created)->toBe(1) ->and($second->created)->toBe(0) @@ -169,6 +185,14 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ->count(); expect($count)->toBe(1); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) + ->first(); + + expect($finding->times_seen)->toBe(2) + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00'); }); it('auto-resolves when assignment is removed', function (): void { @@ -208,17 +232,19 @@ function makeGenerator(): EntraAdminRolesFindingGenerator $payload1 = buildPayload( [gaRoleDef()], [makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice')], + '2026-02-24T10:00:00Z', ); $generator->generate($tenant, $payload1); // Scan 2: remove → auto-resolve - $payload2 = buildPayload([gaRoleDef()], []); + $payload2 = buildPayload([gaRoleDef()], [], '2026-02-25T10:00:00Z'); $generator->generate($tenant, $payload2); // Scan 3: re-assign → re-open $payload3 = buildPayload( [gaRoleDef()], [makeEntraAssignment('a1', 'def-ga', 'user-1', '#microsoft.graph.user', 'Alice Reactivated')], + '2026-02-26T10:00:00Z', ); $result3 = $generator->generate($tenant, $payload3); @@ -230,7 +256,8 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ->where('subject_external_id', 'user-1:def-ga') ->first(); - expect($finding->status)->toBe(Finding::STATUS_NEW) + expect($finding->status)->toBe(Finding::STATUS_REOPENED) + ->and($finding->reopened_at?->toIso8601String())->toBe('2026-02-26T10:00:00+00:00') ->and($finding->resolved_at)->toBeNull() ->and($finding->resolved_reason)->toBeNull() ->and($finding->evidence_jsonb['principal_display_name'])->toBe('Alice Reactivated'); diff --git a/tests/Feature/Findings/DriftStaleAutoResolveTest.php b/tests/Feature/Findings/DriftStaleAutoResolveTest.php new file mode 100644 index 0000000..41e13d4 --- /dev/null +++ b/tests/Feature/Findings/DriftStaleAutoResolveTest.php @@ -0,0 +1,136 @@ +where('tenant_id', (int) $tenant->getKey()) + ->where('policy_id', (int) $policy->getKey()) + ->max('version_number'); + + $startingVersion = is_numeric($startingVersion) ? (int) $startingVersion : 0; + $baselineVersionNumber = $startingVersion + 1; + $currentVersionNumber = $startingVersion + 2; + + PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => $baselineVersionNumber, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $baseline->finished_at->copy()->subMinute(), + 'snapshot' => $baselineSnapshot, + 'assignments' => [], + ]); + + PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => $currentVersionNumber, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $current->finished_at->copy()->subMinute(), + 'snapshot' => $currentSnapshot, + 'assignments' => [], + ]); +} + +it('auto-resolves open drift findings not detected in the latest run as no_longer_detected', function (): void { + [, $tenant] = createUserWithTenant(role: 'manager'); + + $scopeKey = hash('sha256', 'scope-drift-stale-auto-resolve'); + + $policy = Policy::factory()->for($tenant)->create([ + 'external_id' => 'policy-stale-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + ]); + + $baseline1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), + ]); + + $current1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), + ]); + + seedPolicyVersionsForStaleTest( + tenant: $tenant, + policy: $policy, + baseline: $baseline1, + current: $current1, + baselineSnapshot: ['setting' => 'old'], + currentSnapshot: ['setting' => 'new'], + ); + + $generator = app(DriftFindingGenerator::class); + $created1 = $generator->generate($tenant, $baseline1, $current1, $scopeKey); + + expect($created1)->toBe(1); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->where('subject_type', 'policy') + ->firstOrFail(); + + expect($finding->status)->toBe(Finding::STATUS_NEW); + + $baseline2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-24T00:00:00Z'), + ]); + + $current2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-25T00:00:00Z'), + ]); + + seedPolicyVersionsForStaleTest( + tenant: $tenant, + policy: $policy, + baseline: $baseline2, + current: $current2, + baselineSnapshot: ['setting' => 'same'], + currentSnapshot: ['setting' => 'same'], + ); + + $created2 = $generator->generate($tenant, $baseline2, $current2, $scopeKey); + + expect($created2)->toBe(0); + + $finding->refresh(); + + expect(Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->count())->toBe(1); + + expect($finding->status)->toBe(Finding::STATUS_RESOLVED) + ->and($finding->resolved_reason)->toBe('no_longer_detected') + ->and($finding->resolved_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00'); +}); diff --git a/tests/Feature/Findings/FindingAuditLogTest.php b/tests/Feature/Findings/FindingAuditLogTest.php new file mode 100644 index 0000000..c3440b3 --- /dev/null +++ b/tests/Feature/Findings/FindingAuditLogTest.php @@ -0,0 +1,81 @@ +for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'evidence_jsonb' => [ + 'secret_token' => 'should-not-be-audited', + 'payload' => ['x' => 'y'], + ], + ]); + + $service = app(FindingWorkflowService::class); + + $service->triage($finding, $tenant, $user); + $service->resolve($finding->refresh(), $tenant, $user, 'fixed'); + + $audit = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('resource_type', 'finding') + ->where('resource_id', (string) $finding->getKey()) + ->where('action', 'finding.resolved') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect((int) $audit->actor_id)->toBe((int) $user->getKey()) + ->and($audit->actor_email)->toBe($user->email) + ->and(data_get($audit->metadata, 'finding_id'))->toBe((int) $finding->getKey()) + ->and(data_get($audit->metadata, 'before_status'))->toBe(Finding::STATUS_TRIAGED) + ->and(data_get($audit->metadata, 'after_status'))->toBe(Finding::STATUS_RESOLVED) + ->and(data_get($audit->metadata, 'resolved_reason'))->toBe('fixed') + ->and(data_get($audit->metadata, 'before'))->toBeArray() + ->and(data_get($audit->metadata, 'after'))->toBeArray() + ->and(data_get($audit->metadata, 'evidence_jsonb'))->toBeNull() + ->and(data_get($audit->metadata, 'before.evidence_jsonb'))->toBeNull() + ->and(data_get($audit->metadata, 'after.evidence_jsonb'))->toBeNull(); +}); + +it('writes assignment audit entries without evidence payloads', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + $assignee = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + app(FindingWorkflowService::class)->assign( + finding: $finding, + tenant: $tenant, + actor: $owner, + assigneeUserId: (int) $assignee->getKey(), + ownerUserId: (int) $owner->getKey(), + ); + + $audit = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'finding.assigned') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect(data_get($audit->metadata, 'assignee_user_id'))->toBe((int) $assignee->getKey()) + ->and(data_get($audit->metadata, 'owner_user_id'))->toBe((int) $owner->getKey()) + ->and(data_get($audit->metadata, 'before'))->toBeArray() + ->and(data_get($audit->metadata, 'after'))->toBeArray() + ->and(data_get($audit->metadata, 'before.evidence_jsonb'))->toBeNull() + ->and(data_get($audit->metadata, 'after.evidence_jsonb'))->toBeNull(); +}); diff --git a/tests/Feature/Findings/FindingBackfillTest.php b/tests/Feature/Findings/FindingBackfillTest.php new file mode 100644 index 0000000..9517519 --- /dev/null +++ b/tests/Feature/Findings/FindingBackfillTest.php @@ -0,0 +1,134 @@ +for($tenant)->create([ + 'severity' => Finding::SEVERITY_MEDIUM, + 'status' => Finding::STATUS_ACKNOWLEDGED, + 'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), + 'acknowledged_by_user_id' => (int) $user->getKey(), + 'first_seen_at' => null, + 'last_seen_at' => null, + 'times_seen' => null, + 'sla_days' => null, + 'due_at' => null, + 'triaged_at' => null, + 'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'), + 'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'), + ]); + + BackfillFindingLifecycleJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: (int) $user->getKey(), + ); + + $finding->refresh(); + + expect($finding->status)->toBe(Finding::STATUS_TRIAGED) + ->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00') + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00') + ->and($finding->times_seen)->toBe(1) + ->and($finding->sla_days)->toBe(14) + ->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00') + ->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00') + ->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey()); + + CarbonImmutable::setTestNow(); +}); + +it('computes drift recurrence keys and consolidates drift duplicates', function (): void { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $scopeKey = hash('sha256', 'scope-drift-backfill-duplicate'); + + $evidence = [ + 'change_type' => 'modified', + 'summary' => [ + 'kind' => 'policy_snapshot', + 'changed_fields' => ['snapshot_hash'], + ], + 'baseline' => ['policy_id' => 'policy-dupe'], + 'current' => ['policy_id' => 'policy-dupe'], + ]; + + $open = Finding::factory()->for($tenant)->create([ + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'scope_key' => $scopeKey, + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-dupe', + 'status' => Finding::STATUS_NEW, + 'recurrence_key' => null, + 'evidence_jsonb' => $evidence, + 'first_seen_at' => null, + 'last_seen_at' => null, + 'times_seen' => null, + 'sla_days' => null, + 'due_at' => null, + 'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), + 'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), + ]); + + $duplicate = Finding::factory()->for($tenant)->create([ + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'scope_key' => $scopeKey, + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-dupe', + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), + 'resolved_reason' => 'fixed', + 'recurrence_key' => null, + 'evidence_jsonb' => $evidence, + 'first_seen_at' => null, + 'last_seen_at' => null, + 'times_seen' => null, + 'created_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'), + 'updated_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'), + ]); + + BackfillFindingLifecycleJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: (int) $user->getKey(), + ); + + $tenantId = (int) $tenant->getKey(); + $expectedRecurrenceKey = hash( + 'sha256', + sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, 'policy-dupe'), + ); + + expect(Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('recurrence_key', $expectedRecurrenceKey) + ->count())->toBe(1); + + $open->refresh(); + $duplicate->refresh(); + + expect($open->recurrence_key)->toBe($expectedRecurrenceKey) + ->and($open->status)->toBe(Finding::STATUS_NEW); + + expect($duplicate->recurrence_key)->toBeNull() + ->and($duplicate->status)->toBe(Finding::STATUS_RESOLVED) + ->and($duplicate->resolved_reason)->toBe('consolidated_duplicate') + ->and($duplicate->resolved_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00'); + + CarbonImmutable::setTestNow(); +}); diff --git a/tests/Feature/Findings/FindingBulkActionsTest.php b/tests/Feature/Findings/FindingBulkActionsTest.php new file mode 100644 index 0000000..e4e4d16 --- /dev/null +++ b/tests/Feature/Findings/FindingBulkActionsTest.php @@ -0,0 +1,155 @@ +actingAs($manager); + Filament::setTenant($tenant, true); + + $findings = Finding::factory() + ->count(101) + ->for($tenant) + ->create([ + 'status' => Finding::STATUS_NEW, + 'triaged_at' => null, + ]); + + Livewire::test(ListFindings::class) + ->callTableBulkAction('triage_selected', $findings) + ->assertHasNoTableBulkActionErrors(); + + $findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_TRIAGED)); + + expect(AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'finding.triaged') + ->count())->toBe(101); + + $assignee = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + + $assignFindings = Finding::factory() + ->count(3) + ->for($tenant) + ->create([ + 'status' => Finding::STATUS_TRIAGED, + 'assignee_user_id' => null, + 'owner_user_id' => null, + ]); + + Livewire::test(ListFindings::class) + ->callTableBulkAction('assign_selected', $assignFindings, data: [ + 'assignee_user_id' => (int) $assignee->getKey(), + 'owner_user_id' => (int) $manager->getKey(), + ]) + ->assertHasNoTableBulkActionErrors(); + + $assignFindings->each(function (Finding $finding) use ($assignee, $manager): void { + $finding->refresh(); + + expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()) + ->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey()); + }); + + expect(AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'finding.assigned') + ->count())->toBe(3); + + $resolveFindings = Finding::factory() + ->count(2) + ->for($tenant) + ->create([ + 'status' => Finding::STATUS_IN_PROGRESS, + 'resolved_at' => null, + 'resolved_reason' => null, + ]); + + Livewire::test(ListFindings::class) + ->callTableBulkAction('resolve_selected', $resolveFindings, data: [ + 'resolved_reason' => 'fixed', + ]) + ->assertHasNoTableBulkActionErrors(); + + $resolveFindings->each(function (Finding $finding): void { + $finding->refresh(); + + expect($finding->status)->toBe(Finding::STATUS_RESOLVED) + ->and($finding->resolved_reason)->toBe('fixed') + ->and($finding->resolved_at)->not->toBeNull(); + }); + + expect(AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'finding.resolved') + ->count())->toBe(2); + + $closeFindings = Finding::factory() + ->count(2) + ->for($tenant) + ->create([ + 'status' => Finding::STATUS_TRIAGED, + 'closed_at' => null, + 'closed_reason' => null, + ]); + + Livewire::test(ListFindings::class) + ->callTableBulkAction('close_selected', $closeFindings, data: [ + 'closed_reason' => 'not applicable', + ]) + ->assertHasNoTableBulkActionErrors(); + + $closeFindings->each(function (Finding $finding): void { + $finding->refresh(); + + expect($finding->status)->toBe(Finding::STATUS_CLOSED) + ->and($finding->closed_reason)->toBe('not applicable') + ->and($finding->closed_at)->not->toBeNull() + ->and($finding->closed_by_user_id)->not->toBeNull(); + }); + + expect(AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'finding.closed') + ->count())->toBe(2); + + $riskFindings = Finding::factory() + ->count(2) + ->for($tenant) + ->create([ + 'status' => Finding::STATUS_TRIAGED, + 'closed_at' => null, + 'closed_reason' => null, + ]); + + Livewire::test(ListFindings::class) + ->callTableBulkAction('risk_accept_selected', $riskFindings, data: [ + 'closed_reason' => 'accepted risk', + ]) + ->assertHasNoTableBulkActionErrors(); + + $riskFindings->each(function (Finding $finding): void { + $finding->refresh(); + + expect($finding->status)->toBe(Finding::STATUS_RISK_ACCEPTED) + ->and($finding->closed_reason)->toBe('accepted risk') + ->and($finding->closed_at)->not->toBeNull() + ->and($finding->closed_by_user_id)->not->toBeNull(); + }); + + expect(AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'finding.risk_accepted') + ->count())->toBe(2); +}); diff --git a/tests/Feature/Findings/FindingRbacTest.php b/tests/Feature/Findings/FindingRbacTest.php new file mode 100644 index 0000000..d7f6976 --- /dev/null +++ b/tests/Feature/Findings/FindingRbacTest.php @@ -0,0 +1,66 @@ +create(); + [$user] = createUserWithTenant(role: 'owner'); + + $finding = Finding::factory()->for($tenant)->create(); + + $this->actingAs($user) + ->get(FindingResource::getUrl('index', tenant: $tenant)) + ->assertNotFound(); + + $this->actingAs($user) + ->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)) + ->assertNotFound(); +}); + +it('shows triage row action disabled for readonly members', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + Livewire::test(ListFindings::class) + ->assertTableActionVisible('triage', $finding) + ->assertTableActionDisabled('triage', $finding); +}); + +it('enforces 404 for non-member and 403 for member missing capability in workflow service', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $outsider = \App\Models\User::factory()->create(); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $service = app(FindingWorkflowService::class); + + expect(fn () => $service->triage($finding, $tenant, $outsider)) + ->toThrow(NotFoundHttpException::class); + + expect(fn () => $service->triage($finding, $tenant, $readonly)) + ->toThrow(AuthorizationException::class); + + $triaged = $service->triage($finding, $tenant, $owner); + + expect($triaged->status)->toBe(Finding::STATUS_TRIAGED); +}); diff --git a/tests/Feature/Findings/FindingRecurrenceTest.php b/tests/Feature/Findings/FindingRecurrenceTest.php new file mode 100644 index 0000000..bc6a77a --- /dev/null +++ b/tests/Feature/Findings/FindingRecurrenceTest.php @@ -0,0 +1,341 @@ +where('tenant_id', (int) $tenant->getKey()) + ->where('policy_id', (int) $policy->getKey()) + ->max('version_number'); + + $startingVersion = is_numeric($startingVersion) ? (int) $startingVersion : 0; + $baselineVersionNumber = $startingVersion + 1; + $currentVersionNumber = $startingVersion + 2; + + PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => $baselineVersionNumber, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $baseline->finished_at->copy()->subMinute(), + 'snapshot' => $baselineSnapshot, + 'assignments' => [], + ]); + + PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => $currentVersionNumber, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $current->finished_at->copy()->subMinute(), + 'snapshot' => $currentSnapshot, + 'assignments' => [], + ]); +} + +it('reopens a resolved drift finding on recurrence and resets due_at', function (): void { + [, $tenant] = createUserWithTenant(role: 'manager'); + + $scopeKey = hash('sha256', 'scope-drift-recurrence-reopen'); + + $policy = Policy::factory()->for($tenant)->create([ + 'external_id' => 'policy-recur-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + ]); + + $baseline1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), + ]); + + $current1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), + ]); + + seedPolicySnapshotVersions( + tenant: $tenant, + policy: $policy, + baseline: $baseline1, + current: $current1, + baselineSnapshot: ['setting' => 'old'], + currentSnapshot: ['setting' => 'new'], + ); + + $generator = app(DriftFindingGenerator::class); + $created1 = $generator->generate($tenant, $baseline1, $current1, $scopeKey); + + expect($created1)->toBe(1); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->where('subject_type', 'policy') + ->first(); + + $tenantId = (int) $tenant->getKey(); + $expectedRecurrenceKey = hash( + 'sha256', + sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, (string) $policy->external_id), + ); + + expect($finding)->not->toBeNull() + ->and($finding->recurrence_key)->toBe($expectedRecurrenceKey) + ->and($finding->fingerprint)->toBe($expectedRecurrenceKey) + ->and($finding->status)->toBe(Finding::STATUS_NEW) + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-21T00:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-21T00:00:00+00:00') + ->and($finding->times_seen)->toBe(1) + ->and($finding->sla_days)->toBe(14) + ->and($finding->due_at?->toIso8601String())->toBe('2026-03-07T00:00:00+00:00'); + + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), + 'resolved_reason' => 'fixed', + ])->save(); + + $baseline2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-24T00:00:00Z'), + ]); + + $current2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-25T00:00:00Z'), + ]); + + seedPolicySnapshotVersions( + tenant: $tenant, + policy: $policy, + baseline: $baseline2, + current: $current2, + baselineSnapshot: ['setting' => 'old-again'], + currentSnapshot: ['setting' => 'new-again'], + ); + + $created2 = $generator->generate($tenant, $baseline2, $current2, $scopeKey); + + expect($created2)->toBe(0); + + $finding->refresh(); + + expect(Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('recurrence_key', $expectedRecurrenceKey) + ->count())->toBe(1); + + expect($finding->status)->toBe(Finding::STATUS_REOPENED) + ->and($finding->reopened_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00') + ->and($finding->resolved_at)->toBeNull() + ->and($finding->resolved_reason)->toBeNull() + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-21T00:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe(14) + ->and($finding->due_at?->toIso8601String())->toBe('2026-03-11T00:00:00+00:00') + ->and((int) $finding->baseline_operation_run_id)->toBe((int) $baseline2->getKey()) + ->and((int) $finding->current_operation_run_id)->toBe((int) $current2->getKey()); +}); + +it('keeps closed drift findings terminal on recurrence but updates seen tracking', function (): void { + [, $tenant] = createUserWithTenant(role: 'manager'); + + $scopeKey = hash('sha256', 'scope-drift-recurrence-closed-terminal'); + + $policy = Policy::factory()->for($tenant)->create([ + 'external_id' => 'policy-recur-2', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + ]); + + $baseline1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), + ]); + + $current1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), + ]); + + seedPolicySnapshotVersions( + tenant: $tenant, + policy: $policy, + baseline: $baseline1, + current: $current1, + baselineSnapshot: ['setting' => 'old'], + currentSnapshot: ['setting' => 'new'], + ); + + $generator = app(DriftFindingGenerator::class); + $generator->generate($tenant, $baseline1, $current1, $scopeKey); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->where('subject_type', 'policy') + ->firstOrFail(); + + $initialDueAt = $finding->due_at; + + $finding->forceFill([ + 'status' => Finding::STATUS_CLOSED, + 'closed_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), + 'closed_reason' => 'accepted', + ])->save(); + + $baseline2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-24T00:00:00Z'), + ]); + + $current2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-25T00:00:00Z'), + ]); + + seedPolicySnapshotVersions( + tenant: $tenant, + policy: $policy, + baseline: $baseline2, + current: $current2, + baselineSnapshot: ['setting' => 'old-again'], + currentSnapshot: ['setting' => 'new-again'], + ); + + $created2 = $generator->generate($tenant, $baseline2, $current2, $scopeKey); + + expect($created2)->toBe(0); + + $finding->refresh(); + + expect($finding->status)->toBe(Finding::STATUS_CLOSED) + ->and($finding->reopened_at)->toBeNull() + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->due_at?->toIso8601String())->toBe($initialDueAt?->toIso8601String()); +}); + +it('does not auto-reopen when resolved_at is after the observation time but advances seen counters', function (): void { + [, $tenant] = createUserWithTenant(role: 'manager'); + + $scopeKey = hash('sha256', 'scope-drift-recurrence-concurrency'); + + $policy = Policy::factory()->for($tenant)->create([ + 'external_id' => 'policy-recur-3', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + ]); + + $baseline1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), + ]); + + $current1 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), + ]); + + seedPolicySnapshotVersions( + tenant: $tenant, + policy: $policy, + baseline: $baseline1, + current: $current1, + baselineSnapshot: ['setting' => 'old'], + currentSnapshot: ['setting' => 'new'], + ); + + $generator = app(DriftFindingGenerator::class); + $generator->generate($tenant, $baseline1, $current1, $scopeKey); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->where('subject_type', 'policy') + ->firstOrFail(); + + $initialDueAt = $finding->due_at; + + $finding->forceFill([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => CarbonImmutable::parse('2026-02-26T00:00:00Z'), + 'resolved_reason' => 'manual', + ])->save(); + + $baseline2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-24T00:00:00Z'), + ]); + + $current2 = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => [$policy->policy_type]], + 'status' => 'success', + 'finished_at' => CarbonImmutable::parse('2026-02-25T00:00:00Z'), + ]); + + seedPolicySnapshotVersions( + tenant: $tenant, + policy: $policy, + baseline: $baseline2, + current: $current2, + baselineSnapshot: ['setting' => 'old-again'], + currentSnapshot: ['setting' => 'new-again'], + ); + + $created2 = $generator->generate($tenant, $baseline2, $current2, $scopeKey); + + expect($created2)->toBe(0); + + $finding->refresh(); + + expect($finding->status)->toBe(Finding::STATUS_RESOLVED) + ->and($finding->reopened_at)->toBeNull() + ->and($finding->resolved_at?->toIso8601String())->toBe('2026-02-26T00:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-25T00:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->due_at?->toIso8601String())->toBe($initialDueAt?->toIso8601String()); +}); diff --git a/tests/Feature/Findings/FindingWorkflowRowActionsTest.php b/tests/Feature/Findings/FindingWorkflowRowActionsTest.php new file mode 100644 index 0000000..b946575 --- /dev/null +++ b/tests/Feature/Findings/FindingWorkflowRowActionsTest.php @@ -0,0 +1,126 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + Livewire::test(ListFindings::class) + ->callTableAction('triage', $finding) + ->assertHasNoTableActionErrors(); + + $finding->refresh(); + expect($finding->status)->toBe(Finding::STATUS_TRIAGED) + ->and($finding->triaged_at)->not->toBeNull(); + + Livewire::test(ListFindings::class) + ->callTableAction('start_progress', $finding) + ->assertHasNoTableActionErrors(); + + $finding->refresh(); + expect($finding->status)->toBe(Finding::STATUS_IN_PROGRESS) + ->and($finding->in_progress_at)->not->toBeNull(); + + Livewire::test(ListFindings::class) + ->callTableAction('resolve', $finding, [ + 'resolved_reason' => 'patched', + ]) + ->assertHasNoTableActionErrors(); + + $finding->refresh(); + expect($finding->status)->toBe(Finding::STATUS_RESOLVED) + ->and($finding->resolved_reason)->toBe('patched') + ->and($finding->resolved_at)->not->toBeNull(); + + Livewire::test(ListFindings::class) + ->filterTable('open', false) + ->callTableAction('reopen', $finding) + ->assertHasNoTableActionErrors(); + + $finding->refresh(); + expect($finding->status)->toBe(Finding::STATUS_REOPENED) + ->and($finding->reopened_at)->not->toBeNull() + ->and($finding->due_at)->not->toBeNull(); +}); + +it('supports close and risk accept via row actions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $closeFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $riskFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + Livewire::test(ListFindings::class) + ->callTableAction('close', $closeFinding, [ + 'closed_reason' => 'duplicate ticket', + ]) + ->assertHasNoTableActionErrors(); + + Livewire::test(ListFindings::class) + ->callTableAction('risk_accept', $riskFinding, [ + 'closed_reason' => 'accepted by security', + ]) + ->assertHasNoTableActionErrors(); + + expect($closeFinding->refresh()->status)->toBe(Finding::STATUS_CLOSED) + ->and($closeFinding->closed_reason)->toBe('duplicate ticket'); + + expect($riskFinding->refresh()->status)->toBe(Finding::STATUS_RISK_ACCEPTED) + ->and($riskFinding->closed_reason)->toBe('accepted by security'); +}); + +it('assigns owners and assignees via row action and rejects non-member ids', function (): void { + [$manager, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($manager); + Filament::setTenant($tenant, true); + + $assignee = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + + $outsider = User::factory()->create(); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + Livewire::test(ListFindings::class) + ->callTableAction('assign', $finding, [ + 'assignee_user_id' => (int) $assignee->getKey(), + 'owner_user_id' => (int) $manager->getKey(), + ]) + ->assertHasNoTableActionErrors(); + + $finding->refresh(); + expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()) + ->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey()); + + Livewire::test(ListFindings::class) + ->callTableAction('assign', $finding, [ + 'assignee_user_id' => (int) $outsider->getKey(), + 'owner_user_id' => (int) $manager->getKey(), + ]); + + $finding->refresh(); + expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()); +}); diff --git a/tests/Feature/Findings/FindingWorkflowViewActionsTest.php b/tests/Feature/Findings/FindingWorkflowViewActionsTest.php new file mode 100644 index 0000000..da492f8 --- /dev/null +++ b/tests/Feature/Findings/FindingWorkflowViewActionsTest.php @@ -0,0 +1,81 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $newFinding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]); + $triagedFinding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_TRIAGED]); + $resolvedFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => now(), + 'resolved_reason' => 'fixed', + ]); + + Livewire::test(ViewFinding::class, ['record' => $newFinding->getKey()]) + ->assertActionVisible('triage') + ->assertActionVisible('assign') + ->assertActionVisible('resolve') + ->assertActionVisible('close') + ->assertActionVisible('risk_accept'); + + Livewire::test(ViewFinding::class, ['record' => $triagedFinding->getKey()]) + ->assertActionVisible('start_progress'); + + Livewire::test(ViewFinding::class, ['record' => $resolvedFinding->getKey()]) + ->assertActionVisible('reopen'); +}); + +it('executes workflow actions from view header and supports assignment to tenant members only', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $assignee = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + + $outsider = User::factory()->create(); + + $finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]); + + Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]) + ->callAction('triage') + ->assertHasNoActionErrors() + ->callAction('assign', [ + 'assignee_user_id' => (int) $assignee->getKey(), + 'owner_user_id' => (int) $user->getKey(), + ]) + ->assertHasNoActionErrors() + ->callAction('resolve', [ + 'resolved_reason' => 'handled in queue', + ]) + ->assertHasNoActionErrors(); + + $finding->refresh(); + expect($finding->status)->toBe(Finding::STATUS_RESOLVED) + ->and((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()) + ->and((int) $finding->owner_user_id)->toBe((int) $user->getKey()); + + Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]) + ->callAction('reopen') + ->assertHasNoActionErrors() + ->callAction('assign', [ + 'assignee_user_id' => (int) $outsider->getKey(), + 'owner_user_id' => (int) $user->getKey(), + ]); + + $finding->refresh(); + expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()); +}); diff --git a/tests/Feature/Findings/FindingsListDefaultsTest.php b/tests/Feature/Findings/FindingsListDefaultsTest.php new file mode 100644 index 0000000..f27a958 --- /dev/null +++ b/tests/Feature/Findings/FindingsListDefaultsTest.php @@ -0,0 +1,51 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $openDrift = Finding::factory()->for($tenant)->create([ + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'status' => Finding::STATUS_NEW, + ]); + + $openPermission = Finding::factory()->permissionPosture()->for($tenant)->create([ + 'status' => Finding::STATUS_TRIAGED, + ]); + + $openEntra = Finding::factory()->entraAdminRoles()->for($tenant)->create([ + 'status' => Finding::STATUS_IN_PROGRESS, + ]); + + $reopened = Finding::factory()->for($tenant)->create([ + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'status' => Finding::STATUS_REOPENED, + ]); + + $resolved = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + ]); + + $closed = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_CLOSED, + ]); + + $riskAccepted = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RISK_ACCEPTED, + ]); + + Livewire::test(ListFindings::class) + ->assertCanSeeTableRecords([$openDrift, $openPermission, $openEntra, $reopened]) + ->assertCanNotSeeTableRecords([$resolved, $closed, $riskAccepted]); +}); diff --git a/tests/Feature/Findings/FindingsListFiltersTest.php b/tests/Feature/Findings/FindingsListFiltersTest.php new file mode 100644 index 0000000..8376466 --- /dev/null +++ b/tests/Feature/Findings/FindingsListFiltersTest.php @@ -0,0 +1,93 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $overdueOpen = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'due_at' => now()->subDay(), + ]); + + $notOverdueOpen = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_TRIAGED, + 'due_at' => now()->addDay(), + ]); + + $overdueTerminal = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'due_at' => now()->subDay(), + ]); + + Livewire::test(ListFindings::class) + ->filterTable('overdue', true) + ->assertCanSeeTableRecords([$overdueOpen]) + ->assertCanNotSeeTableRecords([$notOverdueOpen, $overdueTerminal]); +}); + +it('filters findings by high severity quick filter', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $critical = Finding::factory()->for($tenant)->create([ + 'severity' => Finding::SEVERITY_CRITICAL, + 'status' => Finding::STATUS_NEW, + ]); + + $high = Finding::factory()->for($tenant)->create([ + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_TRIAGED, + ]); + + $medium = Finding::factory()->for($tenant)->create([ + 'severity' => Finding::SEVERITY_MEDIUM, + 'status' => Finding::STATUS_IN_PROGRESS, + ]); + + Livewire::test(ListFindings::class) + ->filterTable('high_severity', true) + ->assertCanSeeTableRecords([$critical, $high]) + ->assertCanNotSeeTableRecords([$medium]); +}); + +it('filters findings by my assigned quick filter', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $otherUser = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'operator'); + + $assignedToMe = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'assignee_user_id' => (int) $user->getKey(), + ]); + + $assignedToOther = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_TRIAGED, + 'assignee_user_id' => (int) $otherUser->getKey(), + ]); + + $unassigned = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_REOPENED, + 'assignee_user_id' => null, + ]); + + Livewire::test(ListFindings::class) + ->filterTable('my_assigned', true) + ->assertCanSeeTableRecords([$assignedToMe]) + ->assertCanNotSeeTableRecords([$assignedToOther, $unassigned]); +}); diff --git a/tests/Feature/Models/FindingResolvedTest.php b/tests/Feature/Models/FindingResolvedTest.php index 534a70a..ac90740 100644 --- a/tests/Feature/Models/FindingResolvedTest.php +++ b/tests/Feature/Models/FindingResolvedTest.php @@ -22,7 +22,7 @@ ->and($fresh->resolved_at)->not->toBeNull(); }); -it('reopens a resolved finding', function (): void { +it('reopens a resolved finding (legacy model helper compatibility)', function (): void { $finding = Finding::factory()->permissionPosture()->resolved()->create(); $newEvidence = [ @@ -42,6 +42,31 @@ ->and($finding->evidence_jsonb)->toBe($newEvidence); }); +it('exposes v2 open and terminal status helpers', function (): void { + expect(Finding::openStatuses())->toBe([ + Finding::STATUS_NEW, + Finding::STATUS_TRIAGED, + Finding::STATUS_IN_PROGRESS, + Finding::STATUS_REOPENED, + ]); + + expect(Finding::terminalStatuses())->toBe([ + Finding::STATUS_RESOLVED, + Finding::STATUS_CLOSED, + Finding::STATUS_RISK_ACCEPTED, + ]); + + expect(Finding::openStatusesForQuery())->toContain(Finding::STATUS_ACKNOWLEDGED); +}); + +it('maps legacy acknowledged status to triaged in v2 helpers', function (): void { + expect(Finding::canonicalizeStatus(Finding::STATUS_ACKNOWLEDGED)) + ->toBe(Finding::STATUS_TRIAGED); + + expect(Finding::isOpenStatus(Finding::STATUS_ACKNOWLEDGED))->toBeTrue(); + expect(Finding::isTerminalStatus(Finding::STATUS_ACKNOWLEDGED))->toBeFalse(); +}); + it('preserves acknowledged metadata when resolving an acknowledged finding', function (): void { $user = User::factory()->create(); $finding = Finding::factory()->permissionPosture()->create(); diff --git a/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php b/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php index ae30c00..a8a1310 100644 --- a/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php +++ b/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\PermissionPosture\PermissionPostureFindingGenerator; use App\Services\PermissionPosture\PostureResult; +use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -39,6 +40,8 @@ function errorPermission(string $key, array $features = []): array it('creates findings for missing permissions with correct attributes', function (): void { [$user, $tenant] = createUserWithTenant(); + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + $generator = app(PermissionPostureFindingGenerator::class); $comparison = buildComparison([ missingPermission('DeviceManagementApps.ReadWrite.All', ['policy-sync', 'backup']), @@ -60,9 +63,16 @@ function errorPermission(string $key, array $features = []): array ->and($finding->subject_external_id)->toBe('DeviceManagementApps.ReadWrite.All') ->and($finding->severity)->toBe(Finding::SEVERITY_HIGH) // 2 features → high ->and($finding->status)->toBe(Finding::STATUS_NEW) + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->times_seen)->toBe(1) + ->and($finding->sla_days)->toBe(7) + ->and($finding->due_at?->toIso8601String())->toBe('2026-03-03T10:00:00+00:00') ->and($finding->evidence_jsonb['permission_key'])->toBe('DeviceManagementApps.ReadWrite.All') ->and($finding->evidence_jsonb['actual_status'])->toBe('missing') ->and($finding->fingerprint)->not->toBeEmpty(); + + CarbonImmutable::setTestNow(); }); // (2) Auto-resolves finding when permission granted @@ -119,16 +129,23 @@ function errorPermission(string $key, array $features = []): array [$user, $tenant] = createUserWithTenant(); $generator = app(PermissionPostureFindingGenerator::class); - $comparison = buildComparison([missingPermission('Perm.A')]); + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + $result1 = $generator->generate($tenant, buildComparison([missingPermission('Perm.A')])); - $result1 = $generator->generate($tenant, $comparison); - $result2 = $generator->generate($tenant, $comparison); + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z')); + $result2 = $generator->generate($tenant, buildComparison([missingPermission('Perm.A')])); expect($result1->findingsCreated)->toBe(1) ->and($result2->findingsCreated)->toBe(0) ->and($result2->findingsUnchanged)->toBe(1); expect(Finding::query()->where('tenant_id', $tenant->getKey())->count())->toBe(1); + + $finding = Finding::query()->where('tenant_id', $tenant->getKey())->first(); + expect($finding->times_seen)->toBe(2) + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00'); + + CarbonImmutable::setTestNow(); }); // (5) Re-opens resolved finding when permission revoked again @@ -137,19 +154,35 @@ function errorPermission(string $key, array $features = []): array $generator = app(PermissionPostureFindingGenerator::class); // Missing → resolve → missing again - $generator->generate($tenant, buildComparison([missingPermission('Perm.A')])); + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + $generator->generate($tenant, buildComparison([missingPermission('Perm.A', ['policy-sync', 'backup'])])); + + $initial = Finding::query()->where('tenant_id', $tenant->getKey())->first(); + $initialDueAt = $initial->due_at; + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-25T10:00:00Z')); $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted')); $finding = Finding::query()->where('tenant_id', $tenant->getKey())->first(); expect($finding->status)->toBe(Finding::STATUS_RESOLVED); - $result = $generator->generate($tenant, buildComparison([missingPermission('Perm.A')])); + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-26T10:00:00Z')); + $result = $generator->generate($tenant, buildComparison([missingPermission('Perm.A', ['policy-sync', 'backup'])])); $finding->refresh(); expect($result->findingsReopened)->toBe(1) - ->and($finding->status)->toBe(Finding::STATUS_NEW) + ->and($finding->status)->toBe(Finding::STATUS_REOPENED) + ->and($finding->reopened_at?->toIso8601String())->toBe('2026-02-26T10:00:00+00:00') ->and($finding->resolved_at)->toBeNull() - ->and($finding->resolved_reason)->toBeNull(); + ->and($finding->resolved_reason)->toBeNull() + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-26T10:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe(7) + ->and($finding->due_at?->toIso8601String())->toBe('2026-03-05T10:00:00+00:00') + ->and($finding->due_at?->toIso8601String())->not->toBe($initialDueAt?->toIso8601String()); + + CarbonImmutable::setTestNow(); }); // (6) Creates error finding for status=error permissions diff --git a/tests/Feature/ReviewPack/ReviewPackPruneTest.php b/tests/Feature/ReviewPack/ReviewPackPruneTest.php index c8838c3..5ba24db 100644 --- a/tests/Feature/ReviewPack/ReviewPackPruneTest.php +++ b/tests/Feature/ReviewPack/ReviewPackPruneTest.php @@ -152,10 +152,11 @@ expect(ReviewPack::query()->whereKey($pack->getKey())->exists())->toBeTrue(); }); -it('AlertRule form no longer shows sla_due option', function (): void { +it('AlertRule form shows sla_due option', function (): void { $options = AlertRuleResource::eventTypeOptions(); - expect($options)->not->toHaveKey(AlertRule::EVENT_SLA_DUE) + expect($options)->toHaveKey(AlertRule::EVENT_SLA_DUE) + ->and($options[AlertRule::EVENT_SLA_DUE])->toBe('SLA due') ->and($options)->toHaveKey(AlertRule::EVENT_HIGH_DRIFT) ->and($options)->toHaveKey(AlertRule::EVENT_COMPARE_FAILED) ->and($options)->toHaveKey(AlertRule::EVENT_PERMISSION_MISSING) diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php index 97e4888..ae324ef 100644 --- a/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php @@ -12,6 +12,7 @@ use App\Services\Settings\SettingsWriter; use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Testing\TestAction; use Illuminate\Validation\ValidationException; use Livewire\Livewire; @@ -45,18 +46,30 @@ function workspaceManagerUser(): array ->test(WorkspaceSettings::class) ->assertSet('data.backup_retention_keep_last_default', null) ->assertSet('data.backup_retention_min_floor', null) - ->assertSet('data.drift_severity_mapping', null) + ->assertSet('data.drift_severity_mapping', []) + ->assertSet('data.findings_sla_critical', null) + ->assertSet('data.findings_sla_high', null) + ->assertSet('data.findings_sla_medium', null) + ->assertSet('data.findings_sla_low', null) ->assertSet('data.operations_operation_run_retention_days', null) ->assertSet('data.operations_stuck_run_threshold_minutes', null) ->set('data.backup_retention_keep_last_default', 55) ->set('data.backup_retention_min_floor', 12) - ->set('data.drift_severity_mapping', '{"drift":"critical"}') + ->set('data.drift_severity_mapping', ['drift' => 'critical']) + ->set('data.findings_sla_critical', 2) + ->set('data.findings_sla_high', 5) + ->set('data.findings_sla_medium', 10) + ->set('data.findings_sla_low', 20) ->set('data.operations_operation_run_retention_days', 120) ->set('data.operations_stuck_run_threshold_minutes', 60) ->callAction('save') ->assertHasNoErrors() ->assertSet('data.backup_retention_keep_last_default', 55) ->assertSet('data.backup_retention_min_floor', 12) + ->assertSet('data.findings_sla_critical', 2) + ->assertSet('data.findings_sla_high', 5) + ->assertSet('data.findings_sla_medium', 10) + ->assertSet('data.findings_sla_low', 20) ->assertSet('data.operations_operation_run_retention_days', 120) ->assertSet('data.operations_stuck_run_threshold_minutes', 60); @@ -75,6 +88,14 @@ function workspaceManagerUser(): array expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping')) ->toBe(['drift' => 'critical']); + expect(app(SettingsResolver::class)->resolveValue($workspace, 'findings', 'sla_days')) + ->toBe([ + 'critical' => 2, + 'high' => 5, + 'medium' => 10, + 'low' => 20, + ]); + expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'operation_run_retention_days')) ->toBe(120); @@ -106,6 +127,81 @@ function workspaceManagerUser(): array ->exists())->toBeFalse(); }); +it('treats an empty KeyValue row as unset and still allows saving other fields', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->set('data.drift_severity_mapping', ['' => '']) + ->set('data.backup_retention_keep_last_default', 10) + ->callAction('save') + ->assertHasNoErrors(); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping')) + ->toBe([]); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'drift') + ->where('key', 'severity_mapping') + ->exists())->toBeFalse(); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) + ->toBe(10); +}); + +it('accepts Filament KeyValue row-shaped state when saving drift severity mapping', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->set('data.drift_severity_mapping', [ + ['key' => 'drift', 'value' => 'critical'], + ['key' => '', 'value' => ''], + ]) + ->callAction('save') + ->assertHasNoErrors(); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping')) + ->toBe(['drift' => 'critical']); +}); + +it('clearing a KeyValue mapping via an empty row resets the existing override', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'drift', + 'key' => 'severity_mapping', + 'value' => ['drift' => 'low'], + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertSet('data.drift_severity_mapping', ['drift' => 'low']) + ->set('data.drift_severity_mapping', ['' => '']) + ->callAction('save') + ->assertHasNoErrors(); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping')) + ->toBe([]); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'drift') + ->where('key', 'severity_mapping') + ->exists())->toBeFalse(); +}); + it('rejects unknown setting keys and does not persist or audit changes', function (): void { [$workspace, $user] = workspaceManagerUser(); @@ -142,7 +238,7 @@ function workspaceManagerUser(): array expect(AuditLog::query()->count())->toBe(0); }); -it('rejects malformed drift severity mapping JSON on save', function (): void { +it('rejects malformed drift severity mapping values on save', function (): void { [$workspace, $user] = workspaceManagerUser(); $this->actingAs($user) @@ -151,7 +247,7 @@ function workspaceManagerUser(): array Livewire::actingAs($user) ->test(WorkspaceSettings::class) - ->set('data.drift_severity_mapping', '{invalid-json}') + ->set('data.drift_severity_mapping', ['drift' => 'urgent']) ->callAction('save') ->assertHasErrors(['data.drift_severity_mapping']); @@ -162,6 +258,43 @@ function workspaceManagerUser(): array ->exists())->toBeFalse(); }); +it('saves partial findings sla days without auto-filling unset severities', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->set('data.findings_sla_critical', 2) + ->callAction('save') + ->assertHasNoErrors() + ->assertSet('data.findings_sla_critical', 2) + ->assertSet('data.findings_sla_high', null) + ->assertSet('data.findings_sla_medium', null) + ->assertSet('data.findings_sla_low', null); + + $stored = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'findings') + ->where('key', 'sla_days') + ->first(); + + expect($stored)->not->toBeNull(); + + $storedValue = $stored->getAttribute('value'); + expect($storedValue)->toBe(['critical' => 2]); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'findings', 'sla_days')) + ->toBe([ + 'critical' => 2, + 'high' => 7, + 'medium' => 14, + 'low' => 30, + ]); +}); + it('rejects invalid drift severity mapping shape and values', function (): void { [$workspace, $user] = workspaceManagerUser(); @@ -265,6 +398,19 @@ function workspaceManagerUser(): array 'updated_by_user_id' => (int) $user->getKey(), ]); + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'findings', + 'key' => 'sla_days', + 'value' => [ + 'critical' => 3, + 'high' => 7, + 'medium' => 14, + 'low' => 30, + ], + 'updated_by_user_id' => (int) $user->getKey(), + ]); + $component = Livewire::actingAs($user)->test(WorkspaceSettings::class); $component @@ -282,6 +428,11 @@ function workspaceManagerUser(): array expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); $component->unmountFormComponentAction(); + $component + ->mountAction(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section')); + expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); + $component->unmountAction(); + $component ->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content'); expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php index cf4b1f5..6800c65 100644 --- a/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php @@ -9,6 +9,7 @@ use App\Models\WorkspaceMembership; use App\Models\WorkspaceSetting; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Testing\TestAction; use Livewire\Livewire; it('allows view-only members to view workspace settings but forbids save and per-setting reset mutations', function (): void { @@ -39,7 +40,11 @@ ->test(WorkspaceSettings::class) ->assertSet('data.backup_retention_keep_last_default', 27) ->assertSet('data.backup_retention_min_floor', null) - ->assertSet('data.drift_severity_mapping', null) + ->assertSet('data.drift_severity_mapping', []) + ->assertSet('data.findings_sla_critical', null) + ->assertSet('data.findings_sla_high', null) + ->assertSet('data.findings_sla_medium', null) + ->assertSet('data.findings_sla_low', null) ->assertSet('data.operations_operation_run_retention_days', null) ->assertSet('data.operations_stuck_run_threshold_minutes', null) ->assertActionVisible('save') @@ -50,6 +55,8 @@ ->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_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') ->assertFormComponentActionDisabled('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content') ->assertFormComponentActionVisible('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content') diff --git a/tests/Feature/Support/Badges/FindingBadgeTest.php b/tests/Feature/Support/Badges/FindingBadgeTest.php index 9a8d2f3..bd2af45 100644 --- a/tests/Feature/Support/Badges/FindingBadgeTest.php +++ b/tests/Feature/Support/Badges/FindingBadgeTest.php @@ -27,10 +27,24 @@ it('still renders acknowledged status badge', function (): void { $spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED); - expect($spec->label)->toBe('Acknowledged') + expect($spec->label)->toBe('Triaged') ->and($spec->color)->toBe('gray'); }); +it('renders v2 workflow status badges', function (): void { + $triaged = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_TRIAGED); + $inProgress = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_IN_PROGRESS); + $reopened = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_REOPENED); + $closed = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_CLOSED); + $riskAccepted = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_RISK_ACCEPTED); + + expect($triaged->label)->toBe('Triaged') + ->and($inProgress->label)->toBe('In progress') + ->and($reopened->label)->toBe('Reopened') + ->and($closed->label)->toBe('Closed') + ->and($riskAccepted->label)->toBe('Risk accepted'); +}); + it('renders permission_posture finding type badge', function (): void { $spec = BadgeCatalog::spec(BadgeDomain::FindingType, Finding::FINDING_TYPE_PERMISSION_POSTURE); diff --git a/tests/Unit/Badges/FindingBadgesTest.php b/tests/Unit/Badges/FindingBadgesTest.php index e75fd30..fcb0be2 100644 --- a/tests/Unit/Badges/FindingBadgesTest.php +++ b/tests/Unit/Badges/FindingBadgesTest.php @@ -28,7 +28,25 @@ expect($new->label)->toBe('New'); expect($new->color)->toBe('warning'); - $acknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); - expect($acknowledged->label)->toBe('Acknowledged'); - expect($acknowledged->color)->toBe('gray'); + $triaged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'triaged'); + expect($triaged->label)->toBe('Triaged'); + expect($triaged->color)->toBe('gray'); + + $legacyAcknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); + expect($legacyAcknowledged->label)->toBe('Triaged'); + expect($legacyAcknowledged->color)->toBe('gray'); + + $inProgress = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'in_progress'); + expect($inProgress->label)->toBe('In progress'); + expect($inProgress->color)->toBe('info'); + + $reopened = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'reopened'); + expect($reopened->label)->toBe('Reopened'); + expect($reopened->color)->toBe('danger'); + + $closed = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'closed'); + expect($closed->label)->toBe('Closed'); + + $riskAccepted = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'risk_accepted'); + expect($riskAccepted->label)->toBe('Risk accepted'); }); diff --git a/tests/Unit/Findings/FindingWorkflowServiceTest.php b/tests/Unit/Findings/FindingWorkflowServiceTest.php new file mode 100644 index 0000000..5033996 --- /dev/null +++ b/tests/Unit/Findings/FindingWorkflowServiceTest.php @@ -0,0 +1,105 @@ +for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $service = app(FindingWorkflowService::class); + + expect(fn () => $service->startProgress($finding, $tenant, $user)) + ->toThrow(\InvalidArgumentException::class); + + expect(fn () => $service->resolve($finding, $tenant, $user, ' ')) + ->toThrow(\InvalidArgumentException::class); +}); + +it('resets due_at and sla_days when reopening and clears terminal fields', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + + $finding = Finding::factory()->for($tenant)->create([ + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => now()->subDay(), + 'resolved_reason' => 'fixed', + 'closed_at' => now()->subHours(2), + 'closed_reason' => 'legacy-close', + 'closed_by_user_id' => $user->getKey(), + 'sla_days' => 30, + 'due_at' => now()->subDays(10), + ]); + + $reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user); + + expect($reopened->status)->toBe(Finding::STATUS_REOPENED) + ->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($reopened->sla_days)->toBe(7) + ->and($reopened->due_at?->toIso8601String())->toBe('2026-03-03T10:00:00+00:00') + ->and($reopened->resolved_at)->toBeNull() + ->and($reopened->resolved_reason)->toBeNull() + ->and($reopened->closed_at)->toBeNull() + ->and($reopened->closed_reason)->toBeNull() + ->and($reopened->closed_by_user_id)->toBeNull(); + + CarbonImmutable::setTestNow(); +}); + +it('keeps due_at stable across open workflow transitions even if severity changes while open', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $dueAt = now()->addDays(14)->startOfMinute(); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'severity' => Finding::SEVERITY_MEDIUM, + 'due_at' => $dueAt, + 'sla_days' => 14, + ]); + + $finding->forceFill(['severity' => Finding::SEVERITY_CRITICAL])->save(); + + $service = app(FindingWorkflowService::class); + $service->triage($finding->refresh(), $tenant, $user); + $inProgress = $service->startProgress($finding->refresh(), $tenant, $user); + + expect($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS) + ->and($inProgress->due_at?->toIso8601String())->toBe($dueAt->toIso8601String()) + ->and($inProgress->sla_days)->toBe(14); +}); + +it('allows assigning current tenant members and rejects non-members', function (): void { + [$manager, $tenant] = createUserWithTenant(role: 'manager'); + + $assignee = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + + $outsider = User::factory()->create(); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $service = app(FindingWorkflowService::class); + + $assigned = $service->assign($finding, $tenant, $manager, (int) $assignee->getKey(), (int) $manager->getKey()); + + expect((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey()) + ->and((int) $assigned->owner_user_id)->toBe((int) $manager->getKey()); + + expect(fn () => $service->assign($assigned, $tenant, $manager, (int) $outsider->getKey(), null)) + ->toThrow(\InvalidArgumentException::class); +}); diff --git a/tests/Unit/Settings/FindingsSlaDaysSettingTest.php b/tests/Unit/Settings/FindingsSlaDaysSettingTest.php new file mode 100644 index 0000000..22b6e46 --- /dev/null +++ b/tests/Unit/Settings/FindingsSlaDaysSettingTest.php @@ -0,0 +1,75 @@ +require('findings', 'sla_days'); + + expect($definition->type)->toBe('json') + ->and($definition->systemDefault)->toBe([ + 'critical' => 3, + 'high' => 7, + 'medium' => 14, + 'low' => 30, + ]); +}); + +it('validates findings sla_days values and normalizes them', function (): void { + $definition = app(SettingsRegistry::class)->require('findings', 'sla_days'); + + $valid = [ + 'medium' => '14', + 'critical' => '3', + 'low' => 30, + 'high' => 7, + ]; + + $validator = Validator::make( + ['value' => $valid], + ['value' => $definition->rules], + ); + + expect($validator->fails())->toBeFalse(); + expect($definition->normalize($validator->validated()['value']))->toBe([ + 'critical' => 3, + 'high' => 7, + 'medium' => 14, + 'low' => 30, + ]); +}); + +it('rejects malformed findings sla_days values', function (): void { + $definition = app(SettingsRegistry::class)->require('findings', 'sla_days'); + + $invalidValue = Validator::make( + ['value' => ['critical' => 0, 'high' => 7, 'medium' => 14, 'low' => 30]], + ['value' => $definition->rules], + ); + + $unknownSeverity = Validator::make( + ['value' => ['critical' => 3, 'high' => 7, 'medium' => 14, 'low' => 30, 'urgent' => 1]], + ['value' => $definition->rules], + ); + + expect($invalidValue->fails())->toBeTrue() + ->and($unknownSeverity->fails())->toBeTrue(); +}); + +it('allows partial findings sla_days and stores only provided keys', function (): void { + $definition = app(SettingsRegistry::class)->require('findings', 'sla_days'); + + $partial = ['critical' => 2]; + + $validator = Validator::make( + ['value' => $partial], + ['value' => $definition->rules], + ); + + expect($validator->fails())->toBeFalse(); + expect($definition->normalize($validator->validated()['value']))->toBe([ + 'critical' => 2, + ]); +});