feat(111): findings workflow + SLA settings (#135)

Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation.

Key changes:
- Findings workflow service + SLA policy and alerting.
- Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults.
- New migrations, jobs, command, UI/resource updates, and comprehensive test coverage.

Tests:
- `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #135
This commit is contained in:
ahmido 2026-02-25 01:48:01 +00:00
parent f13a4ce409
commit 7ac53f4cc4
67 changed files with 6937 additions and 313 deletions

View 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();
}
}

View File

@ -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

View File

@ -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)',
];

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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'),
];
}
}

View File

@ -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();

View 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;
}
}

View File

@ -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();

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -192,6 +192,7 @@ public function panel(Panel $panel): Panel
FilamentInfoWidget::class,
])
->databaseNotifications()
->unsavedChangesAlerts()
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,

View File

@ -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,

View File

@ -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) {

View File

@ -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[] = [

View 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));
}
}

View 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,
];
}
}

View File

@ -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(),
];
}

View File

@ -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 = [

View File

@ -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()

View File

@ -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

View File

@ -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(),
};
}

View File

@ -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,
};
}

View File

@ -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;
}
}

View File

@ -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' => [],
];
}

View File

@ -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',
]);
});
}
};

View File

@ -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');
});
}
};

View File

@ -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');
}
}
};

View 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);
})();

View File

@ -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>

View 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

View 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.

View 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
```

View 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.

View 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.

View 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 runs `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).

View 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 dont 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 findings 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 findings 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 events `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 findings `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).

View 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 US4s 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

View File

@ -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());
});

View 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']);
});

View File

@ -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);

View File

@ -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();
});

View File

@ -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));
});

View File

@ -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();

View File

@ -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.
}

View File

@ -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();
});
});

View File

@ -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)');
});

View File

@ -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');

View 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');
});

View 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();
});

View 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();
});

View 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);
});

View 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);
});

View 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());
});

View 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());
});

View 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());
});

View 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]);
});

View 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]);
});

View File

@ -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();

View File

@ -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

View File

@ -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)

View File

@ -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();

View File

@ -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')

View File

@ -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);

View File

@ -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');
});

View 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);
});

View 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,
]);
});