feat(111): findings workflow + SLA settings #135
114
app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
Normal file
114
app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotBackfillFindingLifecycle extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:findings:backfill-lifecycle
|
||||
{--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
|
||||
|
||||
public function handle(OperationRunService $operationRuns): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
|
||||
if ($tenantIdentifiers === []) {
|
||||
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||
|
||||
if ($tenants->isEmpty()) {
|
||||
$this->info('No tenants matched the provided identifiers.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$queued = 0;
|
||||
$skipped = 0;
|
||||
|
||||
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<int, string> $tenantIdentifiers
|
||||
* @return \Illuminate\Support\Collection<int, Tenant>
|
||||
*/
|
||||
private function resolveTenants(array $tenantIdentifiers)
|
||||
{
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()
|
||||
->forTenant($identifier)
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = array_values(array_unique($tenantIds));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->whereIn('id', $tenantIds)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@ -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<int, string>
|
||||
*/
|
||||
private const KEYVALUE_FIELDS = [
|
||||
'drift_severity_mapping',
|
||||
];
|
||||
|
||||
/**
|
||||
* Findings SLA days are decomposed into individual form fields per severity.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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<string, array{user_name: string, updated_at: Carbon}>
|
||||
*/
|
||||
public array $domainLastModified = [];
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
@ -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<string, mixed>, 1: array<string, array<int, string>>}
|
||||
*/
|
||||
@ -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 "(?<severity>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<mixed> $value
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $data
|
||||
* @param array<string, mixed> $workspaceOverrides
|
||||
* @param array<string, array{source: string, value: mixed, system_default: mixed}> $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
|
||||
|
||||
@ -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)',
|
||||
];
|
||||
|
||||
@ -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<int, Actions\Action>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array{overdue_total:int, overdue_by_severity:array<string, int>, 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<string, int> $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();
|
||||
|
||||
385
app/Jobs/BackfillFindingLifecycleJob.php
Normal file
385
app/Jobs/BackfillFindingLifecycleJob.php
Normal file
@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class BackfillFindingLifecycleJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly int $workspaceId,
|
||||
public readonly ?int $initiatorUserId = null,
|
||||
) {}
|
||||
|
||||
public function handle(OperationRunService $operationRuns, FindingSlaPolicy $slaPolicy): void
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $this->initiatorUserId !== null
|
||||
? User::query()->find($this->initiatorUserId)
|
||||
: null;
|
||||
|
||||
$operationRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'findings.lifecycle.backfill',
|
||||
identityInputs: [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'trigger' => 'backfill',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'initiator_user_id' => $this->initiatorUserId,
|
||||
],
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
if ($operationRun->status !== OperationRunStatus::Completed->value) {
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Blocked->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'findings.lifecycle.backfill.lock_busy',
|
||||
'message' => 'Another findings lifecycle backfill is already running for this tenant.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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<int, Finding> $findings
|
||||
*/
|
||||
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
|
||||
{
|
||||
if ($findings->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
|
||||
|
||||
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
|
||||
|
||||
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
|
||||
|
||||
if ($alreadyCanonical instanceof Finding) {
|
||||
return $alreadyCanonical;
|
||||
}
|
||||
|
||||
/** @var Finding $sorted */
|
||||
$sorted = $candidates
|
||||
->sortByDesc(function (Finding $finding): array {
|
||||
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
|
||||
|
||||
return [
|
||||
max($lastSeen, $createdAt),
|
||||
(int) $finding->getKey(),
|
||||
];
|
||||
})
|
||||
->first();
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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<int, string>
|
||||
*/
|
||||
public static function openStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_NEW,
|
||||
self::STATUS_TRIAGED,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_REOPENED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function terminalStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_RESOLVED,
|
||||
self::STATUS_CLOSED,
|
||||
self::STATUS_RISK_ACCEPTED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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) {
|
||||
|
||||
@ -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<int, string> $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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,6 +192,7 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->unsavedChangesAlerts()
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, mixed> $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<int, string> $seenRecurrenceKeys
|
||||
* @param array<string, mixed> $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<int, string> $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) {
|
||||
|
||||
@ -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[] = [
|
||||
|
||||
52
app/Services/Findings/FindingSlaPolicy.php
Normal file
52
app/Services/Findings/FindingSlaPolicy.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Findings;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class FindingSlaPolicy
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsResolver $settingsResolver,
|
||||
) {}
|
||||
|
||||
public function daysForFinding(Finding $finding, Tenant $tenant): int
|
||||
{
|
||||
return $this->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));
|
||||
}
|
||||
}
|
||||
380
app/Services/Findings/FindingWorkflowService.php
Normal file
380
app/Services/Findings/FindingWorkflowService.php
Normal file
@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Findings;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class FindingWorkflowService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingSlaPolicy $slaPolicy,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
) {}
|
||||
|
||||
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
{
|
||||
$this->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<int, string> $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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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<int, array<string, mixed>> $permissions
|
||||
*/
|
||||
@ -313,7 +442,7 @@ private function buildAlertEvent(Tenant $tenant, string $key, string $type, arra
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<string, int>
|
||||
*/
|
||||
private static function defaultFindingsSlaDays(): array
|
||||
{
|
||||
return [
|
||||
Finding::SEVERITY_CRITICAL => 3,
|
||||
Finding::SEVERITY_HIGH => 7,
|
||||
Finding::SEVERITY_MEDIUM => 14,
|
||||
Finding::SEVERITY_LOW => 30,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('findings', function (Blueprint $table): void {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('findings', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('findings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('findings', 'first_seen_at')
|
||||
|| ! Schema::hasColumn('findings', 'last_seen_at')
|
||||
|| ! Schema::hasColumn('findings', 'times_seen')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
DB::table('findings')
|
||||
->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');
|
||||
}
|
||||
}
|
||||
};
|
||||
163
public/js/tenantpilot/filament-sidebar-store-fallback.js
Normal file
163
public/js/tenantpilot/filament-sidebar-store-fallback.js
Normal file
@ -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);
|
||||
})();
|
||||
@ -1 +1,2 @@
|
||||
<script defer src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
||||
<script defer src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
||||
|
||||
35
specs/111-findings-workflow-sla/checklists/requirements.md
Normal file
35
specs/111-findings-workflow-sla/checklists/requirements.md
Normal file
@ -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
|
||||
122
specs/111-findings-workflow-sla/contracts/api-contracts.md
Normal file
122
specs/111-findings-workflow-sla/contracts/api-contracts.md
Normal file
@ -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.
|
||||
|
||||
98
specs/111-findings-workflow-sla/data-model.md
Normal file
98
specs/111-findings-workflow-sla/data-model.md
Normal file
@ -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
|
||||
```
|
||||
|
||||
180
specs/111-findings-workflow-sla/plan.md
Normal file
180
specs/111-findings-workflow-sla/plan.md
Normal file
@ -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.
|
||||
110
specs/111-findings-workflow-sla/quickstart.md
Normal file
110
specs/111-findings-workflow-sla/quickstart.md
Normal file
@ -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.
|
||||
|
||||
193
specs/111-findings-workflow-sla/research.md
Normal file
193
specs/111-findings-workflow-sla/research.md
Normal file
@ -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).
|
||||
|
||||
269
specs/111-findings-workflow-sla/spec.md
Normal file
269
specs/111-findings-workflow-sla/spec.md
Normal file
@ -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).
|
||||
339
specs/111-findings-workflow-sla/tasks.md
Normal file
339
specs/111-findings-workflow-sla/tasks.md
Normal file
@ -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
|
||||
@ -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());
|
||||
});
|
||||
|
||||
196
tests/Feature/Alerts/SlaDueAlertTest.php
Normal file
196
tests/Feature/Alerts/SlaDueAlertTest.php
Normal file
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId);
|
||||
$reflection = new ReflectionMethod($job, 'slaDueEvents');
|
||||
|
||||
/** @var array<int, array<string, mixed>> $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']);
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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));
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
136
tests/Feature/Findings/DriftStaleAutoResolveTest.php
Normal file
136
tests/Feature/Findings/DriftStaleAutoResolveTest.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedPolicyVersionsForStaleTest(
|
||||
\App\Models\Tenant $tenant,
|
||||
Policy $policy,
|
||||
\App\Models\OperationRun $baseline,
|
||||
\App\Models\OperationRun $current,
|
||||
array $baselineSnapshot,
|
||||
array $currentSnapshot,
|
||||
): void {
|
||||
$startingVersion = PolicyVersion::query()
|
||||
->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');
|
||||
});
|
||||
81
tests/Feature/Findings/FindingAuditLogTest.php
Normal file
81
tests/Feature/Findings/FindingAuditLogTest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('writes sanitized audit entries for workflow mutations with before and after metadata', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$finding = Finding::factory()->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();
|
||||
});
|
||||
134
tests/Feature/Findings/FindingBackfillTest.php
Normal file
134
tests/Feature/Findings/FindingBackfillTest.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('backfills legacy findings (ack → triaged, lifecycle fields, due_at from backfill time)', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||
'acknowledged_by_user_id' => (int) $user->getKey(),
|
||||
'first_seen_at' => null,
|
||||
'last_seen_at' => null,
|
||||
'times_seen' => null,
|
||||
'sla_days' => null,
|
||||
'due_at' => null,
|
||||
'triaged_at' => null,
|
||||
'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
|
||||
'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
|
||||
]);
|
||||
|
||||
BackfillFindingLifecycleJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_TRIAGED)
|
||||
->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
|
||||
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
|
||||
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
|
||||
->and($finding->times_seen)->toBe(1)
|
||||
->and($finding->sla_days)->toBe(14)
|
||||
->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00')
|
||||
->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
|
||||
->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('computes drift recurrence keys and consolidates drift duplicates', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-drift-backfill-duplicate');
|
||||
|
||||
$evidence = [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => ['policy_id' => 'policy-dupe'],
|
||||
'current' => ['policy_id' => 'policy-dupe'],
|
||||
];
|
||||
|
||||
$open = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-dupe',
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'recurrence_key' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'first_seen_at' => null,
|
||||
'last_seen_at' => null,
|
||||
'times_seen' => null,
|
||||
'sla_days' => null,
|
||||
'due_at' => null,
|
||||
'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||
'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||
]);
|
||||
|
||||
$duplicate = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-dupe',
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
|
||||
'resolved_reason' => '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();
|
||||
});
|
||||
155
tests/Feature/Findings/FindingBulkActionsTest.php
Normal file
155
tests/Feature/Findings/FindingBulkActionsTest.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('supports bulk workflow actions and audits each record', function (): void {
|
||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->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);
|
||||
});
|
||||
66
tests/Feature/Findings/FindingRbacTest.php
Normal file
66
tests/Feature/Findings/FindingRbacTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 for non-members on findings pages', function (): void {
|
||||
$tenant = \App\Models\Tenant::factory()->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);
|
||||
});
|
||||
341
tests/Feature/Findings/FindingRecurrenceTest.php
Normal file
341
tests/Feature/Findings/FindingRecurrenceTest.php
Normal file
@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedPolicySnapshotVersions(
|
||||
\App\Models\Tenant $tenant,
|
||||
Policy $policy,
|
||||
\App\Models\OperationRun $baseline,
|
||||
\App\Models\OperationRun $current,
|
||||
array $baselineSnapshot,
|
||||
array $currentSnapshot,
|
||||
): void {
|
||||
$startingVersion = PolicyVersion::query()
|
||||
->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());
|
||||
});
|
||||
126
tests/Feature/Findings/FindingWorkflowRowActionsTest.php
Normal file
126
tests/Feature/Findings/FindingWorkflowRowActionsTest.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('supports triage start resolve and reopen via row actions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->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());
|
||||
});
|
||||
81
tests/Feature/Findings/FindingWorkflowViewActionsTest.php
Normal file
81
tests/Feature/Findings/FindingWorkflowViewActionsTest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows workflow header actions on the view page for authorized members', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->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());
|
||||
});
|
||||
51
tests/Feature/Findings/FindingsListDefaultsTest.php
Normal file
51
tests/Feature/Findings/FindingsListDefaultsTest.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('defaults to open findings across all finding types', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->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]);
|
||||
});
|
||||
93
tests/Feature/Findings/FindingsListFiltersTest.php
Normal file
93
tests/Feature/Findings/FindingsListFiltersTest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('filters findings by overdue quick filter using open statuses only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->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]);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
105
tests/Unit/Findings/FindingWorkflowServiceTest.php
Normal file
105
tests/Unit/Findings/FindingWorkflowServiceTest.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('enforces transition rules and required reasons', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$finding = Finding::factory()->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);
|
||||
});
|
||||
75
tests/Unit/Settings/FindingsSlaDaysSettingTest.php
Normal file
75
tests/Unit/Settings/FindingsSlaDaysSettingTest.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Settings\SettingsRegistry;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
it('registers findings sla_days with defaults', function (): void {
|
||||
$definition = app(SettingsRegistry::class)->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,
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user