feat: implement finding risk acceptance lifecycle #184

Merged
ahmido merged 1 commits from 154-finding-risk-acceptance into dev 2026-03-20 01:07:56 +00:00
69 changed files with 7031 additions and 152 deletions
Showing only changes of commit 7c492ef6df - Show all commits

View File

@ -92,6 +92,8 @@ ## Active Technologies
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -111,8 +113,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
- 152-livewire-context-locking: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
- 151-findings-workflow-backstop: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,503 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingExceptionService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class FindingExceptionsQueue extends Page implements HasTable
{
use InteractsWithTable;
public ?int $selectedFindingExceptionId = null;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Finding exceptions';
protected static ?string $slug = 'finding-exceptions/queue';
protected static ?string $title = 'Finding Exceptions Queue';
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
}
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
public function mount(): void
{
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
$this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter();
if ($this->selectedFindingExceptionId !== null) {
$this->selectedFindingException();
}
}
protected function getHeaderActions(): array
{
$actions = app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_finding_exceptions',
returnActionName: 'operate_hub_return_finding_exceptions',
);
$actions[] = Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveQueueFilters())
->action(function (): void {
$this->removeTableFilter('tenant_id');
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->resetTable();
});
$actions[] = Action::make('view_tenant_register')
->label('View tenant register')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
->url(function (): ?string {
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
});
$actions[] = Action::make('clear_selected_exception')
->label('Close details')
->color('gray')
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->action(function (): void {
$this->selectedFindingExceptionId = null;
});
$actions[] = Action::make('open_selected_exception')
->label('Open tenant detail')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->url(fn (): ?string => $this->selectedExceptionUrl());
$actions[] = Action::make('open_selected_finding')
->label('Open finding')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->url(fn (): ?string => $this->selectedFindingUrl());
$actions[] = Action::make('approve_selected_exception')
->label('Approve exception')
->color('success')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->form([
DateTimePicker::make('effective_from')
->label('Effective from')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Expires at')
->required()
->seconds(false),
Textarea::make('approval_reason')
->label('Approval reason')
->rows(3)
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->approve($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
Notification::make()
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
->success()
->send();
});
$actions[] = Action::make('reject_selected_exception')
->label('Reject exception')
->color('danger')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->form([
Textarea::make('rejection_reason')
->label('Rejection reason')
->rows(3)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->reject($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
Notification::make()
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
->success()
->send();
});
return $actions;
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->queueBaseQuery())
->defaultSort('requested_at', 'asc')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
TextColumn::make('current_validity_state')
->label('Validity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
TextColumn::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
->searchable(),
TextColumn::make('requester.name')
->label('Requested by')
->placeholder('—'),
TextColumn::make('owner.name')
->label('Owner')
->placeholder('—'),
TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->placeholder('—')
->sortable(),
TextColumn::make('expires_at')
->label('Expires')
->dateTime()
->placeholder('—')
->sortable(),
TextColumn::make('requested_at')
->label('Requested')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
SelectFilter::make('status')
->options(FilterOptionCatalog::findingExceptionStatuses()),
SelectFilter::make('current_validity_state')
->label('Validity')
->options(FilterOptionCatalog::findingExceptionValidityStates()),
])
->actions([
Action::make('inspect_exception')
->label('Inspect exception')
->icon('heroicon-o-eye')
->color('gray')
->action(function (FindingException $record): void {
$this->selectedFindingExceptionId = (int) $record->getKey();
}),
])
->bulkActions([])
->emptyStateHeading('No exceptions match this queue')
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
->emptyStateIcon('heroicon-o-shield-check')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (): void {
$this->removeTableFilter('tenant_id');
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->resetTable();
}),
]);
}
public function selectedFindingException(): ?FindingException
{
if (! is_int($this->selectedFindingExceptionId)) {
return null;
}
$record = $this->queueBaseQuery()
->whereKey($this->selectedFindingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
}
public function selectedExceptionUrl(): ?string
{
$record = $this->selectedFindingException();
if (! $record instanceof FindingException || ! $record->tenant) {
return null;
}
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
}
public function selectedFindingUrl(): ?string
{
$record = $this->selectedFindingException();
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
if (! $user instanceof User) {
return $this->authorizedTenants = [];
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return $this->authorizedTenants = [];
}
$tenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get();
return $this->authorizedTenants = $tenants
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
->values()
->all();
}
private function queueBaseQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantIds = array_values(array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->authorizedTenants(),
));
return FindingException::query()
->with([
'tenant',
'requester',
'owner',
'approver',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
'decisions.actor',
'evidenceReferences',
])
->where('workspace_id', (int) $workspaceId)
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return Collection::make($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function hasActiveQueueFilters(): bool
{
return $this->currentTenantFilterId() !== null
|| is_string(data_get($this->tableFilters, 'status.value'))
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
}
}

View File

@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingExceptionResource\Pages;
use App\Models\FindingException;
use App\Models\FindingExceptionEvidenceReference;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use UnitEnum;
class FindingExceptionResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = FindingException::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Risk exceptions';
protected static ?int $navigationSort = 60;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
return false;
}
return ! $record instanceof FindingException
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
}
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
->with(static::relationshipsForView());
}
public static function resolveScopedRecordOrFail(int|string|null $record): Model
{
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make('Exception')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
TextEntry::make('current_validity_state')
->label('Validity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextEntry::make('governance_warning')
->label('Governance warning')
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
->columnSpanFull()
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => static::findingSummary($record)),
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
])
->columns(2),
Section::make('Decision history')
->schema([
RepeatableEntry::make('decisions')
->hiddenLabel()
->schema([
TextEntry::make('decision_type')->label('Decision'),
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
])
->columns(3),
]),
Section::make('Evidence references')
->schema([
RepeatableEntry::make('evidenceReferences')
->hiddenLabel()
->schema([
TextEntry::make('label')->label('Label'),
TextEntry::make('source_type')->label('Source'),
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
TextEntry::make('summary_payload')
->label('Summary')
->state(function (FindingExceptionEvidenceReference $record): ?string {
if ($record->summary_payload === [] || $record->summary_payload === null) {
return null;
}
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
})
->placeholder('—')
->columnSpanFull(),
])
->columns(2),
])
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('requested_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
->sortable(),
Tables\Columns\TextColumn::make('current_validity_state')
->label('Validity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
->sortable(),
Tables\Columns\TextColumn::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => static::findingSummary($record))
->searchable(),
Tables\Columns\TextColumn::make('governance_warning')
->label('Governance warning')
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
->wrap(),
Tables\Columns\TextColumn::make('requester.name')
->label('Requested by')
->placeholder('—'),
Tables\Columns\TextColumn::make('owner.name')
->label('Owner')
->placeholder('—'),
Tables\Columns\TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->placeholder('—')
->sortable(),
Tables\Columns\TextColumn::make('requested_at')
->label('Requested')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('status')
->options(FilterOptionCatalog::findingExceptionStatuses()),
SelectFilter::make('current_validity_state')
->label('Validity')
->options(FilterOptionCatalog::findingExceptionValidityStates()),
])
->actions([
Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
->requiresConfirmation()
->form([
Select::make('owner_user_id')
->label('Owner')
->required()
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Textarea::make('request_reason')
->label('Renewal reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Requested expiry')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
$user = auth()->user();
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
abort(404);
}
try {
$service->renew($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Renewal request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Renewal request submitted')
->success()
->send();
}),
Action::make('revoke_exception')
->label('Revoke exception')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
->requiresConfirmation()
->form([
Textarea::make('revocation_reason')
->label('Revocation reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(404);
}
try {
$service->revoke($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception revocation failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception revoked')
->success()
->send();
}),
])
->bulkActions([])
->emptyStateHeading('No exceptions match this view')
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
->emptyStateIcon('heroicon-o-shield-exclamation')
->emptyStateActions([
Action::make('open_findings')
->label('Open findings')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn (): string => FindingResource::getUrl('index')),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListFindingExceptions::route('/'),
'view' => Pages\ViewFindingException::route('/{record}'),
];
}
/**
* @return array<int, string|array<int|string, mixed>>
*/
private static function relationshipsForView(): array
{
return [
'tenant',
'requester',
'owner',
'approver',
'currentDecision',
'decisions.actor',
'evidenceReferences',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
];
}
/**
* @return array<int, string>
*/
private static function tenantMemberOptions(): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return [];
}
return \App\Models\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();
}
private static function findingSummary(FindingException $record): string
{
$summary = $record->finding?->resolvedSubjectDisplayName();
if (is_string($summary) && trim($summary) !== '') {
return trim($summary);
}
return 'Finding #'.$record->finding_id;
}
private static function canManageRecord(FindingException $record): bool
{
$user = auth()->user();
return $user instanceof User
&& $record->tenant instanceof Tenant
&& $user->canAccessTenant($record->tenant)
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
}
private static function governanceWarning(FindingException $record): ?string
{
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if (! $finding instanceof \App\Models\Finding) {
return null;
}
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
}
private static function governanceWarningColor(FindingException $record): string
{
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
return 'warning';
}
return 'danger';
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
class ListFindingExceptions extends ListRecords
{
protected static string $resource = FindingExceptionResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('open_findings')
->label('Open findings')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(FindingResource::getUrl('index')),
];
}
}

View File

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class ViewFindingException extends ViewRecord
{
protected static string $resource = FindingExceptionResource::class;
protected function resolveRecord(int|string $key): Model
{
return FindingExceptionResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
return [
Action::make('open_finding')
->label('Open finding')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(function (): ?string {
$record = $this->getRecord();
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}),
Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
->fillForm(fn (): array => [
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
])
->requiresConfirmation()
->form([
Select::make('owner_user_id')
->label('Owner')
->required()
->options(fn (): array => FindingExceptionResource::canViewAny() ? $this->tenantMemberOptions() : [])
->searchable(),
Textarea::make('request_reason')
->label('Renewal reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Requested expiry')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->getRecord();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
try {
$service->renew($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Renewal request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Renewal request submitted')
->success()
->send();
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
}),
Action::make('revoke_exception')
->label('Revoke exception')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
->requiresConfirmation()
->form([
Textarea::make('revocation_reason')
->label('Revocation reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->getRecord();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
try {
$service->revoke($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception revocation failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception revoked')
->success()
->send();
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
}),
];
}
/**
* @return array<int, string>
*/
private function tenantMemberOptions(): array
{
$record = $this->getRecord();
if (! $record instanceof FindingException) {
return [];
}
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return [];
}
return \App\Models\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();
}
private function canManageRecord(): bool
{
$record = $this->getRecord();
$user = auth()->user();
return $record instanceof FindingException
&& $record->tenant instanceof Tenant
&& $user instanceof User
&& $user->canAccessTenant($record->tenant)
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
}
}

View File

@ -6,11 +6,14 @@
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingResource\Pages;
use App\Models\Finding;
use App\Models\FindingException;
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\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
@ -35,6 +38,8 @@
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
@ -223,6 +228,62 @@ public static function infolist(Schema $schema): Schema
->columns(2)
->columnSpanFull(),
Section::make('Risk governance')
->schema([
TextEntry::make('finding_governance_status')
->label('Exception status')
->badge()
->state(fn (Finding $record): ?string => $record->findingException?->status)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
->placeholder('—'),
TextEntry::make('finding_governance_validity')
->label('Validity')
->badge()
->state(function (Finding $record): ?string {
if ($record->findingException instanceof FindingException) {
return $record->findingException->current_validity_state;
}
return (string) $record->status === Finding::STATUS_RISK_ACCEPTED
? FindingException::VALIDITY_MISSING_SUPPORT
: null;
})
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
->placeholder('—'),
TextEntry::make('finding_governance_warning')
->label('Governance warning')
->state(fn (Finding $record): ?string => static::governanceWarning($record))
->color(fn (Finding $record): string => static::governanceWarningColor($record))
->columnSpanFull()
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
TextEntry::make('finding_governance_owner')
->label('Exception owner')
->state(fn (Finding $record): ?string => $record->findingException?->owner?->name)
->placeholder('—'),
TextEntry::make('finding_governance_approver')
->label('Approver')
->state(fn (Finding $record): ?string => $record->findingException?->approver?->name)
->placeholder('—'),
TextEntry::make('finding_governance_review_due')
->label('Review due')
->state(fn (Finding $record): mixed => $record->findingException?->review_due_at)
->dateTime()
->placeholder('—'),
TextEntry::make('finding_governance_expires')
->label('Expires')
->state(fn (Finding $record): mixed => $record->findingException?->expires_at)
->dateTime()
->placeholder('—'),
])
->columns(2)
->visible(fn (Finding $record): bool => $record->findingException instanceof FindingException || (string) $record->status === Finding::STATUS_RISK_ACCEPTED),
Section::make('Evidence')
->schema([
TextEntry::make('redaction_integrity_note')
@ -1014,80 +1075,6 @@ public static function table(Table $table): Table
->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 = static::resolveTenantContextForCurrentPanel();
$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 {
$record = static::resolveProtectedFindingRecordOrFail($record);
$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'),
])
->emptyStateHeading('No findings match this view')
@ -1098,7 +1085,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
->withSubjectDisplayName();
}
@ -1107,7 +1094,7 @@ public static function resolveScopedRecordOrFail(int|string $key): Model
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
->withSubjectDisplayName(),
);
}
@ -1185,7 +1172,9 @@ public static function workflowActions(): array
static::assignAction(),
static::resolveAction(),
static::closeAction(),
static::riskAcceptAction(),
static::requestExceptionAction(),
static::renewExceptionAction(),
static::revokeExceptionAction(),
static::reopenAction(),
];
}
@ -1355,37 +1344,153 @@ public static function closeAction(): Actions\Action
->apply();
}
public static function riskAcceptAction(): Actions\Action
public static function requestExceptionAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('risk_accept')
->label('Risk accept')
->icon('heroicon-o-shield-check')
Actions\Action::make('request_exception')
->label('Request exception')
->icon('heroicon-o-shield-exclamation')
->color('warning')
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Risk acceptance reason')
->rows(3)
Select::make('owner_user_id')
->label('Owner')
->required()
->maxLength(255),
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Textarea::make('request_reason')
->label('Request reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Expires at')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->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'] ?? ''),
),
);
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
static::runExceptionRequestMutation($record, $data, $service);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function renewExceptionAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRenewed() ?? false)
->fillForm(fn (Finding $record): array => [
'owner_user_id' => static::currentFindingException($record)?->owner_user_id,
])
->requiresConfirmation()
->form([
Select::make('owner_user_id')
->label('Owner')
->required()
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Textarea::make('request_reason')
->label('Renewal reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Requested expiry')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
static::runExceptionRenewalMutation($record, $data, $service);
})
)
->preserveVisibility()
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function revokeExceptionAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('revoke_exception')
->label('Revoke exception')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false)
->requiresConfirmation()
->form([
Textarea::make('revocation_reason')
->label('Revocation reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
static::runExceptionRevocationMutation($record, $data, $service);
})
)
->preserveVisibility()
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
@ -1462,6 +1567,112 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
->send();
}
/**
* @param array<string, mixed> $data
*/
private static function runExceptionRequestMutation(Finding $record, array $data, FindingExceptionService $service): void
{
$record = static::resolveProtectedFindingRecordOrFail($record);
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
try {
$createdException = $service->request($record, $tenant, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception request submitted')
->success()
->actions([
Actions\Action::make('view_exception')
->label('View exception')
->url(static::findingExceptionViewUrl($createdException, $tenant)),
])
->send();
}
/**
* @param array<string, mixed> $data
*/
private static function runExceptionRenewalMutation(Finding $record, array $data, FindingExceptionService $service): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
try {
$renewedException = $service->renew(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Renewal request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Renewal request submitted')
->success()
->actions([
Actions\Action::make('view_exception')
->label('View exception')
->url(static::findingExceptionViewUrl($renewedException, $tenant)),
])
->send();
}
/**
* @param array<string, mixed> $data
*/
private static function runExceptionRevocationMutation(Finding $record, array $data, FindingExceptionService $service): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
try {
$revokedException = $service->revoke(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception revocation failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception revoked')
->success()
->actions([
Actions\Action::make('view_exception')
->label('View exception')
->url(static::findingExceptionViewUrl($revokedException, $tenant)),
])
->send();
}
private static function freshWorkflowRecord(Finding $record): Finding
{
return static::resolveProtectedFindingRecordOrFail($record);
@ -1483,6 +1694,67 @@ private static function resolveProtectedFindingRecordOrFail(Finding|int|string $
return $resolvedRecord;
}
private static function currentFindingException(Finding $record): ?FindingException
{
$finding = static::resolveProtectedFindingRecordOrFail($record);
return static::resolvedFindingException($finding);
}
private static function resolvedFindingException(Finding $finding): ?FindingException
{
$exception = $finding->relationLoaded('findingException')
? $finding->findingException
: $finding->findingException()->with('currentDecision')->first();
if (! $exception instanceof FindingException) {
return null;
}
$exception->loadMissing('currentDecision');
return $exception;
}
private static function resolveCurrentFindingExceptionOrFail(Finding $record): FindingException
{
$exception = static::currentFindingException($record);
if (! $exception instanceof FindingException) {
throw new InvalidArgumentException('This finding does not have an exception to manage.');
}
return $exception;
}
private static function findingExceptionViewUrl(\App\Models\FindingException $exception, Tenant $tenant): string
{
$panelId = Filament::getCurrentPanel()?->getId();
if ($panelId === 'admin') {
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
}
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
}
private static function governanceWarning(Finding $finding): ?string
{
return app(FindingRiskGovernanceResolver::class)
->resolveWarningMessage($finding, static::resolvedFindingException($finding));
}
private static function governanceWarningColor(Finding $finding): string
{
$exception = static::resolvedFindingException($finding);
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
return 'warning';
}
return 'danger';
}
/**
* @return array<int, string>
*/

View File

@ -39,7 +39,7 @@ class ListFindings extends ListRecords
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'risk_accept', 'reopen'], true)) {
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'request_exception', 'reopen'], true)) {
try {
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
} catch (ModelNotFoundException) {

View File

@ -85,6 +85,9 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
$permissionPosturePayload = $this->itemSummaryPayload($items->get('permission_posture'));
$entraRolesPayload = $this->itemSummaryPayload($items->get('entra_admin_roles'));
$operationsPayload = $this->itemSummaryPayload($items->get('operations_summary'));
$riskAcceptance = is_array($snapshot->summary['risk_acceptance'] ?? null)
? $snapshot->summary['risk_acceptance']
: (is_array($findingsPayload['risk_acceptance'] ?? null) ? $findingsPayload['risk_acceptance'] : []);
$findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []);
$recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []);
@ -101,6 +104,7 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
tenant: $tenant,
snapshot: $snapshot,
dataFreshness: $dataFreshness,
riskAcceptance: $riskAcceptance,
includePii: $includePii,
includeOperations: $includeOperations,
);
@ -138,6 +142,7 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
'operation_count' => $recentOperations->count(),
'data_freshness' => $dataFreshness,
'risk_acceptance' => $riskAcceptance,
'evidence_resolution' => [
'outcome' => 'resolved',
'snapshot_id' => (int) $snapshot->getKey(),
@ -197,6 +202,7 @@ private function buildFileMap(
Tenant $tenant,
EvidenceSnapshot $snapshot,
array $dataFreshness,
array $riskAcceptance,
bool $includePii,
bool $includeOperations,
): array {
@ -251,6 +257,7 @@ private function buildFileMap(
'finding_count' => $findings->count(),
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
'operation_count' => $recentOperations->count(),
'risk_acceptance' => $riskAcceptance,
'snapshot_id' => (int) $snapshot->getKey(),
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

View File

@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Arr;
class Finding extends Model
@ -98,6 +99,14 @@ public function closedByUser(): BelongsTo
return $this->belongsTo(User::class, 'closed_by_user_id');
}
/**
* @return HasOne<FindingException, $this>
*/
public function findingException(): HasOne
{
return $this->hasOne(FindingException::class);
}
/**
* @return array<int, string>
*/
@ -160,6 +169,11 @@ public function hasOpenStatus(): bool
return self::isOpenStatus($this->status);
}
public function isRiskAccepted(): bool
{
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
}
public function acknowledge(User $user): self
{
if ($this->status === self::STATUS_ACKNOWLEDGED) {

View File

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FindingException extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string STATUS_PENDING = 'pending';
public const string STATUS_ACTIVE = 'active';
public const string STATUS_EXPIRING = 'expiring';
public const string STATUS_EXPIRED = 'expired';
public const string STATUS_REJECTED = 'rejected';
public const string STATUS_REVOKED = 'revoked';
public const string STATUS_SUPERSEDED = 'superseded';
public const string VALIDITY_VALID = 'valid';
public const string VALIDITY_EXPIRING = 'expiring';
public const string VALIDITY_EXPIRED = 'expired';
public const string VALIDITY_REVOKED = 'revoked';
public const string VALIDITY_REJECTED = 'rejected';
public const string VALIDITY_MISSING_SUPPORT = 'missing_support';
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'requested_at' => 'datetime',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
'revoked_at' => 'datetime',
'effective_from' => 'datetime',
'expires_at' => 'datetime',
'review_due_at' => 'datetime',
'evidence_summary' => 'array',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<Finding, $this>
*/
public function finding(): BelongsTo
{
return $this->belongsTo(Finding::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by_user_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
/**
* @return BelongsTo<FindingExceptionDecision, $this>
*/
public function currentDecision(): BelongsTo
{
return $this->belongsTo(FindingExceptionDecision::class, 'current_decision_id');
}
/**
* @return HasMany<FindingExceptionDecision, $this>
*/
public function decisions(): HasMany
{
return $this->hasMany(FindingExceptionDecision::class)
->orderBy('decided_at')
->orderBy('id');
}
/**
* @return HasMany<FindingExceptionEvidenceReference, $this>
*/
public function evidenceReferences(): HasMany
{
return $this->hasMany(FindingExceptionEvidenceReference::class)
->orderBy('id');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForFinding(Builder $query, Finding $finding): Builder
{
return $query->where('finding_id', (int) $finding->getKey());
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopePending(Builder $query): Builder
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeCurrent(Builder $query): Builder
{
return $query->whereIn('status', [
self::STATUS_PENDING,
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
]);
}
public function isPending(): bool
{
return (string) $this->status === self::STATUS_PENDING;
}
public function isActiveLike(): bool
{
return in_array((string) $this->status, [
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
], true);
}
public function hasPriorApproval(): bool
{
return $this->approved_at !== null
&& $this->effective_from !== null
&& is_numeric($this->approved_by_user_id);
}
public function hasValidGovernance(): bool
{
return in_array((string) $this->current_validity_state, [
self::VALIDITY_VALID,
self::VALIDITY_EXPIRING,
], true);
}
public function currentDecisionType(): ?string
{
$decision = $this->relationLoaded('currentDecision')
? $this->currentDecision
: $this->currentDecision()->first();
return $decision instanceof FindingExceptionDecision
? (string) $decision->decision_type
: null;
}
public function isPendingRenewal(): bool
{
return $this->isPending()
&& $this->hasPriorApproval()
&& $this->currentDecisionType() === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED;
}
public function requiresFreshDecisionForFinding(Finding $finding): bool
{
return ! $finding->isRiskAccepted()
&& ! $this->isPending()
&& $this->hasValidGovernance();
}
public function canBeRenewed(): bool
{
return in_array((string) $this->status, [
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
self::STATUS_EXPIRED,
], true);
}
public function canBeRevoked(): bool
{
if ($this->isPendingRenewal()) {
return true;
}
return in_array((string) $this->status, [
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
], true);
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use LogicException;
class FindingExceptionDecision extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string TYPE_REQUESTED = 'requested';
public const string TYPE_APPROVED = 'approved';
public const string TYPE_REJECTED = 'rejected';
public const string TYPE_RENEWAL_REQUESTED = 'renewal_requested';
public const string TYPE_RENEWED = 'renewed';
public const string TYPE_REVOKED = 'revoked';
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'effective_from' => 'datetime',
'expires_at' => 'datetime',
'metadata' => 'array',
'decided_at' => 'datetime',
];
}
protected static function booted(): void
{
static::updating(static function (): void {
throw new LogicException('Finding exception decisions are append-only.');
});
static::deleting(static function (): void {
throw new LogicException('Finding exception decisions are append-only.');
});
}
/**
* @return BelongsTo<FindingException, $this>
*/
public function exception(): BelongsTo
{
return $this->belongsTo(FindingException::class, 'finding_exception_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FindingExceptionEvidenceReference extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'summary_payload' => 'array',
'measured_at' => 'datetime',
];
}
/**
* @return BelongsTo<FindingException, $this>
*/
public function exception(): BelongsTo
{
return $this->belongsTo(FindingException::class, 'finding_exception_id');
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -261,6 +261,11 @@ public function auditLogs(): HasMany
return $this->hasMany(AuditLog::class);
}
public function findingExceptions(): HasMany
{
return $this->hasMany(FindingException::class);
}
public function evidenceSnapshots(): HasMany
{
return $this->hasMany(EvidenceSnapshot::class);

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
class FindingExceptionPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
$tenant = $this->resolvedTenant();
if (! $tenant instanceof Tenant) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
}
public function view(User $user, FindingException $exception): Response|bool
{
$tenant = $this->authorizedTenantOrNull($user, $exception);
if (! $tenant instanceof Tenant) {
return Response::denyAsNotFound();
}
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
}
public function approve(User $user, FindingException $exception): Response|bool
{
return $this->authorizeCanonicalApproval($user, $exception);
}
public function reject(User $user, FindingException $exception): Response|bool
{
return $this->authorizeCanonicalApproval($user, $exception);
}
private function authorizeCanonicalApproval(User $user, FindingException $exception): Response|bool
{
$tenant = $exception->tenant;
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
return Response::denyAsNotFound();
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId) || $workspaceId !== (int) $exception->workspace_id) {
return Response::denyAsNotFound();
}
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
return Response::denyAsNotFound();
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)
? true
: Response::deny();
}
private function authorizedTenantOrNull(User $user, FindingException $exception): ?Tenant
{
$tenant = $this->resolvedTenant();
if (! $tenant instanceof Tenant) {
return null;
}
if (! $user->canAccessTenant($tenant)) {
return null;
}
if ((int) $exception->tenant_id !== (int) $tenant->getKey()) {
return null;
}
if ((int) $exception->workspace_id !== (int) $tenant->workspace_id) {
return null;
}
return $tenant;
}
private function resolvedTenant(): ?Tenant
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
if (! is_int($tenantId)) {
return null;
}
$tenant = Tenant::query()->whereKey($tenantId)->first();
return $tenant instanceof Tenant && (int) $tenant->workspace_id === $workspaceId ? $tenant : null;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant ? $tenant : null;
}
}

View File

@ -6,6 +6,7 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Filament\Pages\TenantRequiredPermissions;
@ -171,6 +172,7 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class,
TenantRequiredPermissions::class,
WorkspaceSettings::class,
FindingExceptionsQueue::class,
])
->widgets([
AccountWidget::class,

View File

@ -27,6 +27,8 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW,
@ -66,6 +68,8 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW,
@ -97,6 +101,7 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW,
@ -117,6 +122,7 @@ class RoleCapabilityMap
TenantRole::Readonly->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW,

View File

@ -41,6 +41,7 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_BASELINES_VIEW,
Capabilities::WORKSPACE_BASELINES_MANAGE,
Capabilities::AUDIT_VIEW,
Capabilities::FINDING_EXCEPTION_APPROVE,
],
WorkspaceRole::Manager->value => [
@ -63,6 +64,7 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_BASELINES_VIEW,
Capabilities::WORKSPACE_BASELINES_MANAGE,
Capabilities::AUDIT_VIEW,
Capabilities::FINDING_EXCEPTION_APPROVE,
],
WorkspaceRole::Operator->value => [

View File

@ -190,12 +190,19 @@ public function buildSnapshotPayload(Tenant $tenant): array
],
$items,
));
$itemsByKey = collect($items)->keyBy('dimension_key');
$findingsSummary = is_array($itemsByKey->get('findings_summary')['summary_payload'] ?? null)
? $itemsByKey->get('findings_summary')['summary_payload']
: [];
$operationsSummary = is_array($itemsByKey->get('operations_summary')['summary_payload'] ?? null)
? $itemsByKey->get('operations_summary')['summary_payload']
: [];
$summary = [
'dimension_count' => count($items),
'finding_count' => (int) ($items[0]['summary_payload']['count'] ?? 0),
'finding_count' => (int) ($findingsSummary['count'] ?? 0),
'report_count' => count(array_filter($items, static fn (array $item): bool => in_array($item['dimension_key'], ['permission_posture', 'entra_admin_roles'], true) && $item['source_record_id'] !== null)),
'operation_count' => (int) ($items[4]['summary_payload']['operation_count'] ?? 0),
'operation_count' => (int) ($operationsSummary['operation_count'] ?? 0),
'missing_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Missing->value)),
'stale_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Stale->value)),
'dimensions' => array_map(static fn (array $item): array => [
@ -203,6 +210,16 @@ public function buildSnapshotPayload(Tenant $tenant): array
'state' => $item['state'],
'required' => $item['required'],
], $items),
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
? $findingsSummary['risk_acceptance']
: [
'status_marked_count' => 0,
'valid_governed_count' => 0,
'warning_count' => 0,
'expired_count' => 0,
'revoked_count' => 0,
'missing_exception_count' => 0,
],
'hardening' => [
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),

View File

@ -7,10 +7,15 @@
use App\Models\Finding;
use App\Models\Tenant;
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Evidence\EvidenceCompletenessState;
final class FindingsSummarySource implements EvidenceSourceProvider
{
public function __construct(
private readonly FindingRiskGovernanceResolver $governanceResolver,
) {}
public function key(): string
{
return 'findings_summary';
@ -20,10 +25,37 @@ public function collect(Tenant $tenant): array
{
$findings = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->with('findingException.currentDecision')
->orderByDesc('updated_at')
->get();
$latest = $findings->max('updated_at') ?? $findings->max('created_at');
$entries = $findings->map(function (Finding $finding): array {
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
return [
'id' => (int) $finding->getKey(),
'finding_type' => (string) $finding->finding_type,
'severity' => (string) $finding->severity,
'status' => (string) $finding->status,
'title' => $finding->title,
'description' => $finding->description,
'created_at' => $finding->created_at?->toIso8601String(),
'updated_at' => $finding->updated_at?->toIso8601String(),
'governance_state' => $governanceState,
'governance_warning' => $governanceWarning,
];
});
$riskAcceptedEntries = $entries->filter(
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
);
$warningStates = [
'expired_exception',
'revoked_exception',
'rejected_exception',
'risk_accepted_without_valid_exception',
];
$summary = [
'count' => $findings->count(),
@ -34,16 +66,19 @@ public function collect(Tenant $tenant): array
'medium' => $findings->where('severity', Finding::SEVERITY_MEDIUM)->count(),
'low' => $findings->where('severity', Finding::SEVERITY_LOW)->count(),
],
'entries' => $findings->map(static fn (Finding $finding): array => [
'id' => (int) $finding->getKey(),
'finding_type' => (string) $finding->finding_type,
'severity' => (string) $finding->severity,
'status' => (string) $finding->status,
'title' => $finding->title,
'description' => $finding->description,
'created_at' => $finding->created_at?->toIso8601String(),
'updated_at' => $finding->updated_at?->toIso8601String(),
])->all(),
'risk_acceptance' => [
'status_marked_count' => $riskAcceptedEntries->count(),
'valid_governed_count' => $riskAcceptedEntries->filter(
static fn (array $entry): bool => in_array($entry['governance_state'] ?? null, ['valid_exception', 'expiring_exception'], true),
)->count(),
'warning_count' => $riskAcceptedEntries->filter(
static fn (array $entry): bool => in_array($entry['governance_state'] ?? null, $warningStates, true),
)->count(),
'expired_count' => $riskAcceptedEntries->where('governance_state', 'expired_exception')->count(),
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
],
'entries' => $entries->all(),
];
return [

View File

@ -0,0 +1,916 @@
<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Audit\AuditActionId;
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 FindingExceptionService
{
public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private readonly FindingWorkflowService $findingWorkflowService,
private readonly FindingRiskGovernanceResolver $governanceResolver,
private readonly AuditLogger $auditLogger,
) {}
/**
* @param array{
* owner_user_id?: mixed,
* request_reason?: mixed,
* review_due_at?: mixed,
* expires_at?: mixed,
* evidence_references?: mixed
* } $payload
*/
public function request(Finding $finding, Tenant $tenant, User $actor, array $payload): FindingException
{
$this->authorizeRequest($finding, $tenant, $actor);
$ownerUserId = $this->validatedTenantMemberId(
tenant: $tenant,
userId: $payload['owner_user_id'] ?? null,
field: 'owner_user_id',
required: true,
);
$requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
$reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
$evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
$requestedAt = CarbonImmutable::now();
/** @var FindingException $exception */
$exception = DB::transaction(function () use ($finding, $tenant, $actor, $ownerUserId, $requestReason, $reviewDueAt, $expiresAt, $evidenceReferences, $requestedAt): FindingException {
$exception = FindingException::query()
->where('finding_id', (int) $finding->getKey())
->lockForUpdate()
->first();
if ($exception instanceof FindingException && $exception->isPending()) {
throw new InvalidArgumentException('An exception request is already pending for this finding.');
}
if ($exception instanceof FindingException && $exception->isActiveLike()) {
throw new InvalidArgumentException('This finding already has an active exception.');
}
$exception ??= new FindingException([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
]);
$before = $this->exceptionSnapshot($exception);
$exception->fill([
'requested_by_user_id' => (int) $actor->getKey(),
'owner_user_id' => $ownerUserId,
'approved_by_user_id' => null,
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => $requestReason,
'approval_reason' => null,
'rejection_reason' => null,
'revocation_reason' => null,
'requested_at' => $requestedAt,
'approved_at' => null,
'rejected_at' => null,
'revoked_at' => null,
'effective_from' => null,
'expires_at' => $expiresAt,
'review_due_at' => $reviewDueAt,
'evidence_summary' => $this->evidenceSummary($evidenceReferences),
]);
$exception->save();
$this->replaceEvidenceReferences($exception, $evidenceReferences);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => $requestReason,
'expires_at' => $expiresAt,
'metadata' => [
'review_due_at' => $reviewDueAt->toIso8601String(),
'evidence_reference_count' => count($evidenceReferences),
],
'decided_at' => $requestedAt,
]);
$exception->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$after = $this->exceptionSnapshot($exception->fresh($this->exceptionRelationships()) ?? $exception);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRequested,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $exception->getKey(),
targetLabel: 'Finding exception #'.$exception->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'before' => $before,
'after' => $after,
],
],
);
return $exception;
});
return $this->governanceResolver->syncExceptionState(
$exception->fresh($this->exceptionRelationships()) ?? $exception,
);
}
/**
* @param array{
* effective_from?: mixed,
* expires_at?: mixed,
* approval_reason?: mixed
* } $payload
*/
public function approve(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$workspace = $this->workspaceForTenant($tenant);
$this->authorizeApproval($exception, $tenant, $workspace, $actor);
$effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from');
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true);
$approvalReason = $this->validatedOptionalReason($payload['approval_reason'] ?? null, 'approval_reason');
$approvedAt = CarbonImmutable::now();
/** @var FindingException $approvedException */
$approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $effectiveFrom, $expiresAt, $approvalReason, $approvedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['finding', 'tenant', 'requester', 'currentDecision'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->isPending()) {
throw new InvalidArgumentException('Only pending exception requests can be approved.');
}
if ((int) $lockedException->requested_by_user_id === (int) $actor->getKey()) {
throw new InvalidArgumentException('Requesters cannot approve their own exception requests.');
}
$isRenewalApproval = $lockedException->isPendingRenewal();
$before = $this->exceptionSnapshot($lockedException);
$lockedException->fill([
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'approved_by_user_id' => (int) $actor->getKey(),
'approval_reason' => $approvalReason,
'approved_at' => $approvedAt,
'effective_from' => $effectiveFrom,
'expires_at' => $expiresAt,
'rejection_reason' => null,
'rejected_at' => null,
'revocation_reason' => null,
]);
$lockedException->save();
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => $isRenewalApproval
? FindingExceptionDecision::TYPE_RENEWED
: FindingExceptionDecision::TYPE_APPROVED,
'reason' => $approvalReason,
'effective_from' => $effectiveFrom,
'expires_at' => $expiresAt,
'metadata' => [
'request_type' => $isRenewalApproval ? 'renewal' : 'initial',
],
'decided_at' => $approvedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$finding = $lockedException->finding;
if (! $finding instanceof Finding) {
throw new InvalidArgumentException('The linked finding could not be resolved.');
}
if (! $isRenewalApproval) {
$this->findingWorkflowService->riskAcceptFromException(
finding: $finding,
tenant: $tenant,
actor: $actor,
reason: $this->findingRiskAcceptedReason($lockedException, $approvalReason),
);
}
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: $isRenewalApproval
? AuditActionId::FindingExceptionRenewed
: AuditActionId::FindingExceptionApproved,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'decision_type' => $isRenewalApproval
? FindingExceptionDecision::TYPE_RENEWED
: FindingExceptionDecision::TYPE_APPROVED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $approvedException;
}
/**
* @param array{
* rejection_reason?: mixed
* } $payload
*/
public function reject(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$workspace = $this->workspaceForTenant($tenant);
$this->authorizeApproval($exception, $tenant, $workspace, $actor);
$rejectionReason = $this->validatedReason($payload['rejection_reason'] ?? null, 'rejection_reason');
$rejectedAt = CarbonImmutable::now();
/** @var FindingException $rejectedException */
$rejectedException = DB::transaction(function () use ($exception, $tenant, $actor, $rejectionReason, $rejectedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['finding', 'currentDecision'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->isPending()) {
throw new InvalidArgumentException('Only pending exception requests can be rejected.');
}
$isRenewalRejection = $lockedException->isPendingRenewal();
$before = $this->exceptionSnapshot($lockedException);
if ($isRenewalRejection) {
$lockedException->fill([
'status' => FindingException::STATUS_ACTIVE,
'rejection_reason' => $rejectionReason,
'rejected_at' => $rejectedAt,
'review_due_at' => $this->metadataDate($lockedException, 'previous_review_due_at') ?? $lockedException->review_due_at,
]);
} else {
$lockedException->fill([
'status' => FindingException::STATUS_REJECTED,
'current_validity_state' => FindingException::VALIDITY_REJECTED,
'rejection_reason' => $rejectionReason,
'rejected_at' => $rejectedAt,
'approved_by_user_id' => null,
'approved_at' => null,
'approval_reason' => null,
'effective_from' => null,
]);
}
$lockedException->save();
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
'reason' => $rejectionReason,
'metadata' => [
'request_type' => $isRenewalRejection ? 'renewal' : 'initial',
],
'decided_at' => $rejectedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRejected,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $resolvedException->finding_id,
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $rejectedException;
}
/**
* @param array{
* owner_user_id?: mixed,
* request_reason?: mixed,
* review_due_at?: mixed,
* expires_at?: mixed,
* evidence_references?: mixed
* } $payload
*/
public function renew(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$this->authorizeManagement($exception, $tenant, $actor);
$requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
$reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
$requestedExpiry = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
$evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
$requestedAt = CarbonImmutable::now();
/** @var FindingException $renewedException */
$renewedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $requestReason, $reviewDueAt, $requestedExpiry, $evidenceReferences, $requestedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['currentDecision', 'finding'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->canBeRenewed()) {
throw new InvalidArgumentException('Only active, expiring, or expired exceptions can be renewed.');
}
$ownerUserId = array_key_exists('owner_user_id', $payload)
? $this->validatedTenantMemberId($tenant, $payload['owner_user_id'], 'owner_user_id')
: (is_numeric($lockedException->owner_user_id) ? (int) $lockedException->owner_user_id : null);
$before = $this->exceptionSnapshot($lockedException);
$lockedException->fill([
'requested_by_user_id' => (int) $actor->getKey(),
'owner_user_id' => $ownerUserId,
'status' => FindingException::STATUS_PENDING,
'request_reason' => $requestReason,
'requested_at' => $requestedAt,
'review_due_at' => $reviewDueAt,
'rejection_reason' => null,
'rejected_at' => null,
'revocation_reason' => null,
'evidence_summary' => $this->evidenceSummary($evidenceReferences),
]);
$lockedException->save();
$this->replaceEvidenceReferences($lockedException, $evidenceReferences);
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
'reason' => $requestReason,
'expires_at' => $requestedExpiry,
'metadata' => [
'review_due_at' => $reviewDueAt->toIso8601String(),
'requested_expires_at' => $requestedExpiry?->toIso8601String(),
'previous_review_due_at' => $lockedException->getOriginal('review_due_at'),
'previous_expires_at' => $lockedException->getOriginal('expires_at'),
'evidence_reference_count' => count($evidenceReferences),
],
'decided_at' => $requestedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRenewalRequested,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $resolvedException->finding_id,
'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $renewedException;
}
/**
* @param array{
* revocation_reason?: mixed
* } $payload
*/
public function revoke(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$this->authorizeManagement($exception, $tenant, $actor);
$revocationReason = $this->validatedReason($payload['revocation_reason'] ?? null, 'revocation_reason');
$revokedAt = CarbonImmutable::now();
/** @var FindingException $revokedException */
$revokedException = DB::transaction(function () use ($exception, $tenant, $actor, $revocationReason, $revokedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['currentDecision', 'finding'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->canBeRevoked()) {
throw new InvalidArgumentException('Only active or pending-renewal exceptions can be revoked.');
}
$before = $this->exceptionSnapshot($lockedException);
$lockedException->fill([
'status' => FindingException::STATUS_REVOKED,
'current_validity_state' => FindingException::VALIDITY_REVOKED,
'revocation_reason' => $revocationReason,
'revoked_at' => $revokedAt,
]);
$lockedException->save();
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
'reason' => $revocationReason,
'metadata' => [],
'decided_at' => $revokedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRevoked,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $resolvedException->finding_id,
'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $revokedException;
}
private function authorizeRequest(Finding $finding, Tenant $tenant, User $actor): void
{
if (! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
$this->assertFindingOwnedByTenant($finding, $tenant);
if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
return;
}
throw new AuthorizationException('Missing capability for exception request.');
}
private function authorizeApproval(FindingException $exception, Tenant $tenant, Workspace $workspace, User $actor): void
{
if (! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) {
throw new NotFoundHttpException;
}
if ((int) $exception->workspace_id !== (int) $workspace->getKey() || (int) $exception->tenant_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ($this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) {
return;
}
throw new AuthorizationException('Missing capability for exception approval.');
}
private function authorizeManagement(FindingException $exception, Tenant $tenant, User $actor): void
{
if (! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if ((int) $exception->workspace_id !== (int) $tenant->workspace_id || (int) $exception->tenant_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
return;
}
throw new AuthorizationException('Missing capability for exception management.');
}
private function tenantForException(FindingException $exception): Tenant
{
$tenant = $exception->tenant;
if (! $tenant instanceof Tenant) {
$tenant = Tenant::query()->findOrFail((int) $exception->tenant_id);
}
return $tenant;
}
private function workspaceForTenant(Tenant $tenant): Workspace
{
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
$workspace = Workspace::query()->findOrFail((int) $tenant->workspace_id);
}
return $workspace;
}
private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): void
{
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) {
throw new NotFoundHttpException;
}
}
private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int
{
if ($userId === null || $userId === '') {
if ($required) {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
return null;
}
if (! is_numeric($userId) || (int) $userId <= 0) {
throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $field));
}
$resolvedUserId = (int) $userId;
$isMember = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', $resolvedUserId)
->exists();
if (! $isMember) {
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field));
}
return $resolvedUserId;
}
private function validatedReason(mixed $reason, string $field): string
{
if (! is_string($reason)) {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
$resolved = trim($reason);
if ($resolved === '') {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
if (mb_strlen($resolved) > 2000) {
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
}
return $resolved;
}
private function validatedOptionalReason(mixed $reason, string $field): ?string
{
if ($reason === null || $reason === '') {
return null;
}
return $this->validatedReason($reason, $field);
}
private function validatedDate(mixed $value, string $field): CarbonImmutable
{
try {
return CarbonImmutable::parse((string) $value);
} catch (\Throwable) {
throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $field));
}
}
private function validatedFutureDate(mixed $value, string $field): CarbonImmutable
{
$date = $this->validatedDate($value, $field);
if ($date->lessThanOrEqualTo(CarbonImmutable::now())) {
throw new InvalidArgumentException(sprintf('%s must be in the future.', $field));
}
return $date;
}
private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum, bool $required = false): ?CarbonImmutable
{
if ($value === null || $value === '') {
if ($required) {
throw new InvalidArgumentException('expires_at is required.');
}
return null;
}
$expiresAt = $this->validatedDate($value, 'expires_at');
if ($expiresAt->lessThanOrEqualTo($minimum)) {
throw new InvalidArgumentException('expires_at must be after the related review or effective date.');
}
return $expiresAt;
}
/**
* @return list<array{
* source_type: string,
* source_id: ?string,
* source_fingerprint: ?string,
* label: string,
* measured_at: ?CarbonImmutable,
* summary_payload: array<string, mixed>
* }>
*/
private function validatedEvidenceReferences(mixed $references): array
{
if (! is_array($references)) {
return [];
}
$resolved = [];
foreach ($references as $reference) {
if (! is_array($reference)) {
continue;
}
$sourceType = trim((string) ($reference['source_type'] ?? ''));
$label = trim((string) ($reference['label'] ?? ''));
if ($sourceType === '' || $label === '') {
continue;
}
$measuredAt = null;
if (($reference['measured_at'] ?? null) !== null && (string) $reference['measured_at'] !== '') {
$measuredAt = $this->validatedDate($reference['measured_at'], 'measured_at');
}
$resolved[] = [
'source_type' => $sourceType,
'source_id' => filled($reference['source_id'] ?? null) ? trim((string) $reference['source_id']) : null,
'source_fingerprint' => filled($reference['source_fingerprint'] ?? null) ? trim((string) $reference['source_fingerprint']) : null,
'label' => mb_substr($label, 0, 255),
'measured_at' => $measuredAt,
'summary_payload' => is_array($reference['summary_payload'] ?? null) ? $reference['summary_payload'] : [],
];
}
return $resolved;
}
/**
* @param list<array{
* source_type: string,
* source_id: ?string,
* source_fingerprint: ?string,
* label: string,
* measured_at: ?CarbonImmutable,
* summary_payload: array<string, mixed>
* }> $references
*/
private function replaceEvidenceReferences(FindingException $exception, array $references): void
{
$exception->evidenceReferences()->delete();
foreach ($references as $reference) {
$exception->evidenceReferences()->create([
'workspace_id' => (int) $exception->workspace_id,
'tenant_id' => (int) $exception->tenant_id,
'source_type' => $reference['source_type'],
'source_id' => $reference['source_id'],
'source_fingerprint' => $reference['source_fingerprint'],
'label' => $reference['label'],
'measured_at' => $reference['measured_at'],
'summary_payload' => $reference['summary_payload'],
]);
}
}
/**
* @param list<array{
* source_type: string,
* source_id: ?string,
* source_fingerprint: ?string,
* label: string,
* measured_at: ?CarbonImmutable,
* summary_payload: array<string, mixed>
* }> $references
* @return array<string, mixed>
*/
private function evidenceSummary(array $references): array
{
return [
'reference_count' => count($references),
'labels' => array_values(array_map(
static fn (array $reference): string => $reference['label'],
array_slice($references, 0, 5),
)),
];
}
private function findingRiskAcceptedReason(FindingException $exception, ?string $approvalReason): string
{
if (is_string($approvalReason) && $approvalReason !== '') {
return mb_substr($approvalReason, 0, 255);
}
return 'Governed by approved exception #'.$exception->getKey();
}
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
{
$currentDecision = $exception->relationLoaded('currentDecision')
? $exception->currentDecision
: $exception->currentDecision()->first();
if (! $currentDecision instanceof FindingExceptionDecision) {
return null;
}
$value = $currentDecision->metadata[$key] ?? null;
if (! is_string($value) || trim($value) === '') {
return null;
}
return CarbonImmutable::parse($value);
}
/**
* @return array<string, mixed>
*/
private function exceptionSnapshot(FindingException $exception): array
{
return [
'status' => $exception->status,
'current_validity_state' => $exception->current_validity_state,
'current_decision_type' => $exception->currentDecisionType(),
'finding_id' => $exception->finding_id,
'requested_by_user_id' => $exception->requested_by_user_id,
'owner_user_id' => $exception->owner_user_id,
'approved_by_user_id' => $exception->approved_by_user_id,
'requested_at' => $exception->requested_at?->toIso8601String(),
'approved_at' => $exception->approved_at?->toIso8601String(),
'rejected_at' => $exception->rejected_at?->toIso8601String(),
'revoked_at' => $exception->revoked_at?->toIso8601String(),
'effective_from' => $exception->effective_from?->toIso8601String(),
'expires_at' => $exception->expires_at?->toIso8601String(),
'review_due_at' => $exception->review_due_at?->toIso8601String(),
'request_reason' => $exception->request_reason,
'approval_reason' => $exception->approval_reason,
'rejection_reason' => $exception->rejection_reason,
'revocation_reason' => $exception->revocation_reason,
];
}
/**
* @return array<int, string|array<int|string, mixed>>
*/
private function exceptionRelationships(): array
{
return [
'finding',
'tenant',
'requester',
'owner',
'approver',
'currentDecision',
'decisions.actor',
'evidenceReferences',
];
}
}

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon;
final class FindingRiskGovernanceResolver
{
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
{
$now ??= CarbonImmutable::instance(now());
$status = (string) $exception->status;
if (in_array($status, [
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
], true)) {
return $status;
}
if ($status === FindingException::STATUS_PENDING) {
return FindingException::STATUS_PENDING;
}
$expiresAt = $exception->expires_at instanceof Carbon
? CarbonImmutable::instance($exception->expires_at)
: null;
if ($expiresAt instanceof CarbonImmutable && $expiresAt->lessThanOrEqualTo($now)) {
return FindingException::STATUS_EXPIRED;
}
if ($this->isExpiring($exception, $now)) {
return FindingException::STATUS_EXPIRING;
}
return FindingException::STATUS_ACTIVE;
}
public function resolveValidityState(FindingException $exception, ?CarbonImmutable $now = null): string
{
if ($exception->isPendingRenewal()) {
return $this->resolveApprovedValidityState($exception, $now);
}
return match ($this->resolveExceptionStatus($exception, $now)) {
FindingException::STATUS_ACTIVE => FindingException::VALIDITY_VALID,
FindingException::STATUS_EXPIRING => FindingException::VALIDITY_EXPIRING,
FindingException::STATUS_EXPIRED => FindingException::VALIDITY_EXPIRED,
FindingException::STATUS_REVOKED => FindingException::VALIDITY_REVOKED,
FindingException::STATUS_REJECTED => FindingException::VALIDITY_REJECTED,
default => FindingException::VALIDITY_MISSING_SUPPORT,
};
}
public function resolveFindingState(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
{
$exception ??= $finding->relationLoaded('findingException')
? $finding->findingException
: $finding->findingException()->first();
$findingIsRiskAccepted = $finding->isRiskAccepted();
if (! $exception instanceof FindingException) {
return $findingIsRiskAccepted
? 'risk_accepted_without_valid_exception'
: 'ungoverned';
}
if (! $findingIsRiskAccepted) {
return $exception->isPending()
? 'pending_exception'
: 'ungoverned';
}
if ($exception->isPendingRenewal()) {
return match ($this->resolveApprovedValidityState($exception, $now)) {
FindingException::VALIDITY_VALID => 'valid_exception',
FindingException::VALIDITY_EXPIRING => 'expiring_exception',
FindingException::VALIDITY_EXPIRED => 'expired_exception',
default => 'pending_exception',
};
}
return match ($this->resolveExceptionStatus($exception, $now)) {
FindingException::STATUS_PENDING => 'pending_exception',
FindingException::STATUS_ACTIVE => 'valid_exception',
FindingException::STATUS_EXPIRING => 'expiring_exception',
FindingException::STATUS_EXPIRED => 'expired_exception',
FindingException::STATUS_REVOKED => 'revoked_exception',
FindingException::STATUS_REJECTED => 'rejected_exception',
default => $findingIsRiskAccepted
? 'risk_accepted_without_valid_exception'
: 'ungoverned',
};
}
public function isValidGovernedAcceptedRisk(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): bool
{
return in_array($this->resolveFindingState($finding, $exception, $now), [
'valid_exception',
'expiring_exception',
], true);
}
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
{
$exception ??= $finding->relationLoaded('findingException')
? $finding->findingException
: $finding->findingException()->first();
if (! $exception instanceof FindingException) {
return $finding->isRiskAccepted()
? 'This finding is marked as accepted risk without a valid exception record.'
: null;
}
$exceptionStatus = $exception->isPendingRenewal()
? match ($this->resolveApprovedValidityState($exception, $now)) {
FindingException::VALIDITY_EXPIRED => FindingException::STATUS_EXPIRED,
FindingException::VALIDITY_EXPIRING => FindingException::STATUS_EXPIRING,
FindingException::VALIDITY_VALID => FindingException::STATUS_ACTIVE,
default => FindingException::STATUS_PENDING,
}
: $this->resolveExceptionStatus($exception, $now);
if ($finding->isRiskAccepted()) {
return match ($this->resolveFindingState($finding, $exception, $now)) {
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
default => null,
};
}
if ($exception->requiresFreshDecisionForFinding($finding)) {
return 'This finding changed after the earlier exception decision; a fresh decision is required.';
}
return match ($exceptionStatus) {
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
default => null,
};
}
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
{
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
$resolvedValidityState = $this->resolveValidityState($exception, $now);
if ((string) $exception->status === $resolvedStatus && (string) $exception->current_validity_state === $resolvedValidityState) {
return $exception;
}
$exception->forceFill([
'status' => $resolvedStatus,
'current_validity_state' => $resolvedValidityState,
])->save();
return $exception->refresh();
}
private function resolveApprovedValidityState(FindingException $exception, ?CarbonImmutable $now = null): string
{
$now ??= CarbonImmutable::instance(now());
$expiresAt = $this->renewalAwareDate(
$exception,
'previous_expires_at',
$exception->expires_at,
);
if ($expiresAt instanceof CarbonImmutable && $expiresAt->lessThanOrEqualTo($now)) {
return FindingException::VALIDITY_EXPIRED;
}
if ($this->isExpiring($exception, $now, renewalAware: true)) {
return FindingException::VALIDITY_EXPIRING;
}
return FindingException::VALIDITY_VALID;
}
private function isExpiring(FindingException $exception, CarbonImmutable $now, bool $renewalAware = false): bool
{
$reviewDueAt = $renewalAware
? $this->renewalAwareDate($exception, 'previous_review_due_at', $exception->review_due_at)
: ($exception->review_due_at instanceof Carbon ? CarbonImmutable::instance($exception->review_due_at) : null);
if ($reviewDueAt instanceof CarbonImmutable && $reviewDueAt->lessThanOrEqualTo($now)) {
return true;
}
$expiresAt = $renewalAware
? $this->renewalAwareDate($exception, 'previous_expires_at', $exception->expires_at)
: ($exception->expires_at instanceof Carbon ? CarbonImmutable::instance($exception->expires_at) : null);
if (! $expiresAt instanceof CarbonImmutable) {
return false;
}
return $expiresAt->lessThanOrEqualTo($now->addDays(7));
}
private function renewalAwareDate(FindingException $exception, string $metadataKey, mixed $fallback): ?CarbonImmutable
{
$currentDecision = $exception->relationLoaded('currentDecision')
? $exception->currentDecision
: $exception->currentDecision()->first();
if ($currentDecision instanceof FindingExceptionDecision && is_string($currentDecision->metadata[$metadataKey] ?? null)) {
return CarbonImmutable::parse((string) $currentDecision->metadata[$metadataKey]);
}
return $fallback instanceof Carbon
? CarbonImmutable::instance($fallback)
: null;
}
}

View File

@ -189,6 +189,22 @@ public function riskAccept(Finding $finding, Tenant $tenant, User $actor, string
{
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RISK_ACCEPT]);
return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason);
}
public function riskAcceptFromException(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{
$this->assertFindingOwnedByTenant($finding, $tenant);
return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason);
}
private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{
if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RISK_ACCEPTED) {
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
}
$reason = $this->validatedReason($reason, 'closed_reason');
$now = CarbonImmutable::now();

View File

@ -71,6 +71,9 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
'status' => ReviewPackStatus::Queued->value,
'options' => $options,
'summary' => [
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
? $snapshot->summary['risk_acceptance']
: [],
'evidence_resolution' => [
'outcome' => 'resolved',
'snapshot_id' => (int) $snapshot->getKey(),

View File

@ -79,6 +79,12 @@ enum AuditActionId: string
case FindingClosed = 'finding.closed';
case FindingRiskAccepted = 'finding.risk_accepted';
case FindingReopened = 'finding.reopened';
case FindingExceptionRequested = 'finding_exception.requested';
case FindingExceptionApproved = 'finding_exception.approved';
case FindingExceptionRejected = 'finding_exception.rejected';
case FindingExceptionRenewalRequested = 'finding_exception.renewal_requested';
case FindingExceptionRenewed = 'finding_exception.renewed';
case FindingExceptionRevoked = 'finding_exception.revoked';
case EvidenceSnapshotCreated = 'evidence_snapshot.created';
case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed';
@ -201,6 +207,12 @@ private static function labels(): array
self::FindingClosed->value => 'Finding closed',
self::FindingRiskAccepted->value => 'Finding risk accepted',
self::FindingReopened->value => 'Finding reopened',
self::FindingExceptionRequested->value => 'Finding exception requested',
self::FindingExceptionApproved->value => 'Finding exception approved',
self::FindingExceptionRejected->value => 'Finding exception rejected',
self::FindingExceptionRenewalRequested->value => 'Finding exception renewal requested',
self::FindingExceptionRenewed->value => 'Finding exception renewed',
self::FindingExceptionRevoked->value => 'Finding exception revoked',
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
@ -269,6 +281,12 @@ private static function summaries(): array
self::FindingClosed->value => 'Finding closed',
self::FindingRiskAccepted->value => 'Finding risk accepted',
self::FindingReopened->value => 'Finding reopened',
self::FindingExceptionRequested->value => 'Finding exception requested',
self::FindingExceptionApproved->value => 'Finding exception approved',
self::FindingExceptionRejected->value => 'Finding exception rejected',
self::FindingExceptionRenewalRequested->value => 'Finding exception renewal requested',
self::FindingExceptionRenewed->value => 'Finding exception renewed',
self::FindingExceptionRevoked->value => 'Finding exception revoked',
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',

View File

@ -87,6 +87,12 @@ class Capabilities
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
public const FINDING_EXCEPTION_VIEW = 'finding_exception.view';
public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage';
public const FINDING_EXCEPTION_APPROVE = 'finding_exception.approve';
// Verification
public const TENANT_VERIFICATION_ACKNOWLEDGE = 'tenant_verification.acknowledge';

View File

@ -24,6 +24,8 @@ final class BadgeCatalog
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class,
BadgeDomain::FindingSeverity->value => Domains\FindingSeverityBadge::class,
BadgeDomain::FindingExceptionStatus->value => Domains\FindingExceptionStatusBadge::class,
BadgeDomain::FindingRiskGovernanceValidity->value => Domains\FindingRiskGovernanceValidityBadge::class,
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,

View File

@ -15,6 +15,8 @@ enum BadgeDomain: string
case RestoreCheckSeverity = 'restore_check_severity';
case FindingStatus = 'finding_status';
case FindingSeverity = 'finding_severity';
case FindingExceptionStatus = 'finding_exception_status';
case FindingRiskGovernanceValidity = 'finding_risk_governance_validity';
case BooleanEnabled = 'boolean_enabled';
case BooleanHasErrors = 'boolean_has_errors';
case TenantStatus = 'tenant_status';

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Models\FindingException;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class FindingExceptionStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
FindingException::STATUS_PENDING => new BadgeSpec('Pending', 'warning', 'heroicon-o-clock'),
FindingException::STATUS_ACTIVE => new BadgeSpec('Active', 'success', 'heroicon-o-shield-check'),
FindingException::STATUS_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
FindingException::STATUS_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
FindingException::STATUS_REJECTED => new BadgeSpec('Rejected', 'gray', 'heroicon-o-x-circle'),
FindingException::STATUS_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
FindingException::STATUS_SUPERSEDED => new BadgeSpec('Superseded', 'gray', 'heroicon-o-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Models\FindingException;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class FindingRiskGovernanceValidityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
FindingException::VALIDITY_VALID => new BadgeSpec('Valid', 'success', 'heroicon-o-check-badge'),
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'gray', 'heroicon-o-x-circle'),
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'gray', 'heroicon-o-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -6,6 +6,7 @@
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorType;
use App\Support\Audit\AuditOutcome;
@ -129,6 +130,37 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true):
];
}
/**
* @return array<string, string>
*/
public static function findingExceptionStatuses(): array
{
return self::badgeOptions(BadgeDomain::FindingExceptionStatus, [
FindingException::STATUS_PENDING,
FindingException::STATUS_ACTIVE,
FindingException::STATUS_EXPIRING,
FindingException::STATUS_EXPIRED,
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
]);
}
/**
* @return array<string, string>
*/
public static function findingExceptionValidityStates(): array
{
return self::badgeOptions(BadgeDomain::FindingRiskGovernanceValidity, [
FindingException::VALIDITY_VALID,
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_REVOKED,
FindingException::VALIDITY_REJECTED,
FindingException::VALIDITY_MISSING_SUPPORT,
]);
}
/**
* @param iterable<mixed>|null $types
* @return array<string, string>

View File

@ -9,6 +9,7 @@
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\RestoreRunResource;
@ -18,6 +19,7 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
@ -209,6 +211,15 @@ public function auditTargetLink(AuditLog $record): ?array
->exists()
? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
: null,
'finding_exception' => $tenant instanceof Tenant
&& $this->capabilityResolver->isMember($user, $tenant)
&& $this->capabilityResolver->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW)
&& ($findingException = FindingException::query()
->whereKey($resourceId)
->where('tenant_id', (int) $tenant->getKey())
->first()) instanceof FindingException
? ['label' => 'Open finding exception', 'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], panel: 'tenant', tenant: $tenant)]
: null,
default => null,
};
}

View File

@ -8,6 +8,7 @@
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
@ -18,6 +19,7 @@
use App\Models\EntraGroup;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
@ -100,6 +102,16 @@ public static function firstSlice(): array
'action_surface_reason' => 'FindingResource declares its action surface contract directly.',
'notes' => 'Findings are not part of global search in the first slice.',
],
'FindingException' => [
'table' => 'finding_exceptions',
'model' => FindingException::class,
'resource' => FindingExceptionResource::class,
'tenant_relationship' => 'tenant',
'search_posture' => 'disabled',
'action_surface' => 'declared',
'action_surface_reason' => 'FindingExceptionResource declares its action surface contract directly.',
'notes' => 'Finding exceptions stay off global search in the first rollout.',
],
'EvidenceSnapshot' => [
'table' => 'evidence_snapshots',
'model' => EvidenceSnapshot::class,
@ -159,6 +171,16 @@ public static function residualRolloutInventory(): array
'likely_surface' => 'Permissions and onboarding diagnostics surfaces',
'why_not_in_first_slice' => 'Permission posture is enforced through dedicated diagnostics and onboarding flows, not a first-slice primary resource.',
],
'FindingExceptionDecision' => [
'table' => 'finding_exception_decisions',
'likely_surface' => 'FindingExceptionResource decision history entries',
'why_not_in_first_slice' => 'Decision history is subordinate to the finding exception aggregate instead of a standalone primary resource.',
],
'FindingExceptionEvidenceReference' => [
'table' => 'finding_exception_evidence_references',
'likely_surface' => 'FindingExceptionResource evidence sections',
'why_not_in_first_slice' => 'Evidence references are subordinate support records rendered inside finding exception detail.',
],
];
}

View File

@ -31,6 +31,7 @@ public static function firstSlice(): array
'backup_sets',
'restore_runs',
'findings',
'finding_exceptions',
'evidence_snapshots',
'inventory_items',
'entra_groups',
@ -47,6 +48,8 @@ public static function residual(): array
'inventory_links',
'entra_role_definitions',
'tenant_permissions',
'finding_exception_decisions',
'finding_exception_evidence_references',
];
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('finding_exceptions', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('finding_id')->constrained('findings')->cascadeOnDelete();
$table->foreignId('requested_by_user_id')->constrained('users');
$table->foreignId('owner_user_id')->nullable()->constrained('users');
$table->foreignId('approved_by_user_id')->nullable()->constrained('users');
$table->unsignedBigInteger('current_decision_id')->nullable();
$table->string('status')->default('pending');
$table->string('current_validity_state')->default('missing_support');
$table->text('request_reason');
$table->text('approval_reason')->nullable();
$table->text('rejection_reason')->nullable();
$table->text('revocation_reason')->nullable();
$table->timestampTz('requested_at')->nullable();
$table->timestampTz('approved_at')->nullable();
$table->timestampTz('rejected_at')->nullable();
$table->timestampTz('revoked_at')->nullable();
$table->timestampTz('effective_from')->nullable();
$table->timestampTz('expires_at')->nullable();
$table->timestampTz('review_due_at')->nullable();
$table->jsonb('evidence_summary')->default('{}');
$table->timestamps();
$table->unique('finding_id');
$table->index(['workspace_id', 'status', 'review_due_at'], 'finding_exceptions_workspace_status_review_idx');
$table->index(['workspace_id', 'status', 'expires_at'], 'finding_exceptions_workspace_status_expiry_idx');
$table->index(['workspace_id', 'tenant_id', 'status'], 'finding_exceptions_tenant_status_idx');
$table->index(['tenant_id', 'finding_id'], 'finding_exceptions_finding_lookup_idx');
$table->index(['tenant_id', 'requested_at'], 'finding_exceptions_requested_at_idx');
$table
->foreign(['tenant_id', 'workspace_id'], 'finding_exceptions_tenant_workspace_fk')
->references(['id', 'workspace_id'])
->on('tenants')
->cascadeOnDelete();
});
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX finding_exceptions_evidence_summary_gin ON finding_exceptions USING GIN (evidence_summary)');
}
}
public function down(): void
{
Schema::dropIfExists('finding_exceptions');
}
};

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('finding_exception_decisions', function (Blueprint $table): void {
$table->id();
$table->foreignId('finding_exception_id')->constrained('finding_exceptions')->cascadeOnDelete();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('actor_user_id')->constrained('users');
$table->string('decision_type');
$table->text('reason')->nullable();
$table->timestampTz('effective_from')->nullable();
$table->timestampTz('expires_at')->nullable();
$table->jsonb('metadata')->default('{}');
$table->timestampTz('decided_at');
$table->timestamps();
$table->index(['finding_exception_id', 'decided_at'], 'finding_exception_decisions_history_idx');
$table->index(['workspace_id', 'tenant_id', 'decision_type'], 'finding_exception_decisions_scope_type_idx');
$table->index(['tenant_id', 'actor_user_id'], 'finding_exception_decisions_actor_idx');
$table
->foreign(['tenant_id', 'workspace_id'], 'finding_exception_decisions_tenant_workspace_fk')
->references(['id', 'workspace_id'])
->on('tenants')
->cascadeOnDelete();
});
Schema::table('finding_exceptions', function (Blueprint $table): void {
$table
->foreign('current_decision_id')
->references('id')
->on('finding_exception_decisions')
->nullOnDelete();
});
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX finding_exception_decisions_metadata_gin ON finding_exception_decisions USING GIN (metadata)');
}
}
public function down(): void
{
Schema::table('finding_exceptions', function (Blueprint $table): void {
$table->dropForeign(['current_decision_id']);
});
Schema::dropIfExists('finding_exception_decisions');
}
};

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('finding_exception_evidence_references', function (Blueprint $table): void {
$table->id();
$table->foreignId('finding_exception_id')->constrained('finding_exceptions')->cascadeOnDelete();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('source_type');
$table->string('source_id')->nullable();
$table->string('source_fingerprint')->nullable();
$table->string('label');
$table->jsonb('summary_payload')->default('{}');
$table->timestampTz('measured_at')->nullable();
$table->timestamps();
$table->index(['finding_exception_id', 'source_type'], 'finding_exception_evidence_refs_parent_idx');
$table->index(['workspace_id', 'tenant_id'], 'finding_exception_evidence_refs_scope_idx');
$table->index(['tenant_id', 'source_type'], 'finding_exception_evidence_refs_source_idx');
$table
->foreign(['tenant_id', 'workspace_id'], 'finding_exception_evidence_refs_tenant_workspace_fk')
->references(['id', 'workspace_id'])
->on('tenants')
->cascadeOnDelete();
});
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX finding_exception_evidence_refs_payload_gin ON finding_exception_evidence_references USING GIN (summary_payload)');
}
}
public function down(): void
{
Schema::dropIfExists('finding_exception_evidence_references');
}
};

View File

@ -0,0 +1,128 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Canonical risk-acceptance approvals
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Review pending exception requests across entitled tenants without leaving the Monitoring area.
</div>
</div>
</x-filament::section>
{{ $this->table }}
@php
$selectedException = $this->selectedFindingException();
@endphp
@if ($selectedException)
<x-filament::section
:heading="'Finding exception #'.$selectedException->getKey()"
:description="$selectedException->requested_at?->toDayDateTimeString()"
>
<div class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Status
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingExceptionStatus)($selectedException->status) }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Scope
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $selectedException->tenant?->name ?? 'Unknown tenant' }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Finding #{{ $selectedException->finding_id }}
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Review timing
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
Review due {{ $selectedException->review_due_at?->toDayDateTimeString() ?? '—' }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Expires {{ $selectedException->expires_at?->toDayDateTimeString() ?? '—' }}
</div>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Request
</div>
<dl class="mt-3 space-y-3">
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Requested by
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->requester?->name ?? 'Unknown requester' }}
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Owner
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->owner?->name ?? 'Unassigned' }}
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Reason
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->request_reason }}
</dd>
</div>
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Decision history
</div>
@if ($selectedException->decisions->isEmpty())
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
No decisions have been recorded yet.
</div>
@else
<div class="mt-3 space-y-3">
@foreach ($selectedException->decisions as $decision)
<div class="rounded-xl border border-gray-200 px-3 py-3 dark:border-gray-800">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ucfirst(str_replace('_', ' ', $decision->decision_type)) }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $decision->actor?->name ?? 'Unknown actor' }} · {{ $decision->decided_at?->toDayDateTimeString() ?? '—' }}
</div>
@if (filled($decision->reason))
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{{ $decision->reason }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>
</x-filament::section>
@endif
</x-filament-panels::page>

View File

@ -0,0 +1,126 @@
# Implementation Plan: Finding Risk Acceptance Lifecycle
**Branch**: `001-finding-risk-acceptance` | **Date**: 2026-03-19 | **Spec**: [/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-finding-risk-acceptance/spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-finding-risk-acceptance/spec.md)
**Input**: Feature specification from `/specs/001-finding-risk-acceptance/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce a first-class tenant-owned Finding Exception domain that governs formal risk acceptance for findings instead of relying on a bare `risk_accepted` status and freeform reason field. The implementation adds dedicated exception and exception-decision records, tenant-scoped request and detail surfaces, a canonical workspace approval queue, centralized validity semantics, audit coverage for every lifecycle mutation, and explicit downstream contracts so evidence and reporting flows can distinguish valid governed exceptions from expired, revoked, rejected, or missing ones.
The implementation keeps Findings as the system of record for the underlying issue, uses the existing `FindingWorkflowService` as the only path that can transition a finding into or out of `risk_accepted`, stores governance history in append-only decision records, and uses DB-backed tenant/workspace queries rather than a new `OperationRun` workflow for normal approval actions.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
**Storage**: PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata
**Testing**: Pest feature tests, Pest unit tests, and Livewire/Filament component tests
**Target Platform**: Laravel Sail web application on PostgreSQL
**Project Type**: Web application monolith
**Performance Goals**: Exception request, approval, rejection, renewal, and revocation remain synchronous DB-backed actions under 2 seconds; tenant and canonical exception lists remain DB-only at render time; expiring queue filters remain index-backed
**Constraints**: No Microsoft Graph calls; no new public API; one current valid active exception per finding at a time; approval history must remain append-only; normal workflow stays outside `OperationRun`; status-like UI uses centralized badge semantics
**Scale/Scope**: First rollout covers finding-specific exceptions only, tenant detail plus workspace approval queue, linked evidence references, validity-state evaluation, and downstream reuse by evidence/reporting consumers
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **Pre-Phase-0 Gate: PASS**
- Inventory-first: PASS. The feature governs findings and linked evidence already present in the product; it does not recollect or redefine source inventory.
- Read/write separation: PASS. Exception request, approval, rejection, renewal, and revocation are explicit governance writes with confirmation, audit coverage, and focused tests.
- Graph contract path: PASS. No Graph calls are introduced.
- Deterministic capabilities: PASS. New capabilities are added to the canonical registry and role maps and tested through existing capability resolver patterns.
- RBAC-UX / workspace / tenant isolation: PASS. Tenant exception records stay tenant-owned; the canonical workspace queue is query-only and entitlement-filtered; non-members remain 404 and in-scope capability denials remain 403.
- Global search: PASS. The first rollout does not require global-search exposure for exception records.
- Run observability: PASS with explicit exemption. Normal exception decisions are DB-only and expected to complete under 2 seconds, so they intentionally skip `OperationRun` and rely on audit history and surface state changes. No remote or long-running work is introduced.
- Ops-UX 3-surface feedback: PASS by non-applicability. No new `OperationRun`-driven operator workflow is introduced in v1.
- Ops-UX lifecycle / summary counts / system runs: PASS by non-applicability for the core decision paths.
- Data minimization: PASS. Exception records store bounded justification, structured evidence references, and sanitized audit context; no raw payloads or secrets are persisted.
- BADGE-001: PASS. New exception-state and validity-state badges are introduced via centralized badge domain entries and covered by tests.
- UI-NAMING-001: PASS. Operator-facing vocabulary remains `Request exception`, `Approve exception`, `Reject exception`, `Renew exception`, and `Revoke exception` with `risk acceptance` used for the governed outcome.
- Filament UI Action Surface Contract: PASS. Tenant finding detail, tenant exception register, canonical approval queue, and exception detail all use explicit inspection affordances, grouped actions, and confirmed destructive-like mutations.
- Filament UI UX-001: PASS. Detail surfaces are inspection-first Infolists; list surfaces expose search, sort, and filters; exception request and renewal use structured sections in modals or dedicated forms.
**Post-Phase-1 Re-check: PASS**
- The design keeps Findings as the underlying domain record, adds a tenant-owned governance layer without cross-tenant duplication, routes all status mutations through the existing workflow service, avoids unnecessary `OperationRun` usage, and preserves audit-first history for every decision path.
## Project Structure
### Documentation (this feature)
```text
specs/001-finding-risk-acceptance/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Monitoring/
│ └── Resources/
├── Models/
├── Policies/
├── Services/
│ ├── Audit/
│ ├── Auth/
│ ├── Evidence/
│ └── Findings/
└── Support/
├── Audit/
├── Auth/
├── Badges/
└── Rbac/
database/
└── migrations/
tests/
├── Feature/
│ ├── Findings/
│ ├── Monitoring/
│ └── Guards/
└── Unit/
├── Findings/
└── Support/
```
**Structure Decision**: Keep the existing Laravel monolith structure. Add new exception models and decision-history tables under `app/Models`, lifecycle orchestration under `app/Services/Findings`, authorization under `app/Policies`, and tenant/canonical Filament surfaces under `app/Filament`. Persist schema in `database/migrations` and cover behavior with focused Pest feature/unit tests in existing Findings, Monitoring, and guard suites.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
## Phase 0 — Research Output
- [research.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-finding-risk-acceptance/research.md)
## Phase 1 — Design Output
- [data-model.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-finding-risk-acceptance/data-model.md)
- [quickstart.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-finding-risk-acceptance/quickstart.md)
- [contracts/finding-risk-acceptance.openapi.yaml](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-finding-risk-acceptance/contracts/finding-risk-acceptance.openapi.yaml)
## Phase 2 — Implementation Planning
`tasks.md` should cover:
1. Schema creation for `finding_exceptions` and `finding_exception_decisions` with tenant/workspace ownership constraints, validity indexes, and evidence-reference metadata.
2. Capability registry and role-map updates for `finding_exception.view`, `finding_exception.manage`, and `finding_exception.approve` plus authorization policies for tenant and canonical views.
3. Service-layer orchestration that routes all accepted-risk status mutations through a new exception lifecycle service plus the existing `FindingWorkflowService`.
4. Filament tenant finding-detail, tenant exception register, canonical approval queue, and exception detail surfaces aligned with Action Surface and UX-001 rules.
5. Audit-log integration, badge-domain additions, and canonical related-navigation support.
6. Downstream validity-resolution hooks for evidence and reporting consumers that must distinguish valid governed exceptions from expired, revoked, rejected, or missing ones.
7. Focused Pest coverage for positive and negative authorization, invalid transitions, renewal/revocation history, wrong-tenant behavior, and canonical queue filtering.
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View File

@ -0,0 +1,203 @@
# Feature Specification: Finding Risk Acceptance Lifecycle
**Feature Branch**: `001-finding-risk-acceptance`
**Created**: 2026-03-19
**Status**: Draft
**Input**: User description: "Create a formal exception and risk acceptance workflow for findings with approval, expiry, renewal, audit trail, and evidence linkage."
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/t/{tenant}/findings/{finding}` as the tenant-context finding inspection surface where operators can review and initiate risk-acceptance requests
- `/admin/t/{tenant}/exceptions` as the tenant-scoped exception register for active, pending, expiring, expired, rejected, and revoked finding exceptions
- `/admin/exceptions` as the canonical workspace review and governance queue for authorized approvers and auditors
- Existing evidence and audit destinations remain drill-down targets from exception detail when the operator is entitled to inspect them
- **Data Ownership**:
- Tenant-owned: finding exception records, approval decisions, renewal decisions, expiry state, revocation state, and linked evidence references for one tenant's findings
- Workspace-owned but tenant-filtered: canonical review queue state, approval workload filters, and workspace-level summaries for expiring or overdue exceptions without changing tenant ownership of the exception itself
- Existing findings, evidence snapshots, review packs, and audit events remain separate systems of record and are referenced rather than duplicated
- **RBAC**:
- Workspace membership remains required for every exception workflow surface
- Tenant entitlement remains required to inspect or mutate tenant-scoped exception records
- `finding_exception.view` permits reviewing exception details within authorized scope
- `finding_exception.manage` permits creating requests, renewing requests, attaching justification and evidence references, and revoking exceptions where policy allows
- `finding_exception.approve` permits approving or rejecting requests and renewals within authorized scope
- Non-members or users outside the relevant workspace or tenant scope remain deny-as-not-found, while in-scope members lacking the required capability remain forbidden
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When an operator navigates from a tenant finding into the shared exceptions queue, the canonical workspace view opens with that tenant prefiltered. The operator may clear or change the filter only within their authorized tenant set.
- **Explicit entitlement checks preventing cross-tenant leakage**: Exception queries, counts, approver queues, filter options, related finding labels, and linked evidence references must be assembled only after workspace and tenant entitlement checks. Unauthorized users must not learn whether another tenant has pending, active, expiring, or expired exceptions.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Propose and approve a time-bounded risk acceptance (Priority: P1)
As a tenant manager, I want to request a formal risk acceptance for a finding and route it for approval, so that a risk decision becomes explicit, reviewable, and time-bounded instead of being hidden behind a status flag.
**Why this priority**: This is the core governance gap. Without a first-class request and approval flow, the product still cannot answer who accepted a risk, why, and until when.
**Independent Test**: Can be fully tested by creating a finding, submitting a risk-acceptance request with justification and review date, approving it as an authorized approver, and verifying that the finding becomes governed by a valid active exception.
**Acceptance Scenarios**:
1. **Given** a finding is open and no active exception exists, **When** an authorized operator submits a risk-acceptance request with justification, owner, and review deadline, **Then** the system creates a pending exception request linked to that finding.
2. **Given** a pending exception request exists, **When** an authorized approver approves it, **Then** the exception becomes active with a recorded approver, decision time, and expiry date.
3. **Given** a pending exception request exists, **When** an authorized approver rejects it, **Then** the request records the rejection outcome and reason without changing the finding into an accepted-risk state.
4. **Given** a user lacks the relevant capability or tenant entitlement, **When** they attempt to create or approve an exception request, **Then** the server denies the action with the correct 404 or 403 behavior.
---
### User Story 2 - See whether accepted risk is still valid (Priority: P1)
As an auditor or workspace approver, I want a clear register of pending, active, expiring, expired, rejected, and revoked exceptions, so that I can tell which accepted risks are still valid and which require action.
**Why this priority**: A risk-acceptance workflow is only governable if operators can review its current state without reconstructing history from comments and status changes.
**Independent Test**: Can be fully tested by creating exception records in several lifecycle states and verifying that tenant and canonical views expose the correct state, dates, owners, and next-action cues without leaking unauthorized tenant data.
**Acceptance Scenarios**:
1. **Given** a tenant has pending, active, and expired exceptions, **When** an authorized operator opens the tenant exception register, **Then** each exception clearly shows its lifecycle state, finding, owner, approver context, and review timing.
2. **Given** an approver is responsible for multiple tenants, **When** they open the canonical exceptions queue, **Then** they can filter by tenant, state, and due timing without seeing unauthorized tenants.
3. **Given** an active exception is nearing expiry, **When** an authorized operator inspects the register, **Then** the exception is visibly distinguished from long-valid exceptions.
4. **Given** no exception matches the current filters, **When** the operator opens the register, **Then** the empty state explains that no governed exceptions match and offers exactly one clear next action.
---
### User Story 3 - Renew or revoke an accepted risk with audit evidence (Priority: P2)
As a governance operator, I want to renew or revoke an existing accepted risk with a durable decision trail and linked evidence, so that exceptions stay current rather than becoming permanent silent waivers.
**Why this priority**: Time-bounded approval loses value if the product cannot handle renewal and revocation as first-class governance decisions.
**Independent Test**: Can be fully tested by renewing an active exception with new justification and evidence references, revoking another one, and verifying that lifecycle history, current validity, and audit trail remain intelligible.
**Acceptance Scenarios**:
1. **Given** an active exception is approaching expiry, **When** an authorized operator submits a renewal request with updated justification and supporting evidence references, **Then** the system records a new renewal decision path without rewriting the earlier decision.
2. **Given** a renewal request exists, **When** an authorized approver approves it, **Then** the active-validity window extends and the prior decision history remains visible.
3. **Given** an active exception is no longer acceptable, **When** an authorized operator revokes it with a reason, **Then** the exception becomes revoked and no longer counts as valid risk acceptance.
4. **Given** a linked evidence snapshot or supporting artifact later disappears from active views, **When** an operator reviews the exception history, **Then** the exception remains understandable from stored reference metadata.
---
### User Story 4 - Detect governance drift in accepted-risk findings (Priority: P2)
As a compliance-focused operator, I want the system to surface findings marked as accepted risk without a currently valid exception, so that governance drift is visible instead of silently undermining auditability.
**Why this priority**: The business risk is not just missing workflow. It is false confidence when a finding looks accepted even though its approval expired, was revoked, or never existed.
**Independent Test**: Can be fully tested by creating findings in accepted-risk status with valid, expired, revoked, and missing exception records and verifying that only truly valid exceptions count as accepted governance state.
**Acceptance Scenarios**:
1. **Given** a finding is marked as accepted risk and has a valid active exception, **When** the operator inspects it, **Then** the finding shows that the acceptance is governed and time-bounded.
2. **Given** a finding is marked as accepted risk but the linked exception is expired, revoked, or absent, **When** the operator inspects it or opens the exception queue, **Then** the system surfaces it as a governance warning rather than a valid accepted risk.
3. **Given** a downstream review or evidence workflow summarizes accepted risks, **When** it evaluates findings, **Then** only findings backed by a currently valid exception count as active risk acceptance.
### Edge Cases
- A finding is resolved or closed while an exception request is still pending; the request must not silently convert into an active accepted risk without an explicit decision.
- A finding remains in `risk_accepted` status after the governing exception expires or is revoked; the system must show that the risk state is no longer valid.
- An operator attempts to renew an exception that is already expired; the renewal path must remain explicit and must not overwrite the expired decision history.
- The same person requests and approves an exception; the system must either block self-approval in normal flow or record an explicit elevated-policy override when self-approval is allowed.
- A finding reopens through detection recurrence while a previous exception exists; the system must make it clear whether the earlier exception still governs the re-opened risk or whether a fresh decision is required.
- Evidence linked to an exception may be partial, stale, or later removed from active surfaces; the exception history must preserve enough reference context for review.
- A workspace approver can review multiple tenants, but must not see queue counts, labels, or filter values for unauthorized tenants.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces a new governance data model and new user-driven write behavior, but no new Microsoft Graph calls. Exception request, approval, renewal, rejection, expiry, and revocation are security-relevant DB-backed governance mutations and must be explicitly audited. The feature must define tenant isolation, approval safety, validity semantics, linked-evidence semantics, and tests for valid, expired, revoked, missing, and unauthorized paths. If scheduled reminder or expiry evaluation is introduced, it must describe how that work is observable and how it avoids cross-tenant leakage.
**Constitution alignment (OPS-UX):** The primary workflow is synchronous governance mutation and does not require a dedicated long-running `OperationRun` for request, approval, rejection, renewal, or revocation. These decisions must therefore be observable through audit history, surface state changes, and user notifications instead of an operation progress surface. If the product later adds scheduled reminder or expiry evaluation, that work may integrate with existing monitoring or alerting patterns, but the first release of this feature does not rely on a new operator-facing progress workflow.
**Constitution alignment (RBAC-UX):** This feature operates in the tenant/admin plane for tenant-scoped finding and exception surfaces and in the workspace-admin canonical view for the approval queue. Cross-plane access remains deny-as-not-found. Non-members or users outside workspace or tenant scope receive `404`. In-scope users lacking `finding_exception.view`, `finding_exception.manage`, or `finding_exception.approve` receive `403` according to the attempted action. Authorization must be enforced server-side for request creation, approval, rejection, renewal, revocation, and any canonical queue action. The canonical capability registry remains the only capability source. Destructive-like actions such as revoke and reject require confirmation.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
**Constitution alignment (BADGE-001):** Exception lifecycle state, risk-governance validity, and due-timing indicators are status-like values and must use centralized badge semantics rather than per-page color choices. Tests must cover all introduced states such as pending, active, expiring, expired, rejected, and revoked.
**Constitution alignment (UI-NAMING-001):** The target object is the finding exception. Operator-facing verbs are `Request exception`, `Approve exception`, `Reject exception`, `Renew exception`, and `Revoke exception`. The term `risk acceptance` describes the governance outcome, while `exception` names the governed record. The same vocabulary must be preserved across finding detail, exception register, approval queue, audit prose, and notifications. Implementation-first terms such as `waiver row`, `approval token`, or `state machine` must not become primary labels.
**Constitution alignment (Filament Action Surfaces):** This feature modifies tenant finding detail and introduces exception list and detail inspection surfaces plus approval actions. The Action Surface Contract is satisfied if request and review actions are explicit, destructive-like actions require confirmation, list inspection uses a canonical inspect affordance, and every mutation is authorization-gated and audited.
**Constitution alignment (UX-001 — Layout & Information Architecture):** Exception list screens must provide search, sort, and filters for state, tenant, owner, approver, and expiry timing. Exception detail must be an inspection surface using Infolist-style composition rather than a disabled edit form. Creation and renewal may use a structured modal or dedicated form surface, but must keep justification, owner, timing, and evidence references grouped inside sections. Empty states must include a specific title, explanation, and exactly one CTA.
### Functional Requirements
- **FR-001**: The system MUST provide a first-class finding exception record that governs formal risk acceptance for a specific finding.
- **FR-002**: A finding exception MUST capture at minimum the target finding, requester, accountable owner, requested justification, requested decision time, and the bounded validity window for accepted risk.
- **FR-003**: The system MUST support an exception lifecycle that distinguishes at least pending, active, expiring, expired, rejected, revoked, and superseded or renewed states.
- **FR-004**: An operator MUST be able to request risk acceptance for a finding without directly bypassing the approval lifecycle.
- **FR-005**: The system MUST support explicit approval and explicit rejection of pending exception requests, with durable decision reason and actor history.
- **FR-006**: The system MUST support renewal of an existing exception as a new governance decision that preserves earlier request and approval history.
- **FR-007**: The system MUST support explicit revocation of an active exception, with recorded actor, time, and revocation reason.
- **FR-008**: The system MUST treat a finding as having valid accepted risk only while a currently valid active exception exists for that finding.
- **FR-009**: A finding in `risk_accepted` status without a currently valid exception MUST be surfaced as a governance warning rather than a fully governed accepted risk.
- **FR-010**: The feature MUST define whether one finding may have multiple historical exception records over time, while ensuring that only one current exception can govern the finding for a given validity window.
- **FR-011**: Exception requests and renewals MUST support structured supporting context, including freeform justification and one or more linked evidence references when available.
- **FR-012**: Evidence references linked to an exception MUST remain intelligible even if the live evidence artifact later expires, is superseded, or becomes inaccessible from normal active views.
- **FR-013**: The system MUST provide a tenant-scoped exception register that allows authorized operators to review current and historical exception records for that tenant.
- **FR-014**: The system MUST provide a canonical workspace approval and governance queue that allows authorized viewers to review pending, expiring, expired, rejected, and revoked exceptions across entitled tenants.
- **FR-015**: Tenant and canonical views MUST provide filters for lifecycle state, due timing, requester, owner, approver, and finding severity or type where relevant.
- **FR-016**: The system MUST make upcoming expiry and already-expired exceptions clearly visible so that time-bounded risk acceptance does not silently lapse.
- **FR-017**: The system MUST define reminder semantics for exceptions nearing expiry, including who needs visibility when action is required.
- **FR-018**: All exception lifecycle mutations must be recorded in audit history with workspace scope, tenant scope, actor, target finding context, action, outcome, and readable supporting context.
- **FR-019**: Exception audit records MUST be summary-first and MUST NOT store secrets, raw evidence payloads, or arbitrary oversized snapshots.
- **FR-020**: The system MUST enforce 404 deny-as-not-found behavior for non-members and out-of-scope users, and 403 behavior for in-scope users lacking the required capability.
- **FR-021**: The feature MUST define approval separation rules, including whether normal self-approval is blocked and how any exceptional override path is governed and auditable.
- **FR-022**: The feature MUST preserve intelligible history when a finding later resolves, closes, reopens, or changes severity after an exception decision.
- **FR-023**: Downstream review, evidence, and reporting workflows that summarize accepted risk MUST distinguish valid governed exceptions from expired, revoked, rejected, or missing ones.
- **FR-024**: The feature MUST introduce at least one positive and one negative authorization test for tenant-context request flows and canonical approval-queue flows.
- **FR-025**: The feature MUST introduce regression tests for pending, approved, rejected, renewed, revoked, expired, and missing-exception states, plus wrong-tenant and invalid-transition paths.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Finding Detail Risk Panel | Tenant-context finding inspection under `/admin/t/{tenant}/findings/{finding}` | `Request exception` (`finding_exception.manage`) when no valid exception exists | Linked exception summary card or explicit `View exception` affordance | `View exception`, `Request exception` or `Renew exception` depending on state | None | `Request first exception` when no governance record exists | `Renew exception`, `Revoke exception` when authorized | N/A | Yes | Action labels must describe the governance object, not just the finding status |
| Tenant Exception Register | Tenant-context list under `/admin/t/{tenant}/exceptions` | Contextual filters only | Clickable row to exception detail | `View exception`, `Renew exception` or `Revoke exception` depending on state | None in v1 | `Request first exception` | None | N/A | Yes | Inspection-first surface; no bulk approval in first slice |
| Canonical Exceptions Queue | Workspace canonical view at `/admin/exceptions` | Contextual filters only | Clickable row to exception detail | `Approve exception`, `Reject exception` for pending items | None in v1 | `Clear filters` | None | N/A | Yes | Queue must remain tenant-safe and only show entitled tenants |
| Exception Detail | Tenant or canonical detail inspection surface | None | N/A | None | None | N/A | `Approve exception`, `Reject exception`, `Renew exception`, `Revoke exception` depending on state and capability | N/A | Yes | Detail is an inspection surface, not a disabled edit form |
### Key Entities *(include if feature involves data)*
- **Finding Exception**: A governed risk-acceptance record for one finding, including request context, decision state, validity timing, and current governance outcome.
- **Exception Decision**: A durable approval, rejection, renewal, or revocation record that explains who made the decision, when, and why.
- **Exception Evidence Reference**: A structured pointer to supporting evidence used to justify or review an exception, preserved as intelligible reference metadata.
- **Risk Governance Validity**: The normalized truth of whether a finding's accepted-risk posture is currently valid, expiring soon, expired, revoked, rejected, or unsupported.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An authorized operator can request and route a formal finding exception in under 3 minutes without leaving the product.
- **SC-002**: In automated tests, 100% of findings counted as valid accepted risk are backed by a currently valid active exception.
- **SC-003**: In acceptance review, an authorized auditor can answer who requested, who approved, why it was accepted, and until when it remains valid within 2 minutes using the product alone.
- **SC-004**: Expired, revoked, rejected, and missing-governance accepted-risk states are all distinguishable in automated regression coverage with no false classification as valid active acceptance.
- **SC-005**: Negative authorization tests prove that non-members or wrong-tenant users receive deny-as-not-found behavior and in-scope users without the required capability cannot request, approve, renew, or revoke exceptions.
- **SC-006**: Renewal and revocation flows preserve prior decision history in automated tests rather than overwriting the previous governance record.
## Assumptions
- Spec 111 remains the product source of truth for finding lifecycle and status semantics, including the existing `risk_accepted` status.
- Spec 134 remains the source of truth for canonical audit readability and event history behavior.
- Evidence linkage may reference evidence snapshots, review artifacts, or other governance evidence when available, but the exception lifecycle must not be blocked merely because evidence is partial.
- Normal approval flow should not rely on silent self-approval; any permitted override path must be explicit and auditable.
- The first rollout focuses on finding-specific exceptions, not a generic cross-domain waiver engine.
## Non-Goals
- Replacing the existing findings workflow with a different status model
- Creating a generic exception platform for every future domain in the first slice
- Suppressing or deleting findings automatically when risk is accepted
- Making legal or certification claims about compliance acceptance
- Replacing evidence snapshots, review packs, or the broader audit foundation with exception-owned storage
## Dependencies
- Findings workflow semantics and lifecycle rules from `specs/111-findings-workflow-sla/spec.md`
- Audit history foundation and event readability rules from `specs/134-audit-log-foundation/spec.md`
- Evidence-domain linkage patterns from `specs/153-evidence-domain-foundation/spec.md` when evidence snapshots are available

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Finding Risk Acceptance Lifecycle
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-19
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/154-finding-risk-acceptance/spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed against the draft on 2026-03-19.
- The spec is intentionally bounded to finding-specific exceptions and does not attempt to define a generic waiver engine.
- Approval separation is specified as a feature requirement so planning can decide the exact policy without weakening the governance scope.

View File

@ -0,0 +1,446 @@
openapi: 3.1.0
info:
title: Finding Risk Acceptance Internal Contract
version: 0.1.0
description: |
Internal admin-plane contract for the Finding Risk Acceptance Lifecycle.
These endpoints represent the server-side action contract backing Filament and Livewire surfaces.
servers:
- url: /api/internal
paths:
/tenants/{tenantId}/findings/{findingId}/exception-requests:
post:
summary: Request exception
operationId: requestFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FindingId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RequestExceptionInput'
responses:
'201':
description: Exception request created
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/tenants/{tenantId}/finding-exceptions:
get:
summary: List tenant exceptions
operationId: listTenantFindingExceptions
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/StateFilter'
- $ref: '#/components/parameters/DueFilter'
responses:
'200':
description: Tenant exception register
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/tenants/{tenantId}/finding-exceptions/{exceptionId}:
get:
summary: View exception detail
operationId: showFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
responses:
'200':
description: Exception detail
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionDetail'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/approve:
post:
summary: Approve exception
operationId: approveFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ApproveExceptionInput'
responses:
'200':
description: Exception approved
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/reject:
post:
summary: Reject exception
operationId: rejectFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RejectExceptionInput'
responses:
'200':
description: Exception rejected
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/renew:
post:
summary: Renew exception
operationId: renewFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RenewExceptionInput'
responses:
'200':
description: Renewal request accepted
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/revoke:
post:
summary: Revoke exception
operationId: revokeFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RevokeExceptionInput'
responses:
'200':
description: Exception revoked
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/workspaces/{workspaceId}/finding-exceptions/queue:
get:
summary: Canonical exception approval queue
operationId: listCanonicalFindingExceptionQueue
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/WorkspaceId'
- $ref: '#/components/parameters/StateFilter'
- $ref: '#/components/parameters/DueFilter'
- name: tenantId
in: query
schema:
type: integer
responses:
'200':
description: Canonical queue filtered to entitled tenants
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
components:
parameters:
TenantId:
name: tenantId
in: path
required: true
schema:
type: integer
FindingId:
name: findingId
in: path
required: true
schema:
type: integer
ExceptionId:
name: exceptionId
in: path
required: true
schema:
type: integer
WorkspaceId:
name: workspaceId
in: path
required: true
schema:
type: integer
StateFilter:
name: state
in: query
schema:
type: string
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
DueFilter:
name: due
in: query
schema:
type: string
enum: [all, expiring, expired]
responses:
Forbidden:
description: Member lacks required capability
NotFound:
description: Workspace or tenant scope is not entitled
ValidationError:
description: Input or transition validation failed
schemas:
FindingExceptionResource:
type: object
required:
- id
- tenant_id
- finding_id
- status
- validity_state
properties:
id:
type: integer
tenant_id:
type: integer
finding_id:
type: integer
status:
type: string
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
validity_state:
type: string
enum: [valid, expiring, expired, revoked, rejected, missing_support]
owner_user_id:
type: integer
nullable: true
requested_by_user_id:
type: integer
approved_by_user_id:
type: integer
nullable: true
requested_at:
type: string
format: date-time
approved_at:
type: string
format: date-time
nullable: true
expires_at:
type: string
format: date-time
nullable: true
review_due_at:
type: string
format: date-time
nullable: true
FindingExceptionDetail:
allOf:
- $ref: '#/components/schemas/FindingExceptionResource'
- type: object
properties:
request_reason:
type: string
decisions:
type: array
items:
$ref: '#/components/schemas/FindingExceptionDecision'
evidence_references:
type: array
items:
$ref: '#/components/schemas/FindingExceptionEvidenceReference'
FindingExceptionDecision:
type: object
required:
- decision_type
- actor_user_id
- decided_at
properties:
decision_type:
type: string
enum: [requested, approved, rejected, renewal_requested, renewed, revoked]
actor_user_id:
type: integer
reason:
type: string
nullable: true
effective_from:
type: string
format: date-time
nullable: true
expires_at:
type: string
format: date-time
nullable: true
decided_at:
type: string
format: date-time
FindingExceptionEvidenceReference:
type: object
required:
- source_type
- label
properties:
source_type:
type: string
source_id:
type: string
nullable: true
source_fingerprint:
type: string
nullable: true
label:
type: string
measured_at:
type: string
format: date-time
nullable: true
summary_payload:
type: object
additionalProperties: true
nullable: true
RequestExceptionInput:
type: object
required:
- owner_user_id
- request_reason
- review_due_at
properties:
owner_user_id:
type: integer
request_reason:
type: string
maxLength: 2000
review_due_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
nullable: true
evidence_references:
type: array
items:
$ref: '#/components/schemas/FindingExceptionEvidenceReference'
ApproveExceptionInput:
type: object
required:
- effective_from
- expires_at
properties:
effective_from:
type: string
format: date-time
expires_at:
type: string
format: date-time
approval_reason:
type: string
nullable: true
maxLength: 2000
RejectExceptionInput:
type: object
required:
- rejection_reason
properties:
rejection_reason:
type: string
maxLength: 2000
RenewExceptionInput:
type: object
required:
- request_reason
- review_due_at
properties:
request_reason:
type: string
maxLength: 2000
review_due_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
nullable: true
evidence_references:
type: array
items:
$ref: '#/components/schemas/FindingExceptionEvidenceReference'
RevokeExceptionInput:
type: object
required:
- revocation_reason
properties:
revocation_reason:
type: string
maxLength: 2000

View File

@ -0,0 +1,145 @@
# Data Model: Finding Risk Acceptance Lifecycle
## 1. FindingException
- **Purpose**: Tenant-owned governance aggregate that represents the current accepted-risk exception state for one finding.
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` NOT NULL).
- **Fields**:
- `id`
- `workspace_id`
- `tenant_id`
- `finding_id`
- `status` enum: `pending`, `active`, `expiring`, `expired`, `rejected`, `revoked`, `superseded`
- `requested_by_user_id`
- `owner_user_id`
- `approved_by_user_id` nullable
- `current_decision_id` nullable
- `request_reason` text
- `approval_reason` text nullable
- `rejection_reason` text nullable
- `revocation_reason` text nullable
- `requested_at`
- `approved_at` nullable
- `rejected_at` nullable
- `revoked_at` nullable
- `effective_from` nullable
- `expires_at` nullable
- `review_due_at` nullable
- `evidence_summary` JSONB nullable
- `current_validity_state` enum: `valid`, `expiring`, `expired`, `revoked`, `rejected`, `missing_support`
- `created_at`, `updated_at`
- **Relationships**:
- belongs to `Finding`
- belongs to `Tenant`
- belongs to `Workspace`
- belongs to requester `User`
- belongs to owner `User`
- belongs to approver `User`
- has many `FindingExceptionDecision`
- has many `FindingExceptionEvidenceReference`
- **Validation / invariants**:
- `workspace_id`, `tenant_id`, and `finding_id` are always required.
- `finding_id` must reference a finding in the same workspace and tenant.
- At most one current valid active exception may govern one finding at a time.
- `approved_by_user_id` must differ from `requested_by_user_id` in v1.
- `expires_at` must be after `effective_from` when both are present.
## 2. FindingExceptionDecision
- **Purpose**: Append-only historical record of every exception lifecycle decision.
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` NOT NULL).
- **Fields**:
- `id`
- `workspace_id`
- `tenant_id`
- `finding_exception_id`
- `decision_type` enum: `requested`, `approved`, `rejected`, `renewal_requested`, `renewed`, `revoked`
- `actor_user_id`
- `reason` text nullable
- `effective_from` nullable
- `expires_at` nullable
- `metadata` JSONB nullable
- `decided_at`
- `created_at`, `updated_at`
- **Relationships**:
- belongs to `FindingException`
- belongs to actor `User`
- **Validation / invariants**:
- Decision rows are append-only after creation.
- Decision type must be compatible with the parent exception's lifecycle state.
- Renewal decisions must not erase prior approval or rejection records.
## 3. FindingExceptionEvidenceReference
- **Purpose**: Structured pointer to evidence used to justify or review the exception.
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` NOT NULL).
- **Fields**:
- `id`
- `workspace_id`
- `tenant_id`
- `finding_exception_id`
- `source_type` string
- `source_id` string nullable
- `source_fingerprint` string nullable
- `label` string
- `summary_payload` JSONB nullable
- `measured_at` nullable
- `created_at`, `updated_at`
- **Relationships**:
- belongs to `FindingException`
- **Validation / invariants**:
- References must stay intelligible even if the live source artifact later expires or is removed from active views.
- `summary_payload` is bounded, sanitized, and not a raw payload dump.
## 4. Finding Risk Governance Projection
- **Purpose**: Derived truth used by finding detail, tenant exception lists, canonical queues, and downstream evidence/reporting consumers.
- **Derived from**:
- `Finding.status`
- `FindingException.status`
- exception validity window (`effective_from`, `expires_at`)
- current exception evidence support state
- **Values**:
- `ungoverned`
- `pending_exception`
- `valid_exception`
- `expiring_exception`
- `expired_exception`
- `revoked_exception`
- `rejected_exception`
- `risk_accepted_without_valid_exception`
- **Invariant**:
- Downstream consumers must use this projection, not finding status alone, when determining whether accepted risk is currently governed.
## State Transitions
### FindingException
- `pending` -> `active` on approval
- `pending` -> `rejected` on rejection
- `active` -> `expiring` when within reminder threshold
- `active|expiring` -> `expired` when `expires_at` passes
- `active|expiring` -> `revoked` on explicit revoke
- `active|expiring|expired` -> `superseded` when a renewal produces a newer governing decision under the same aggregate semantics
### FindingExceptionDecision
- `requested` always occurs first
- `approved` or `rejected` resolves a pending request
- `renewal_requested` may occur from `active`, `expiring`, or `expired`
- `renewed` extends a current or lapsed exception through a new decision
- `revoked` ends current validity explicitly
## Indexing and Query Needs
- Composite indexes on `(workspace_id, tenant_id, status)` for tenant register filtering.
- Composite indexes on `(workspace_id, status, review_due_at)` for canonical queue and expiring views.
- Unique partial index to prevent more than one current valid active exception per finding.
- Composite index on `(finding_id, tenant_id)` for finding-detail resolution.
## Relationship to Existing Domain Records
- `Finding` remains the system of record for the detected issue and status workflow.
- `FindingWorkflowService` remains the only allowed path for changing finding status.
- `AuditLog` remains the immutable historical event stream for every lifecycle mutation.
- `EvidenceSnapshot` and related artifacts remain separate systems of record referenced by exception evidence links.

View File

@ -0,0 +1,120 @@
# Implementation Plan: Finding Risk Acceptance Lifecycle
**Branch**: `154-finding-risk-acceptance` | **Date**: 2026-03-19 | **Spec**: [/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/154-finding-risk-acceptance/spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/154-finding-risk-acceptance/spec.md)
**Input**: Feature specification from `/specs/154-finding-risk-acceptance/spec.md`
## Summary
Introduce a first-class tenant-owned Finding Exception domain that governs formal risk acceptance for findings instead of relying on a bare `risk_accepted` status and freeform reason field. The implementation adds dedicated exception and exception-decision records, tenant-scoped request and detail surfaces, a canonical workspace approval queue, centralized validity semantics, audit coverage for every lifecycle mutation, and explicit downstream contracts so evidence and reporting flows can distinguish valid governed exceptions from expired, revoked, rejected, or missing ones.
The implementation keeps Findings as the system of record for the underlying issue, uses the existing `FindingWorkflowService` as the only path that can transition a finding into or out of `risk_accepted`, stores governance history in append-only decision records, and uses DB-backed tenant/workspace queries rather than a new `OperationRun` workflow for normal approval actions.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
**Storage**: PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata
**Testing**: Pest feature tests, Pest unit tests, and Livewire/Filament component tests
**Target Platform**: Laravel Sail web application on PostgreSQL
**Project Type**: Web application monolith
**Performance Goals**: Exception request, approval, rejection, renewal, and revocation remain synchronous DB-backed actions under 2 seconds; tenant and canonical exception lists remain DB-only at render time; expiring queue filters remain index-backed
**Constraints**: No Microsoft Graph calls; no new public API; one current valid active exception per finding at a time; no parallel pending request or renewal workflows for the same finding; self-approval is blocked in v1 with no override path; approval history must remain append-only; reminder semantics stay passive and in-product only for v1; normal workflow stays outside `OperationRun`; status-like UI uses centralized badge semantics
**Scale/Scope**: First rollout covers finding-specific exceptions only, tenant detail plus workspace approval queue, linked evidence references, validity-state evaluation, and downstream reuse by evidence/reporting consumers
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **Pre-Phase-0 Gate: PASS**
- Inventory-first: PASS. The feature governs findings and linked evidence already present in the product; it does not recollect or redefine source inventory.
- Read/write separation: PASS. Exception request, approval, rejection, renewal, and revocation are explicit governance writes with confirmation, audit coverage, and focused tests.
- Graph contract path: PASS. No Graph calls are introduced.
- Deterministic capabilities: PASS. New capabilities are added to the canonical registry and role maps and tested through existing capability resolver patterns.
- RBAC-UX / workspace / tenant isolation: PASS. Tenant exception records stay tenant-owned; the canonical workspace queue is query-only and entitlement-filtered; non-members remain 404 and in-scope capability denials remain 403.
- Global search: PASS. The first rollout does not require global-search exposure for exception records.
- Run observability: PASS with explicit exemption. Normal exception decisions are DB-only and expected to complete under 2 seconds, so they intentionally skip `OperationRun` and rely on audit history and surface state changes. No remote or long-running work is introduced.
- Ops-UX 3-surface feedback: PASS by non-applicability. No new `OperationRun`-driven operator workflow is introduced in v1.
- Ops-UX lifecycle / summary counts / system runs: PASS by non-applicability for the core decision paths.
- Data minimization: PASS. Exception records store bounded justification, structured evidence references, and sanitized audit context; no raw payloads or secrets are persisted.
- BADGE-001: PASS. New exception-state and validity-state badges are introduced via centralized badge domain entries and covered by tests.
- UI-NAMING-001: PASS. Operator-facing vocabulary remains `Request exception`, `Approve exception`, `Reject exception`, `Renew exception`, and `Revoke exception` with `risk acceptance` used for the governed outcome.
- Filament UI Action Surface Contract: PASS with explicit exemption. Tenant finding detail, tenant exception register, canonical approval queue, and exception detail all use explicit inspection affordances and confirmed destructive-like mutations; the two list surfaces intentionally defer bulk actions in v1 because exception decisions require per-record review, confirmation, and audit context.
- Filament UI UX-001: PASS. Detail surfaces are inspection-first Infolists; list surfaces expose search, sort, filters, and passive expiring-state reminder cues; exception request and renewal use structured sections in modals or dedicated forms.
**Post-Phase-1 Re-check: PASS**
- The design keeps Findings as the underlying domain record, adds a tenant-owned governance layer without cross-tenant duplication, routes all status mutations through the existing workflow service, avoids unnecessary `OperationRun` usage, and preserves audit-first history for every decision path.
## Project Structure
### Documentation (this feature)
```text
specs/154-finding-risk-acceptance/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Monitoring/
│ └── Resources/
├── Models/
├── Policies/
├── Services/
│ ├── Audit/
│ ├── Auth/
│ ├── Evidence/
│ └── Findings/
└── Support/
├── Audit/
├── Auth/
├── Badges/
└── Rbac/
database/
└── migrations/
tests/
├── Feature/
│ ├── Findings/
│ ├── Monitoring/
│ └── Guards/
└── Unit/
├── Findings/
└── Support/
```
**Structure Decision**: Keep the existing Laravel monolith structure. Add new exception models and decision-history tables under `app/Models`, lifecycle orchestration under `app/Services/Findings`, authorization under `app/Policies`, and tenant/canonical Filament surfaces under `app/Filament`. Persist schema in `database/migrations` and cover behavior with focused Pest feature/unit tests in existing Findings, Monitoring, and guard suites.
## Complexity Tracking
No constitution violations require justification. The only deviation is the documented v1 exemption from list-surface bulk actions for governance safety.
## Phase 0 — Research Output
- [research.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/154-finding-risk-acceptance/research.md)
## Phase 1 — Design Output
- [data-model.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/154-finding-risk-acceptance/data-model.md)
- [quickstart.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/154-finding-risk-acceptance/quickstart.md)
- [contracts/finding-risk-acceptance.openapi.yaml](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/154-finding-risk-acceptance/contracts/finding-risk-acceptance.openapi.yaml)
## Phase 2 — Implementation Planning
The implementation task plan covers these execution slices:
1. Schema creation for `finding_exceptions` and `finding_exception_decisions` with tenant/workspace ownership constraints, validity indexes, and evidence-reference metadata.
2. Capability registry and role-map updates for `finding_exception.view`, `finding_exception.manage`, and `finding_exception.approve` plus authorization policies for tenant and canonical views.
3. Service-layer orchestration that routes all accepted-risk status mutations through a new exception lifecycle service plus the existing `FindingWorkflowService`.
4. Filament tenant finding-detail, tenant exception register, canonical approval queue, and exception detail surfaces aligned with Action Surface and UX-001 rules.
5. Audit-log integration, badge-domain additions, and canonical related-navigation support.
6. Passive reminder visibility semantics for expiring exceptions on tenant and canonical surfaces without introducing scheduled jobs or external notifications.
7. Downstream validity-resolution hooks for evidence and reporting consumers that must distinguish valid governed exceptions from expired, revoked, rejected, or missing ones.
8. Focused Pest coverage for positive and negative authorization, blocked self-approval, invalid transitions, overlapping-request rejection, renewal/revocation history, finding lifecycle changes after exception decisions, wrong-tenant behavior, and canonical queue filtering.

View File

@ -0,0 +1,87 @@
# Quickstart: Finding Risk Acceptance Lifecycle
## Goal
Verify that the product can govern accepted-risk findings with explicit request, approval, expiry, renewal, revocation, and audit semantics.
## Prerequisites
1. Start Sail and ensure the application database is available.
2. Have at least one workspace, one tenant in that workspace, and at least one finding visible in tenant context.
3. Use two distinct tenant members for validation:
- requester with `finding_exception.manage`
- approver with `finding_exception.approve`
4. Use a seeded local Sail dataset with one workspace, one tenant, one eligible open finding, and one active exception near expiry for reminder validation.
## Happy Path
1. Open the tenant finding detail for an open finding.
2. Trigger `Request exception`.
3. Enter justification, accountable owner, and review or expiry timing.
4. Optionally link one or more supporting evidence references.
5. Confirm that the new exception appears as `pending` in the tenant exception register and the canonical approval queue.
6. Sign in as the approver.
7. Open the pending request from the canonical queue and choose `Approve exception`.
8. Confirm that:
- the exception becomes `active`
- the finding is shown as governed accepted risk
- audit history shows request and approval decisions
## Rejection Path
1. Create a second request for another finding.
2. Reject the request with a reason.
3. Confirm that:
- the exception becomes `rejected`
- the finding is not treated as valid accepted risk
- audit history includes the rejection decision
## Renewal Path
1. Use an active exception with a near-term expiry.
2. Trigger `Renew exception`.
3. Enter updated justification and evidence references.
4. Approve the renewal as an approver.
5. Confirm that:
- the validity window extends
- prior decision history remains visible
- the exception still has one current valid governing record
## Revocation and Expiry Path
1. Revoke one active exception with a reason.
2. Confirm that it becomes `revoked` and no longer counts as valid accepted risk.
3. Create or adjust another exception so its expiry is in the past.
4. Confirm that tenant and canonical views show it as `expired` and that the linked finding surfaces a governance warning if it still shows `risk_accepted`.
## Reminder Visibility Path
1. Use an active exception that is close to expiry according to the configured due-state thresholds.
2. Open the tenant finding detail, tenant exception register, canonical queue, and exception detail.
3. Confirm that requester, accountable owner, tenant managers, and entitled workspace approvers see the expected passive in-product reminder cues in the surfaces they can access.
4. Confirm that no scheduled job, email, background notification, or `OperationRun` is required for this visibility in v1.
## Concurrency and History Path
1. Submit a pending exception request for a finding.
2. Attempt to submit a second request or second renewal workflow for that same finding before the first reaches a terminal state.
3. Confirm that the system blocks the parallel pending workflow and preserves the original in-flight request.
4. Resolve, close, reopen, or change the severity of a finding with historical exception decisions.
5. Confirm that exception history remains readable and the UI shows whether the current finding state is still governed, no longer valid, or requires a fresh decision.
## Authorization Checks
1. Verify a tenant member with `finding_exception.view` but without manage or approve capabilities can inspect exception detail but cannot request, approve, renew, reject, or revoke.
2. Verify a non-member or wrong-tenant user receives deny-as-not-found behavior for tenant and canonical exception routes.
3. Verify the requester cannot approve their own pending request in v1, receives the defined self-approval rejection response, and does not change the exception state.
## Suggested Focused Test Runs
1. Run focused Pest feature tests for findings workflow, exception authorization, canonical queue filtering, and audit history.
2. Run any badge and action-surface guard tests impacted by new exception-state surfaces.
3. Run the minimal Livewire or Filament tests for tenant detail and canonical queue actions.
## Timed Acceptance Checks
1. Time SC-001 by starting on tenant finding detail and stopping when a complete exception request is visible in the canonical queue on the first successful run against the seeded baseline.
2. Time SC-003 by starting on exception detail and stopping when the reviewer can point to requester, approver, justification, and validity-window evidence without leaving the product on the first successful run against the seeded baseline.

View File

@ -0,0 +1,61 @@
# Research: Finding Risk Acceptance Lifecycle
## Decision 1: Use a dedicated tenant-owned exception aggregate instead of overloading `Finding.closed_reason`
**Decision**: Introduce a dedicated `FindingException` aggregate as the tenant-owned governance record for accepted risk, rather than continuing to encode risk acceptance purely as `Finding.status = risk_accepted` plus `closed_reason`.
**Rationale**: The existing finding model already exposes `risk_accepted` as a terminal status, but the handover and roadmap explicitly identify the absence of a formal exception entity as the product gap. A dedicated aggregate lets the system track request, approval, rejection, renewal, revocation, expiry, accountable owner, and linked evidence without distorting the meaning of generic finding workflow fields.
**Alternatives considered**:
- Reuse `Finding.closed_reason` and audit metadata only: rejected because it cannot represent a durable approval lifecycle or one current valid exception per finding.
- Create a generic cross-domain waiver engine immediately: rejected because the spec is intentionally bounded to finding-specific exceptions in the first rollout.
## Decision 2: Preserve history via append-only decision records under one exception root
**Decision**: Model exception history as one root `FindingException` record with append-only `FindingExceptionDecision` child records for request, approval, rejection, renewal, and revocation decisions.
**Rationale**: The product needs both a stable current-state record for efficient tenant and canonical queries and a durable history that survives renewals and revocations. A root aggregate with child decisions avoids rewriting old decisions, keeps list queries fast, and aligns with the repo's audit-first lifecycle design.
**Alternatives considered**:
- Create a new top-level exception row for every renewal: rejected because current-state lookup and canonical queue filtering become noisier and require additional dedupe logic.
- Store all history only in `AuditLog`: rejected because lifecycle state and validity queries would depend on replaying historical events instead of reading domain state.
## Decision 3: Block self-approval by default in v1
**Decision**: Normal workflow blocks the requester from approving their own exception request. No self-approval override is included in the first slice.
**Rationale**: The spec requires approval-separation rules, and a default no-self-approval rule is the clearest governance baseline. It reduces ambiguity, simplifies policy design, and matches the product's least-privilege posture.
**Alternatives considered**:
- Allow self-approval for owners or managers: rejected because it weakens the governance signal and creates policy ambiguity in the first rollout.
- Introduce a special override capability immediately: rejected because it expands RBAC and exception policy complexity before the core workflow is proven.
## Decision 4: Keep normal exception decisions outside `OperationRun`
**Decision**: Request, approval, rejection, renewal, and revocation remain synchronous DB-backed mutations without a dedicated `OperationRun` in v1.
**Rationale**: These actions are local governance decisions, expected to complete quickly, and do not perform remote work. The constitution allows DB-only security-relevant actions to skip `OperationRun` as long as they remain auditable. Using `OperationRun` here would add operational surface area without adding observability value.
**Alternatives considered**:
- Use `OperationRun` for every exception decision: rejected because it violates the repo's preference to avoid long-running infrastructure for fast DB-only mutations.
- Add a scheduled reminder/expiry job in the first slice: rejected because the first release can satisfy reminder semantics through explicit expiring-state UI and canonical queue visibility.
## Decision 5: Link supporting evidence through structured references, not copied payloads
**Decision**: Exception records store structured evidence references such as `source_type`, `source_id`, `source_fingerprint`, and a small summary snapshot, following the evidence snapshot item pattern instead of embedding raw evidence payloads.
**Rationale**: The repo already uses fingerprinted and summarized evidence references in `EvidenceSnapshotItem` and review-pack generation. Reusing that pattern keeps exception history intelligible even when live artifacts change, while preserving data minimization.
**Alternatives considered**:
- Store raw evidence JSON directly on the exception: rejected because it increases payload size and risks leaking data better handled by the evidence domain.
- Store only foreign keys to live evidence records: rejected because history becomes opaque if referenced artifacts are later expired or superseded.
## Decision 6: Risk governance validity is derived from exception state, not from finding status alone
**Decision**: A finding counts as currently valid accepted risk only when it is linked to an active, unexpired, unrevoked exception. The finding's `risk_accepted` status alone is insufficient.
**Rationale**: This closes the core audit gap identified in the handover and allows evidence and reporting consumers to distinguish governed accepted risk from stale or unsupported states. The existing `FindingWorkflowService` remains the single mutation path for the finding status, but validity becomes a cross-record rule.
**Alternatives considered**:
- Treat `Finding.status = risk_accepted` as sufficient forever: rejected because it preserves the current governance gap.
- Automatically revert finding status when an exception expires: rejected because it mutates the finding lifecycle as a side effect and obscures historical operator intent.

View File

@ -0,0 +1,215 @@
# Feature Specification: Finding Risk Acceptance Lifecycle
**Feature Branch**: `154-finding-risk-acceptance`
**Created**: 2026-03-19
**Status**: Draft
**Input**: User description: "Create a formal exception and risk acceptance workflow for findings with approval, expiry, renewal, audit trail, and evidence linkage."
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/t/{tenant}/findings/{finding}` as the tenant-context finding inspection surface where operators can review and initiate risk-acceptance requests
- `/admin/t/{tenant}/exceptions` as the tenant-scoped exception register for active, pending, expiring, expired, rejected, and revoked finding exceptions
- `/admin/exceptions` as the canonical workspace review and governance queue for authorized approvers and auditors
- Existing evidence and audit destinations remain drill-down targets from exception detail when the operator is entitled to inspect them
- **Data Ownership**:
- Tenant-owned: finding exception records, approval decisions, renewal decisions, expiry state, revocation state, and linked evidence references for one tenant's findings
- Workspace-owned but tenant-filtered: canonical review queue state, approval workload filters, and workspace-level summaries for expiring or overdue exceptions without changing tenant ownership of the exception itself
- Existing findings, evidence snapshots, review packs, and audit events remain separate systems of record and are referenced rather than duplicated
- **RBAC**:
- Workspace membership remains required for every exception workflow surface
- Tenant entitlement remains required to inspect or mutate tenant-scoped exception records
- `finding_exception.view` permits reviewing exception details within authorized scope
- `finding_exception.manage` permits creating requests, renewing requests, attaching justification and evidence references, and revoking exceptions where policy allows
- `finding_exception.approve` permits approving or rejecting requests and renewals within authorized scope
- Non-members or users outside the relevant workspace or tenant scope remain deny-as-not-found, while in-scope members lacking the required capability remain forbidden
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When an operator navigates from a tenant finding into the shared exceptions queue, the canonical workspace view opens with that tenant prefiltered. The operator may clear or change the filter only within their authorized tenant set.
- **Explicit entitlement checks preventing cross-tenant leakage**: Exception queries, counts, approver queues, filter options, related finding labels, and linked evidence references must be assembled only after workspace and tenant entitlement checks. Unauthorized users must not learn whether another tenant has pending, active, expiring, or expired exceptions.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Propose and approve a time-bounded risk acceptance (Priority: P1)
As a tenant manager, I want to request a formal risk acceptance for a finding and route it for approval, so that a risk decision becomes explicit, reviewable, and time-bounded instead of being hidden behind a status flag.
**Why this priority**: This is the core governance gap. Without a first-class request and approval flow, the product still cannot answer who accepted a risk, why, and until when.
**Independent Test**: Can be fully tested by creating a finding, submitting a risk-acceptance request with justification and review date, approving it as an authorized approver, and verifying that the finding becomes governed by a valid active exception.
**Acceptance Scenarios**:
1. **Given** a finding is open and no active exception exists, **When** an authorized operator submits a risk-acceptance request with justification, owner, and review deadline, **Then** the system creates a pending exception request linked to that finding.
2. **Given** a pending exception request exists, **When** an authorized approver approves it, **Then** the exception becomes active with a recorded approver, decision time, and expiry date.
3. **Given** a pending exception request exists, **When** an authorized approver rejects it, **Then** the request records the rejection outcome and reason without changing the finding into an accepted-risk state.
4. **Given** a user lacks the relevant capability or tenant entitlement, **When** they attempt to create or approve an exception request, **Then** the server denies the action with the correct 404 or 403 behavior.
---
### User Story 2 - See whether accepted risk is still valid (Priority: P1)
As an auditor or workspace approver, I want a clear register of pending, active, expiring, expired, rejected, and revoked exceptions, so that I can tell which accepted risks are still valid and which require action.
**Why this priority**: A risk-acceptance workflow is only governable if operators can review its current state without reconstructing history from comments and status changes.
**Independent Test**: Can be fully tested by creating exception records in several lifecycle states and verifying that tenant and canonical views expose the correct state, dates, owners, and next-action cues without leaking unauthorized tenant data.
**Acceptance Scenarios**:
1. **Given** a tenant has pending, active, and expired exceptions, **When** an authorized operator opens the tenant exception register, **Then** each exception clearly shows its lifecycle state, finding, owner, approver context, and review timing.
2. **Given** an approver is responsible for multiple tenants, **When** they open the canonical exceptions queue, **Then** they can filter by tenant, state, and due timing without seeing unauthorized tenants.
3. **Given** an active exception is nearing expiry, **When** an authorized operator inspects the register, **Then** the exception is visibly distinguished from long-valid exceptions.
4. **Given** no exception matches the current filters, **When** the operator opens the register, **Then** the empty state explains that no governed exceptions match and offers exactly one clear next action.
---
### User Story 3 - Renew or revoke an accepted risk with audit evidence (Priority: P2)
As a governance operator, I want to renew or revoke an existing accepted risk with a durable decision trail and linked evidence, so that exceptions stay current rather than becoming permanent silent waivers.
**Why this priority**: Time-bounded approval loses value if the product cannot handle renewal and revocation as first-class governance decisions.
**Independent Test**: Can be fully tested by renewing an active exception with new justification and evidence references, revoking another one, and verifying that lifecycle history, current validity, and audit trail remain intelligible.
**Acceptance Scenarios**:
1. **Given** an active exception is approaching expiry, **When** an authorized operator submits a renewal request with updated justification and supporting evidence references, **Then** the system records a new renewal decision path without rewriting the earlier decision.
2. **Given** a renewal request exists, **When** an authorized approver approves it, **Then** the active-validity window extends and the prior decision history remains visible.
3. **Given** an active exception is no longer acceptable, **When** an authorized operator revokes it with a reason, **Then** the exception becomes revoked and no longer counts as valid risk acceptance.
4. **Given** a linked evidence snapshot or supporting artifact later disappears from active views, **When** an operator reviews the exception history, **Then** the exception remains understandable from stored reference metadata.
---
### User Story 4 - Detect governance drift in accepted-risk findings (Priority: P2)
As a compliance-focused operator, I want the system to surface findings marked as accepted risk without a currently valid exception, so that governance drift is visible instead of silently undermining auditability.
**Why this priority**: The business risk is not just missing workflow. It is false confidence when a finding looks accepted even though its approval expired, was revoked, or never existed.
**Independent Test**: Can be fully tested by creating findings in accepted-risk status with valid, expired, revoked, and missing exception records and verifying that only truly valid exceptions count as accepted governance state.
**Acceptance Scenarios**:
1. **Given** a finding is marked as accepted risk and has a valid active exception, **When** the operator inspects it, **Then** the finding shows that the acceptance is governed and time-bounded.
2. **Given** a finding is marked as accepted risk but the linked exception is expired, revoked, or absent, **When** the operator inspects it or opens the exception queue, **Then** the system surfaces it as a governance warning rather than a valid accepted risk.
3. **Given** a downstream review or evidence workflow summarizes accepted risks, **When** it evaluates findings, **Then** only findings backed by a currently valid exception count as active risk acceptance.
### Edge Cases
- A finding is resolved or closed while an exception request is still pending; the request must not silently convert into an active accepted risk without an explicit decision.
- A finding remains in `risk_accepted` status after the governing exception expires or is revoked; the system must show that the risk state is no longer valid.
- An operator attempts to renew an exception that is already expired; the renewal path must remain explicit and must not overwrite the expired decision history.
- The same person requests and approves an exception; the system must block self-approval in v1 because requester and approver must be different users and no override path exists in this slice.
- A finding reopens through detection recurrence while a previous exception exists; the system must make it clear whether the earlier exception still governs the re-opened risk or whether a fresh decision is required.
- Evidence linked to an exception may be partial, stale, or later removed from active surfaces; the exception history must preserve enough reference context for review.
- A workspace approver can review multiple tenants, but must not see queue counts, labels, or filter values for unauthorized tenants.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces a new governance data model and new user-driven write behavior, but no new Microsoft Graph calls. Exception request, approval, renewal, rejection, expiry, and revocation are security-relevant DB-backed governance mutations and must be explicitly audited. The feature must define tenant isolation, approval safety, validity semantics, linked-evidence semantics, and tests for valid, expired, revoked, missing, and unauthorized paths. If scheduled reminder or expiry evaluation is introduced, it must describe how that work is observable and how it avoids cross-tenant leakage.
**Constitution alignment (OPS-UX):** The primary workflow is synchronous governance mutation and does not require a dedicated long-running `OperationRun` for request, approval, rejection, renewal, or revocation. These decisions must therefore be observable through audit history, surface state changes, and user notifications instead of an operation progress surface. If the product later adds scheduled reminder or expiry evaluation, that work may integrate with existing monitoring or alerting patterns, but the first release of this feature does not rely on a new operator-facing progress workflow.
**Constitution alignment (RBAC-UX):** This feature operates in the tenant/admin plane for tenant-scoped finding and exception surfaces and in the workspace-admin canonical view for the approval queue. Cross-plane access remains deny-as-not-found. Non-members or users outside workspace or tenant scope receive `404`. In-scope users lacking `finding_exception.view`, `finding_exception.manage`, or `finding_exception.approve` receive `403` according to the attempted action. Authorization must be enforced server-side for request creation, approval, rejection, renewal, revocation, and any canonical queue action. The canonical capability registry remains the only capability source. Destructive-like actions such as revoke and reject require confirmation.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
**Constitution alignment (BADGE-001):** Exception lifecycle state, risk-governance validity, and due-timing indicators are status-like values and must use centralized badge semantics rather than per-page color choices. Tests must cover all introduced states such as pending, active, expiring, expired, rejected, and revoked.
**Constitution alignment (UI-NAMING-001):** The target object is the finding exception. Operator-facing verbs are `Request exception`, `Approve exception`, `Reject exception`, `Renew exception`, and `Revoke exception`. The term `risk acceptance` describes the governance outcome, while `exception` names the governed record. The same vocabulary must be preserved across finding detail, exception register, approval queue, audit prose, and notifications. Implementation-first terms such as `waiver row`, `approval token`, or `state machine` must not become primary labels.
**Constitution alignment (Filament Action Surfaces):** This feature modifies tenant finding detail and introduces exception list and detail inspection surfaces plus approval actions. The Action Surface Contract is satisfied by explicit header actions on tenant and canonical list surfaces, canonical inspect affordances, and authorization-gated audited mutations. Bulk mutation actions are explicitly exempted for the tenant register and canonical queue in v1 because exception decisions are high-risk governance records and this slice intentionally avoids bulk approve, bulk reject, bulk renew, bulk revoke, or bulk export semantics until a later spec defines safe confirmation, audit, and review behavior.
**Constitution alignment (UX-001 — Layout & Information Architecture):** Exception list screens must provide search, sort, and filters for state, tenant, owner, approver, and expiry timing. Exception detail must be an inspection surface using Infolist-style composition rather than a disabled edit form. Creation and renewal may use a structured modal or dedicated form surface, but must keep justification, owner, timing, and evidence references grouped inside sections. Empty states must include a specific title, explanation, and exactly one CTA.
### Functional Requirements
- **FR-001**: The system MUST provide a first-class finding exception record that governs formal risk acceptance for a specific finding.
- **FR-002**: A finding exception MUST capture at minimum the target finding, requester, accountable owner, requested justification, requested decision time, and the bounded validity window for accepted risk.
- **FR-003**: The system MUST support an exception lifecycle that distinguishes at least pending, active, expiring, expired, rejected, revoked, and superseded or renewed states.
- **FR-004**: An operator MUST be able to request risk acceptance for a finding without directly bypassing the approval lifecycle.
- **FR-005**: The system MUST support explicit approval and explicit rejection of pending exception requests, with durable decision reason and actor history.
- **FR-006**: The system MUST support renewal of an existing exception as a new governance decision that preserves earlier request and approval history.
- **FR-007**: The system MUST support explicit revocation of an active exception, with recorded actor, time, and revocation reason.
- **FR-008**: The system MUST treat a finding as having valid accepted risk only while a currently valid active exception exists for that finding.
- **FR-009**: A finding in `risk_accepted` status without a currently valid exception MUST be surfaced as a governance warning rather than a fully governed accepted risk.
- **FR-010**: One finding MAY have multiple historical exception records over time, but the system MUST allow at most one currently governing active exception and MUST block parallel pending request or renewal workflows for the same finding until the in-flight workflow reaches a terminal state.
- **FR-011**: Exception requests and renewals MUST support structured supporting context, including freeform justification and one or more linked evidence references when available.
- **FR-012**: Evidence references linked to an exception MUST remain intelligible even if the live evidence artifact later expires, is superseded, or becomes inaccessible from normal active views.
- **FR-013**: The system MUST provide a tenant-scoped exception register that allows authorized operators to review current and historical exception records for that tenant.
- **FR-014**: The system MUST provide a canonical workspace approval and governance queue that allows authorized viewers to review pending, expiring, expired, rejected, and revoked exceptions across entitled tenants.
- **FR-015**: Tenant and canonical views MUST provide filters for lifecycle state, due timing, requester, owner, approver, and finding severity or type where relevant.
- **FR-016**: The system MUST make upcoming expiry and already-expired exceptions clearly visible so that time-bounded risk acceptance does not silently lapse.
- **FR-017**: The first release MUST define reminder semantics as passive in-product visibility only: exceptions nearing expiry are highlighted in tenant finding detail, tenant exception register, canonical queue, and exception detail for the requester, accountable owner, tenant managers, and entitled workspace approvers; users with only `finding_exception.view` may inspect due-state badges when they are otherwise entitled, but do not receive management CTAs; no scheduled notifications, emails, or background reminder jobs are introduced in v1.
- **FR-018**: All exception lifecycle mutations must be recorded in audit history with workspace scope, tenant scope, actor, target finding context, action, outcome, and readable supporting context.
- **FR-019**: Exception audit records MUST be summary-first and MUST NOT store secrets, raw evidence payloads, or arbitrary oversized snapshots.
- **FR-020**: The system MUST enforce 404 deny-as-not-found behavior for non-members and out-of-scope users, and 403 behavior for in-scope users lacking the required capability.
- **FR-021**: The feature MUST block normal self-approval in v1. The requester and approver must be different users, and no self-approval override path exists in this slice; any future override path requires a dedicated follow-up spec with explicit governance, audit, and authorization rules.
- **FR-022**: The feature MUST preserve intelligible history when a finding later resolves, closes, reopens, or changes severity after an exception decision, including clear display of the exception decision timeline and whether the current finding state is still governed, expired, revoked, or requires a fresh decision.
### Exception Concurrency And Reminder Rules
- A finding may accumulate multiple historical exception records across time, but only one active governing exception may exist at once.
- If a finding already has a pending initial request or pending renewal workflow, the system blocks creation of an additional pending request for that same finding.
- Renewal extends governance through the current exception record and its decision history rather than creating parallel competing active records.
- Reminder behavior in v1 is limited to visual due-state cues and queue visibility inside the existing tenant and canonical surfaces; requester, accountable owner, tenant managers, and entitled workspace approvers receive these cues in the surfaces they can access, while view-only users can inspect the due state without management actions; no scheduled reminders or external notifications are part of this slice.
- **FR-023**: Downstream review, evidence, and reporting workflows that summarize accepted risk MUST distinguish valid governed exceptions from expired, revoked, rejected, or missing ones.
- **FR-024**: The feature MUST introduce at least one positive and one negative authorization test for tenant-context request flows and canonical approval-queue flows.
- **FR-025**: The feature MUST introduce regression tests for pending, approved, rejected, renewed, revoked, expired, and missing-exception states, plus wrong-tenant and invalid-transition paths.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Finding Detail Risk Panel | Tenant-context finding inspection under `/admin/t/{tenant}/findings/{finding}` | `Request exception` (`finding_exception.manage`) when no valid exception exists | Linked exception summary card or explicit `View exception` affordance | `View exception`, `Request exception` or `Renew exception` depending on state | None | `Request first exception` when no governance record exists | `Renew exception`, `Revoke exception` when authorized | N/A | Yes | Action labels must describe the governance object, not just the finding status |
| Tenant Exception Register | Tenant-context list under `/admin/t/{tenant}/exceptions` | `Request exception` when current tenant context contains an eligible finding; `Clear filters` | Clickable row to exception detail | `View exception`, `Renew exception` or `Revoke exception` depending on state | Explicit exemption in v1: no grouped bulk actions because exception decisions are high-risk governance mutations that require per-record review and confirmation | `Request first exception` | None | N/A | Yes | Inspection-first surface; bulk actions intentionally deferred to a future safety spec |
| Canonical Exceptions Queue | Workspace canonical view at `/admin/exceptions` | `Clear filters`, `View tenant register` for the currently selected tenant filter | Clickable row to exception detail | `Approve exception`, `Reject exception` for pending items | Explicit exemption in v1: no grouped bulk actions because approval and rejection remain per-record governance decisions with tenant-specific context | `Clear filters` | None | N/A | Yes | Queue must remain tenant-safe and only show entitled tenants |
| Exception Detail | Tenant or canonical detail inspection surface | None | N/A | None | None | N/A | `Approve exception`, `Reject exception`, `Renew exception`, `Revoke exception` depending on state and capability | N/A | Yes | Detail is an inspection surface, not a disabled edit form |
### Key Entities *(include if feature involves data)*
- **Finding Exception**: A governed risk-acceptance record for one finding, including request context, decision state, validity timing, and current governance outcome.
- **Exception Decision**: A durable approval, rejection, renewal, or revocation record that explains who made the decision, when, and why.
- **Exception Evidence Reference**: A structured pointer to supporting evidence used to justify or review an exception, preserved as intelligible reference metadata.
- **Risk Governance Validity**: The normalized truth of whether a finding's accepted-risk posture is currently valid, expiring soon, expired, revoked, rejected, or unsupported.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An authorized operator can request and route a formal finding exception in under 3 minutes without leaving the product.
- **SC-002**: In automated tests, 100% of findings counted as valid accepted risk are backed by a currently valid active exception.
- **SC-003**: In acceptance review, an authorized auditor can answer who requested, who approved, why it was accepted, and until when it remains valid within 2 minutes using the product alone.
- **SC-004**: Expired, revoked, rejected, and missing-governance accepted-risk states are all distinguishable in automated regression coverage with no false classification as valid active acceptance.
- **SC-005**: Negative authorization tests prove that non-members or wrong-tenant users receive deny-as-not-found behavior and in-scope users without the required capability cannot request, approve, renew, or revoke exceptions.
- **SC-006**: Renewal and revocation flows preserve prior decision history in automated tests rather than overwriting the previous governance record.
### Measurement Notes
- **SC-001** is measured manually during the quickstart on a seeded local Sail environment with one workspace, one tenant, one eligible open finding, and distinct requester and approver users. The timer starts on tenant finding detail and stops when a complete request is visible in the canonical queue on the first successful run.
- **SC-003** is measured manually during the quickstart on the same seeded baseline. The timer starts on exception detail and stops when the reviewer can point to requester, approver, justification, and validity-window evidence in the product UI on the first successful run.
## Assumptions
- Spec 111 remains the product source of truth for finding lifecycle and status semantics, including the existing `risk_accepted` status.
- Spec 134 remains the source of truth for canonical audit readability and event history behavior.
- Evidence linkage may reference evidence snapshots, review artifacts, or other governance evidence when available, but the exception lifecycle must not be blocked merely because evidence is partial.
- Normal approval flow blocks self-approval in v1; no override path exists in this slice.
- The first rollout focuses on finding-specific exceptions, not a generic cross-domain waiver engine.
## Non-Goals
- Replacing the existing findings workflow with a different status model
- Creating a generic exception platform for every future domain in the first slice
- Suppressing or deleting findings automatically when risk is accepted
- Making legal or certification claims about compliance acceptance
- Replacing evidence snapshots, review packs, or the broader audit foundation with exception-owned storage
## Dependencies
- Findings workflow semantics and lifecycle rules from `specs/111-findings-workflow-sla/spec.md`
- Audit history foundation and event readability rules from `specs/134-audit-log-foundation/spec.md`
- Evidence-domain linkage patterns from `specs/153-evidence-domain-foundation/spec.md` when evidence snapshots are available

View File

@ -0,0 +1,205 @@
# Tasks: Finding Risk Acceptance Lifecycle
**Input**: Design documents from `/specs/154-finding-risk-acceptance/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior, authorization, auditability, and tenant-scoped governance workflows.
**Operations**: This feature intentionally keeps request, approval, rejection, renewal, and revocation as security-relevant DB-only actions without `OperationRun`; tasks therefore enforce `AuditLog` coverage instead of an operations progress workflow.
**RBAC**: This feature introduces authorization changes and MUST enforce canonical capability registry usage, 404 vs 403 semantics, cross-plane deny-as-not-found behavior, and positive/negative authorization tests.
**Filament UI**: This feature adds and modifies Filament Resource and Page surfaces, so tasks include Action Surface Contract, BADGE-001, and UX-001 enforcement work.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create the core schema and file scaffolding needed for all stories.
- [X] T001 Create exception schema migrations in `database/migrations/2026_03_19_000001_create_finding_exceptions_table.php`, `database/migrations/2026_03_19_000002_create_finding_exception_decisions_table.php`, and `database/migrations/2026_03_19_000003_create_finding_exception_evidence_references_table.php`
- [X] T002 [P] Create Eloquent model scaffolding for the new domain in `app/Models/FindingException.php`, `app/Models/FindingExceptionDecision.php`, and `app/Models/FindingExceptionEvidenceReference.php`
- [X] T003 [P] Create Filament surface scaffolding in `app/Filament/Resources/FindingExceptionResource.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, and `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared domain, authorization, audit, and navigation infrastructure before any user-story slice is implemented.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Register exception capabilities and role mappings in `app/Support/Auth/Capabilities.php`, `app/Services/Auth/RoleCapabilityMap.php`, and `app/Services/Auth/WorkspaceRoleCapabilityMap.php`
- [X] T005 Implement policy and entitlement enforcement for tenant and canonical exception access in `app/Policies/FindingExceptionPolicy.php`, `app/Providers/Filament/AdminPanelProvider.php`, and `app/Providers/Filament/TenantPanelProvider.php`
- [X] T006 [P] Add shared audit action IDs, summaries, and related-navigation hooks in `app/Support/Audit/AuditActionId.php`, `app/Support/Navigation/RelatedNavigationResolver.php`, and `app/Services/Intune/AuditLogger.php`
- [X] T007 Implement the shared exception lifecycle service and validity resolver skeleton in `app/Services/Findings/FindingExceptionService.php` and `app/Services/Findings/FindingRiskGovernanceResolver.php`
- [X] T008 Wire domain relationships and tenant-owned query behavior in `app/Models/Finding.php`, `app/Models/FindingException.php`, `app/Models/FindingExceptionDecision.php`, and `app/Models/FindingExceptionEvidenceReference.php`
- [X] T009 [P] Add foundational schema, policy, and model regression coverage in `tests/Unit/Findings/FindingExceptionModelTest.php` and `tests/Feature/Findings/FindingExceptionPolicyTest.php`
**Checkpoint**: Foundation ready. User story work can now proceed.
---
## Phase 3: User Story 1 - Propose And Approve A Time-Bounded Risk Acceptance (Priority: P1) 🎯 MVP
**Goal**: Allow tenant managers to request risk acceptance for a finding and route it through explicit approval or rejection.
**Independent Test**: Create a finding, submit an exception request with justification and review timing, approve or reject it as an authorized approver, and verify correct finding governance state plus 404/403 authorization behavior.
### Tests for User Story 1
- [X] T010 [P] [US1] Add request, approve, and reject feature coverage in `tests/Feature/Findings/FindingExceptionWorkflowTest.php`
- [X] T011 [P] [US1] Add positive and negative authorization coverage for request and approval flows in `tests/Feature/Findings/FindingExceptionAuthorizationTest.php`
- [X] T012 [P] [US1] Add lifecycle transition, blocked self-approval, and overlapping-request rejection unit coverage in `tests/Unit/Findings/FindingExceptionServiceTest.php`
### Implementation for User Story 1
- [X] T013 [US1] Implement request, approve, and reject lifecycle transitions, blocked self-approval, overlapping-request guards, and append-only decision persistence in `app/Services/Findings/FindingExceptionService.php` and `app/Models/FindingExceptionDecision.php`
- [X] T014 [US1] Integrate approved and rejected exception outcomes with finding status mutation rules in `app/Services/Findings/FindingWorkflowService.php` and `app/Models/Finding.php`
- [X] T015 [US1] Add request, approve, and reject forms and actions to the finding detail and canonical queue in `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- [X] T016 [US1] Persist request, approval, and rejection audit history with summary-first metadata in `app/Support/Audit/AuditActionId.php`, `app/Support/Audit/AuditContextSanitizer.php`, and `app/Services/Intune/AuditLogger.php`
**Checkpoint**: User Story 1 is independently functional and demonstrates the MVP governance workflow.
---
## Phase 4: User Story 2 - See Whether Accepted Risk Is Still Valid (Priority: P1)
**Goal**: Provide tenant and canonical views that show pending, active, expiring, expired, rejected, and revoked exceptions without leaking unauthorized tenant data.
**Independent Test**: Seed exception records in multiple lifecycle states and verify tenant and canonical views show correct state, timing, owners, and filters only for entitled tenants.
### Tests for User Story 2
- [X] T017 [P] [US2] Add tenant register and finding-detail coverage for lifecycle-state visibility, passive expiry reminders, actor-specific visibility, empty states, and filters in `tests/Feature/Findings/FindingExceptionRegisterTest.php` and `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
- [X] T018 [P] [US2] Add canonical queue and exception-detail coverage for tenant-safe filtering, passive expiry reminders, and entitled actor visibility in `tests/Feature/Monitoring/FindingExceptionsQueueTest.php` and `tests/Feature/Findings/FindingExceptionAuthorizationTest.php`
- [X] T019 [P] [US2] Add badge and validity-mapping unit coverage in `tests/Unit/Findings/FindingExceptionBadgeTest.php`
### Implementation for User Story 2
- [X] T020 [US2] Implement the tenant exception register, exception detail, and finding-detail reminder surfaces, including actor-specific passive reminder visibility, header actions, and the documented v1 bulk-action exemption, in `app/Filament/Resources/FindingExceptionResource.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `app/Filament/Resources/FindingResource.php`, and `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
- [X] T021 [US2] Implement canonical queue filters, passive expiry reminders, header actions, empty states, documented v1 bulk-action exemption, and entitled tenant prefilter behavior in `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- [X] T022 [US2] Add centralized exception-state and validity badges in `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/Domains/FindingExceptionStatusBadge.php`, and `app/Support/Badges/Domains/FindingRiskGovernanceValidityBadge.php`
- [X] T023 [US2] Add exception filter catalog entries and related navigation affordances in `app/Support/Filament/FilterOptionCatalog.php` and `app/Support/Navigation/RelatedNavigationResolver.php`
**Checkpoint**: User Story 2 is independently functional and can be demonstrated with seeded exception records even without renewal or downstream consumer work.
---
## Phase 5: User Story 3 - Renew Or Revoke An Accepted Risk With Audit Evidence (Priority: P2)
**Goal**: Support renewal and revocation with preserved decision history and structured evidence references.
**Independent Test**: Renew an active exception with updated evidence references, revoke another one, and verify history remains append-only and intelligible.
### Tests for User Story 3
- [X] T024 [P] [US3] Add renewal and revocation feature coverage in `tests/Feature/Findings/FindingExceptionRenewalTest.php` and `tests/Feature/Findings/FindingExceptionRevocationTest.php`
- [X] T025 [P] [US3] Add append-only decision and evidence-reference unit coverage in `tests/Unit/Findings/FindingExceptionDecisionTest.php` and `tests/Unit/Findings/FindingExceptionEvidenceReferenceTest.php`
### Implementation for User Story 3
- [X] T026 [US3] Implement renewal and revocation lifecycle behavior in `app/Services/Findings/FindingExceptionService.php`, `app/Models/FindingException.php`, and `app/Models/FindingExceptionDecision.php`
- [X] T027 [US3] Persist and render structured evidence references in `app/Models/FindingExceptionEvidenceReference.php`, `app/Filament/Resources/FindingExceptionResource.php`, and `app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- [X] T028 [US3] Add renew and revoke actions to tenant and canonical surfaces in `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- [X] T029 [US3] Extend audit summaries and sanitization for renewal, revocation, and linked evidence metadata in `app/Support/Audit/AuditActionId.php`, `app/Support/Audit/AuditContextSanitizer.php`, and `app/Services/Intune/AuditLogger.php`
**Checkpoint**: User Story 3 is independently functional and preserves exception history without overwriting earlier decisions.
---
## Phase 6: User Story 4 - Detect Governance Drift In Accepted-Risk Findings (Priority: P2)
**Goal**: Surface findings that look risk-accepted but are not backed by a currently valid exception, and ensure downstream consumers count only valid governed exceptions.
**Independent Test**: Seed findings with valid, expired, revoked, rejected, and missing exception states and verify only valid ones count as active accepted risk in finding detail and downstream evidence/reporting summaries.
### Tests for User Story 4
- [X] T030 [P] [US4] Add governance-drift and finding-lifecycle-change coverage for finding detail and register warnings in `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
- [X] T031 [P] [US4] Add evidence and review-pack downstream validity coverage in `tests/Feature/Evidence/ExceptionValidityEvidenceIntegrationTest.php` and `tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php`
### Implementation for User Story 4
- [X] T032 [US4] Implement validity projection, warning-state resolution, and history-preserving behavior for resolved, closed, reopened, and severity-changed findings in `app/Services/Findings/FindingRiskGovernanceResolver.php`, `app/Models/FindingException.php`, and `app/Models/Finding.php`
- [X] T033 [US4] Surface missing, expired, and revoked governance warnings in `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and `app/Filament/Resources/FindingExceptionResource.php`
- [X] T034 [US4] Update downstream evidence and review-pack consumers to count only valid governed exceptions in `app/Services/Evidence/EvidenceSnapshotService.php`, `app/Jobs/GenerateReviewPackJob.php`, and `app/Services/ReviewPackService.php`
**Checkpoint**: User Story 4 is independently functional and proves that accepted-risk reporting depends on valid exception governance rather than finding status alone.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finalize cross-story safeguards, UI contract enforcement, and quickstart validation.
- [X] T035 [P] Update Filament action-surface guard coverage for the new exception surfaces, including the documented v1 bulk-action exemptions, and refresh badge guard coverage in `tests/Feature/Guards/ActionSurfaceContractTest.php` and `tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
- [X] T036 [P] Tighten tenant-owned query and cross-tenant leakage guards for the new domain in `tests/Feature/Guards/TenantOwnedQueryGuardTest.php` and `tests/Feature/Findings/FindingExceptionAuthorizationTest.php`
- [X] T037 Validate the quickstart scenarios, passive reminder visibility, actor-specific visibility matrix, and timed success-criteria walkthroughs against focused Pest packs in `specs/154-finding-risk-acceptance/quickstart.md`, `tests/Feature/Findings/FindingExceptionWorkflowTest.php`, `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`, `tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, and `tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; starts immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP slice.
- **User Story 2 (Phase 4)**: Depends on Foundational completion; integrates best after User Story 1 because canonical queue actions reuse approval handlers.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and User Story 1 lifecycle foundations.
- **User Story 4 (Phase 6)**: Depends on Foundational completion and benefits from User Stories 1 and 3 because validity projection relies on complete exception lifecycle states.
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1**: No dependency on other user stories after Foundational; this is the MVP.
- **US2**: Can be viewed with seeded data after Foundational, but approval queue actions are most complete after US1.
- **US3**: Depends on US1 because renewal and revocation extend the primary exception lifecycle.
- **US4**: Depends on US1 for valid governance semantics and on US3 for renewal/revocation edge states.
### Within Each User Story
- Write tests before the corresponding implementation tasks and confirm they fail first.
- Complete service and model behavior before Filament action wiring.
- Complete audit and authorization enforcement before closing the story.
### Parallel Opportunities
- `T002` and `T003` can run in parallel after `T001`.
- `T006` and `T009` can run in parallel once `T004` and `T005` establish the authorization baseline.
- In each story, the `[P]` test tasks can run in parallel.
- `T022` and `T023` can run in parallel during US2 after list/detail surfaces exist.
- `T035` and `T036` can run in parallel in the polish phase.
---
## Parallel Example: User Story 1
```bash
# Run the User Story 1 tests in parallel:
Task: "Add request, approve, and reject feature coverage in tests/Feature/Findings/FindingExceptionWorkflowTest.php"
Task: "Add positive and negative authorization coverage for request and approval flows in tests/Feature/Findings/FindingExceptionAuthorizationTest.php"
Task: "Add lifecycle transition, blocked self-approval, and overlapping-request rejection unit coverage in tests/Unit/Findings/FindingExceptionServiceTest.php"
# Then implement the shared lifecycle and UI wiring in sequence:
Task: "Implement request, approve, and reject lifecycle transitions plus append-only decision persistence in app/Services/Findings/FindingExceptionService.php and app/Models/FindingExceptionDecision.php"
Task: "Integrate approved and rejected exception outcomes with finding status mutation rules in app/Services/Findings/FindingWorkflowService.php and app/Models/Finding.php"
Task: "Add request, approve, and reject forms and actions to the finding detail and canonical queue in app/Filament/Resources/FindingResource.php, app/Filament/Resources/FindingResource/Pages/ViewFinding.php, and app/Filament/Pages/Monitoring/FindingExceptionsQueue.php"
```
---
## Implementation Strategy
### MVP First
1. Complete Phase 1 and Phase 2.
2. Deliver User Story 1 as the MVP: request, approve, reject, audit, and correct authorization semantics.
3. Validate that the core governance gap is closed before expanding read models and downstream consumers.
### Incremental Delivery
1. Add User Story 2 to make governance state visible and operable across tenant and canonical views.
2. Add User Story 3 for renewal and revocation once the core approval lifecycle is stable.
3. Add User Story 4 to tighten downstream validity semantics and governance-drift reporting.
### Suggested MVP Scope
- **Recommended MVP**: Phases 1-3 only.
- **Why**: This yields the minimum coherent business value by turning `risk_accepted` from a bare status into a governed, approved, auditable exception workflow.

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\StoredReport;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('counts only valid governed accepted risk in the findings summary evidence payload', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
]);
OperationRun::factory()->forTenant($tenant)->create();
/** @var FindingExceptionService $exceptionService */
$exceptionService = app(FindingExceptionService::class);
$createApprovedException = function (Finding $finding, string $expiresAt) use ($exceptionService, $requester, $tenant, $approver): Finding {
$requested = $exceptionService->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Temporary exception',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
$exceptionService->approve($requested, $approver, [
'effective_from' => now()->subDays(10)->toDateTimeString(),
'expires_at' => $expiresAt,
'approval_reason' => 'Approved with controls',
]);
return $finding;
};
$validFinding = $createApprovedException(
Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_RISK_ACCEPTED]),
now()->addDays(14)->toDateTimeString(),
);
$expiredFinding = $createApprovedException(
Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_RISK_ACCEPTED]),
now()->subDay()->toDateTimeString(),
);
app(FindingRiskGovernanceResolver::class)->syncExceptionState($expiredFinding->findingException()->firstOrFail());
$revokedFinding = $createApprovedException(
Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_RISK_ACCEPTED]),
now()->addDays(14)->toDateTimeString(),
);
$exceptionService->revoke($revokedFinding->findingException()->firstOrFail(), $requester, [
'revocation_reason' => 'Controls removed',
]);
$missingFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_REOPENED,
]);
/** @var EvidenceSnapshotService $snapshotService */
$snapshotService = app(EvidenceSnapshotService::class);
$payload = $snapshotService->buildSnapshotPayload($tenant);
$findingsItem = collect($payload['items'])->firstWhere('dimension_key', 'findings_summary');
$summary = $findingsItem['summary_payload']['risk_acceptance'] ?? null;
$entries = collect($findingsItem['summary_payload']['entries'] ?? []);
expect($summary)->toBe([
'status_marked_count' => 4,
'valid_governed_count' => 1,
'warning_count' => 3,
'expired_count' => 1,
'revoked_count' => 1,
'missing_exception_count' => 1,
]);
expect($entries->firstWhere('id', (int) $validFinding->getKey())['governance_state'] ?? null)
->toBe('valid_exception')
->and($entries->firstWhere('id', (int) $expiredFinding->getKey())['governance_state'] ?? null)->toBe('expired_exception')
->and($entries->firstWhere('id', (int) $revokedFinding->getKey())['governance_state'] ?? null)->toBe('revoked_exception')
->and($entries->firstWhere('id', (int) $missingFinding->getKey())['governance_state'] ?? null)->toBe('risk_accepted_without_valid_exception');
});

View File

@ -124,32 +124,4 @@
->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,63 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Models\Finding;
use App\Models\FindingException;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('disables request exception for readonly members and denies canonical queue access without approval capability', function (): void {
[$readonly, $tenant] = createUserWithTenant(role: 'readonly');
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
$this->actingAs($readonly);
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertActionVisible('request_exception')
->assertActionDisabled('request_exception');
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$this->get('/admin/finding-exceptions/queue')->assertForbidden();
});
it('returns 404 for non-members on tenant exception routes', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$tenantInSameWorkspace = \App\Models\Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
[$outsider] = createUserWithTenant(tenant: $tenantInSameWorkspace, role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $owner->getKey(),
'owner_user_id' => (int) $owner->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary governance request',
'requested_at' => now(),
'review_due_at' => now()->addDays(7),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($outsider)
->get(FindingExceptionResource::getUrl('index', tenant: $tenant))
->assertNotFound();
$this->actingAs($outsider)
->get(FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant))
->assertNotFound();
});

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
it('allows tenant viewers to inspect exception detail and hides records from non-members', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
[$owner] = createUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $owner->getKey(),
'owner_user_id' => (int) $owner->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary governance request',
'requested_at' => now(),
'review_due_at' => now()->addDays(7),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
expect(Gate::forUser($viewer)->allows('view', $exception))->toBeTrue();
$outsider = \App\Models\User::factory()->create();
$response = Gate::forUser($outsider)->inspect('view', $exception);
expect($response->allowed())->toBeFalse()
->and($response->status())->toBe(404);
});

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\User;
use App\Services\Findings\FindingRiskGovernanceResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows pending, active, and expired exceptions in the tenant register with lifecycle filters', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$createException = function (array $attributes) use ($tenant, $requester, $approver): FindingException {
$finding = Finding::factory()->for($tenant)->create();
return FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $requester->getKey(),
'owner_user_id' => (int) $requester->getKey(),
'approved_by_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'request_reason' => 'Temporary governance coverage',
'approval_reason' => 'Compensating controls accepted',
'requested_at' => now()->subDays(10),
'approved_at' => now()->subDays(9),
'effective_from' => now()->subDays(9),
'expires_at' => now()->addDays(21),
'review_due_at' => now()->addDays(14),
'evidence_summary' => ['reference_count' => 0],
], $attributes));
};
$pending = $createException([
'approved_by_user_id' => null,
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'approval_reason' => null,
'approved_at' => null,
'effective_from' => null,
'expires_at' => now()->addDays(10),
'review_due_at' => now()->addDays(7),
]);
$active = $createException([]);
$expired = $createException([
'expires_at' => now()->subDay(),
'review_due_at' => now()->subDays(3),
]);
app(FindingRiskGovernanceResolver::class)->syncExceptionState($expired);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListFindingExceptions::class)
->assertCanSeeTableRecords([$pending, $active, $expired])
->filterTable('status', FindingException::STATUS_EXPIRED)
->assertCanSeeTableRecords([$expired])
->assertCanNotSeeTableRecords([$pending, $active]);
});
it('renders exception detail with owner, approver, and validity context for tenant viewers', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $requester->getKey(),
'owner_user_id' => (int) $requester->getKey(),
'approved_by_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_EXPIRING,
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
'request_reason' => 'Temporary exception request',
'approval_reason' => 'Valid until remediation window closes',
'requested_at' => now()->subDays(5),
'approved_at' => now()->subDays(4),
'effective_from' => now()->subDays(4),
'expires_at' => now()->addDays(2),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
->assertOk()
->assertSee('Validity')
->assertSee('Expiring')
->assertSee($requester->name)
->assertSee($approver->name);
});
it('shows a single clear empty-state action when no tenant exceptions match', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListFindingExceptions::class)
->assertSee('No exceptions match this view')
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
});

View File

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('requests and approves a renewal while preserving prior decision history and evidence references', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Initial compensating controls approved until maintenance window',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
'evidence_references' => [
[
'label' => 'Initial baseline review',
'source_type' => 'evidence_snapshot',
'source_id' => 'snapshot-001',
'source_fingerprint' => 'fp-initial-001',
'measured_at' => now()->subDay()->toDateTimeString(),
'summary_payload' => ['status' => 'pending_remediation'],
],
],
]);
$service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
'approval_reason' => 'Approved until remediation can be scheduled.',
]);
$this->actingAs($requester);
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertActionVisible('renew_exception')
->callAction('renew_exception', data: [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Renew for the next maintenance window with updated rollback proof.',
'review_due_at' => now()->addDays(10)->toDateTimeString(),
'expires_at' => now()->addDays(45)->toDateTimeString(),
'evidence_references' => [
[
'label' => 'Rollback drill 2026-03-18',
'source_type' => 'review_pack',
'source_id' => 'rp-2026-03-18',
'source_fingerprint' => 'fp-renew-001',
'measured_at' => now()->subHours(12)->toDateTimeString(),
],
[
'label' => 'Compensating controls review',
'source_type' => 'evidence_snapshot',
'source_id' => 'snapshot-002',
'source_fingerprint' => 'fp-renew-002',
'measured_at' => now()->subHours(6)->toDateTimeString(),
],
],
])
->assertHasNoActionErrors();
$pendingRenewal = FindingException::query()
->with(['currentDecision', 'decisions', 'evidenceReferences'])
->where('finding_id', (int) $finding->getKey())
->firstOrFail();
expect($pendingRenewal->status)->toBe(FindingException::STATUS_PENDING)
->and($pendingRenewal->currentDecision?->decision_type)->toBe(FindingExceptionDecision::TYPE_RENEWAL_REQUESTED)
->and($pendingRenewal->decisions->pluck('decision_type')->all())->toBe([
FindingExceptionDecision::TYPE_REQUESTED,
FindingExceptionDecision::TYPE_APPROVED,
FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
])
->and($pendingRenewal->evidenceReferences)->toHaveCount(2)
->and($pendingRenewal->evidenceReferences->pluck('label')->all())->toBe([
'Rollback drill 2026-03-18',
'Compensating controls review',
])
->and($pendingRenewal->evidenceReferences->first()?->summary_payload)->toBe([]);
$this->actingAs($approver);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::withQueryParams([
'exception' => (int) $pendingRenewal->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertActionVisible('approve_selected_exception')
->callAction('approve_selected_exception', data: [
'effective_from' => now()->addDays(14)->toDateTimeString(),
'expires_at' => now()->addDays(45)->toDateTimeString(),
'approval_reason' => 'Renewed while remediation remains scheduled and evidenced.',
])
->assertHasNoActionErrors()
->assertNotified('Exception renewed');
$renewed = $pendingRenewal->fresh(['currentDecision', 'decisions', 'evidenceReferences']);
expect($renewed?->status)->toBe(FindingException::STATUS_ACTIVE)
->and($renewed?->current_validity_state)->toBe(FindingException::VALIDITY_VALID)
->and($renewed?->currentDecision?->decision_type)->toBe(FindingExceptionDecision::TYPE_RENEWED)
->and($renewed?->decisions->pluck('decision_type')->all())->toBe([
FindingExceptionDecision::TYPE_REQUESTED,
FindingExceptionDecision::TYPE_APPROVED,
FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
FindingExceptionDecision::TYPE_RENEWED,
])
->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
->and(AuditLog::query()
->where('action', AuditActionId::FindingExceptionRenewalRequested->value)
->where('resource_type', 'finding_exception')
->where('resource_id', (string) $pendingRenewal->getKey())
->exists())->toBeTrue()
->and(AuditLog::query()
->where('action', AuditActionId::FindingExceptionRenewed->value)
->where('resource_type', 'finding_exception')
->where('resource_id', (string) $pendingRenewal->getKey())
->exists())->toBeTrue();
$this->actingAs($requester);
Filament::setTenant($tenant, true);
Livewire::test(ViewFindingException::class, ['record' => $pendingRenewal->getKey()])
->assertOk()
->assertSee('Rollback drill 2026-03-18')
->assertSee('Compensating controls review')
->assertSee('Renewed while remediation remains scheduled and evidenced.');
});
it('rejects a pending renewal without erasing the prior approved governance window', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Initial acceptance window',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
]);
$active = $service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
'approval_reason' => 'Initial approval',
]);
$service->renew($active, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Need additional time while vendor patch is validated.',
'review_due_at' => now()->addDays(8)->toDateTimeString(),
'expires_at' => now()->addDays(40)->toDateTimeString(),
'evidence_references' => [
[
'label' => 'Patch validation evidence',
'source_type' => 'review_pack',
],
],
]);
$rejected = $service->reject($active->fresh(['currentDecision']), $approver, [
'rejection_reason' => 'Renewal denied until stronger mitigation evidence is attached.',
]);
expect($rejected->status)->toBe(FindingException::STATUS_ACTIVE)
->and($rejected->current_validity_state)->toBe(FindingException::VALIDITY_VALID)
->and($rejected->currentDecision?->decision_type)->toBe(FindingExceptionDecision::TYPE_REJECTED)
->and($rejected->decisions->pluck('decision_type')->all())->toBe([
FindingExceptionDecision::TYPE_REQUESTED,
FindingExceptionDecision::TYPE_APPROVED,
FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
FindingExceptionDecision::TYPE_REJECTED,
])
->and($rejected->rejection_reason)->toBe('Renewal denied until stronger mitigation evidence is attached.')
->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED);
});

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Support\Audit\AuditActionId;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('revokes an approved exception from the tenant finding surface and keeps the audit trail intelligible', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Temporary exception while remediation is scheduled.',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
'evidence_references' => [
[
'label' => 'Initial review note',
'source_type' => 'review_pack',
'source_id' => 'rp-initial',
'source_fingerprint' => 'fp-initial',
'measured_at' => now()->subDay()->toDateTimeString(),
],
],
]);
$service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
'approval_reason' => 'Approved with compensating controls.',
]);
$this->actingAs($requester);
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertActionVisible('revoke_exception')
->callAction('revoke_exception', data: [
'revocation_reason' => 'Compensating controls no longer exist in production.',
])
->assertHasNoActionErrors()
->assertNotified('Exception revoked');
$revoked = FindingException::query()
->with(['currentDecision', 'decisions', 'evidenceReferences'])
->where('finding_id', (int) $finding->getKey())
->firstOrFail();
expect($revoked->status)->toBe(FindingException::STATUS_REVOKED)
->and($revoked->current_validity_state)->toBe(FindingException::VALIDITY_REVOKED)
->and($revoked->currentDecision?->decision_type)->toBe(FindingExceptionDecision::TYPE_REVOKED)
->and($revoked->decisions->pluck('decision_type')->all())->toBe([
FindingExceptionDecision::TYPE_REQUESTED,
FindingExceptionDecision::TYPE_APPROVED,
FindingExceptionDecision::TYPE_REVOKED,
])
->and($revoked->revocation_reason)->toBe('Compensating controls no longer exist in production.')
->and($revoked->revoked_at)->not->toBeNull()
->and($revoked->evidenceReferences)->toHaveCount(1)
->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
->and(AuditLog::query()
->where('action', AuditActionId::FindingExceptionRevoked->value)
->where('resource_type', 'finding_exception')
->where('resource_id', (string) $revoked->getKey())
->exists())->toBeTrue();
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertSee('Risk governance')
->assertSee('Revoked');
});

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\FindingException;
use App\Services\Findings\FindingExceptionService;
use App\Support\Audit\AuditActionId;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('requests and approves a finding exception through the finding view and canonical queue', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = \App\Models\User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
$this->actingAs($requester);
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->callAction('request_exception', [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Awaiting remediation window',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
])
->assertHasNoActionErrors();
$exception = FindingException::query()->where('finding_id', (int) $finding->getKey())->first();
expect($exception)->toBeInstanceOf(FindingException::class)
->and($exception?->status)->toBe(FindingException::STATUS_PENDING);
$this->actingAs($approver);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::withQueryParams([
'exception' => (int) $exception?->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertActionVisible('approve_selected_exception')
->assertSee('Awaiting remediation window');
$pendingException = FindingException::query()->findOrFail((int) $exception?->getKey());
app(FindingExceptionService::class)->approve($pendingException, $approver, [
'effective_from' => now()->addHour()->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
'approval_reason' => 'Approved with compensating controls',
]);
expect($exception?->fresh()?->status)->toBe(FindingException::STATUS_ACTIVE)
->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
->and(AuditLog::query()
->where('action', AuditActionId::FindingExceptionRequested->value)
->where('resource_type', 'finding_exception')
->where('resource_id', (string) $exception?->getKey())
->exists())->toBeTrue()
->and(AuditLog::query()
->where('action', AuditActionId::FindingExceptionApproved->value)
->where('resource_type', 'finding_exception')
->where('resource_id', (string) $exception?->getKey())
->exists())->toBeTrue();
});
it('requests and rejects a finding exception while keeping the finding out of accepted risk', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = \App\Models\User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
$this->actingAs($requester);
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->callAction('request_exception', [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Awaiting vendor remediation timeline',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(21)->toDateTimeString(),
])
->assertHasNoActionErrors();
$exception = FindingException::query()->where('finding_id', (int) $finding->getKey())->firstOrFail();
$this->actingAs($approver);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::withQueryParams([
'exception' => (int) $exception->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertActionVisible('reject_selected_exception')
->assertSee('Awaiting vendor remediation timeline');
app(FindingExceptionService::class)->reject($exception->fresh(), $approver, [
'rejection_reason' => 'Remediation must be completed before acceptance is granted.',
]);
expect($exception->fresh()?->status)->toBe(FindingException::STATUS_REJECTED)
->and($finding->fresh()?->status)->toBe(Finding::STATUS_NEW)
->and(AuditLog::query()
->where('action', AuditActionId::FindingExceptionRequested->value)
->where('resource_type', 'finding_exception')
->where('resource_id', (string) $exception->getKey())
->exists())->toBeTrue()
->and(AuditLog::query()
->where('action', AuditActionId::FindingExceptionRejected->value)
->where('resource_type', 'finding_exception')
->where('resource_id', (string) $exception->getKey())
->exists())->toBeTrue();
});

View File

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows an expiring governance cue on finding detail for entitled viewers', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $requester->getKey(),
'owner_user_id' => (int) $requester->getKey(),
'approved_by_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_EXPIRING,
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
'request_reason' => 'Short-lived exception while remediation is scheduled',
'approval_reason' => 'Compensating controls accepted',
'requested_at' => now()->subDays(8),
'approved_at' => now()->subDays(7),
'effective_from' => now()->subDays(7),
'expires_at' => now()->addDays(2),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertSee('Risk governance')
->assertSee('Expiring');
});
it('shows a governance warning when a finding is marked as accepted risk without a valid exception', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertSee('Risk governance')
->assertSee('without a valid exception record');
expect(app(FindingRiskGovernanceResolver::class)->resolveFindingState($finding->fresh()))
->toBe('risk_accepted_without_valid_exception');
});
it('surfaces expired and revoked exceptions as governance warnings instead of valid accepted risk', function (string $mode, string $expectedWarning, string $expectedState): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Temporary exception',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
$exception = $service->approve($requested, $approver, [
'effective_from' => now()->subDays(10)->toDateTimeString(),
'expires_at' => $mode === 'expired'
? now()->subDay()->toDateTimeString()
: now()->addDays(14)->toDateTimeString(),
'approval_reason' => 'Approved with controls',
]);
if ($mode === 'expired') {
$exception = app(FindingRiskGovernanceResolver::class)->syncExceptionState($exception->fresh());
} else {
$exception = $service->revoke($exception->fresh(), $requester, [
'revocation_reason' => 'Compensating controls were removed.',
]);
}
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertSee($expectedWarning);
Livewire::test(ListFindingExceptions::class)
->assertSee($expectedWarning);
expect(app(FindingRiskGovernanceResolver::class)->resolveFindingState($finding->fresh('findingException')))
->toBe($expectedState);
})->with([
'expired exception' => ['expired', 'expired and no longer governs accepted risk', 'expired_exception'],
'revoked exception' => ['revoked', 'was revoked and no longer governs accepted risk', 'revoked_exception'],
]);
it('keeps historical exceptions visible while requiring a fresh decision after a finding reopens', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Temporary exception while remediation is scheduled',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
]);
$service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(20)->toDateTimeString(),
'approval_reason' => 'Approved with controls',
]);
$finding->forceFill([
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now(),
'closed_reason' => null,
'closed_at' => null,
])->save();
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertSee('fresh decision is required');
Livewire::test(ListFindingExceptions::class)
->assertSee('fresh decision is required');
expect(app(FindingRiskGovernanceResolver::class)->resolveFindingState($finding->fresh('findingException')))
->toBe('ungoverned');
});

View File

@ -4,6 +4,7 @@
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
@ -60,7 +61,7 @@
->and($finding->due_at)->not->toBeNull();
});
it('supports close and risk accept via row actions', function (): void {
it('supports close and request exception via row actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
@ -69,7 +70,7 @@
'status' => Finding::STATUS_NEW,
]);
$riskFinding = Finding::factory()->for($tenant)->create([
$exceptionFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
]);
@ -80,16 +81,24 @@
->assertHasNoTableActionErrors();
Livewire::test(ListFindings::class)
->callTableAction('risk_accept', $riskFinding, [
'closed_reason' => 'accepted by security',
->callTableAction('request_exception', $exceptionFinding, [
'owner_user_id' => (int) $user->getKey(),
'request_reason' => 'accepted by security',
'review_due_at' => now()->addDays(14)->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
])
->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');
$exception = FindingException::query()
->where('finding_id', (int) $exceptionFinding->getKey())
->first();
expect($exception)->toBeInstanceOf(FindingException::class)
->and($exception?->status)->toBe(FindingException::STATUS_PENDING)
->and($exception?->request_reason)->toBe('accepted by security');
});
it('assigns owners and assignees via row action and rejects non-member ids', function (): void {

View File

@ -22,13 +22,17 @@
->assertTableActionVisible('triage', $finding)
->assertTableActionDisabled('triage', $finding)
->assertTableActionVisible('resolve', $finding)
->assertTableActionDisabled('resolve', $finding);
->assertTableActionDisabled('resolve', $finding)
->assertTableActionVisible('request_exception', $finding)
->assertTableActionDisabled('request_exception', $finding);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->assertActionVisible('triage')
->assertActionDisabled('triage')
->assertActionVisible('resolve')
->assertActionDisabled('resolve');
->assertActionDisabled('resolve')
->assertActionVisible('request_exception')
->assertActionDisabled('request_exception');
});
it('preserves the expected workflow action surface by finding status', function (): void {
@ -47,13 +51,13 @@
->assertTableActionVisible('reopen', $resolvedFinding)
->assertTableActionHidden('triage', $resolvedFinding)
->assertTableActionHidden('close', $resolvedFinding)
->assertTableActionHidden('risk_accept', $resolvedFinding);
->assertTableActionHidden('request_exception', $resolvedFinding);
Livewire::test(ViewFinding::class, ['record' => $resolvedFinding->getKey()])
->assertActionVisible('reopen')
->assertActionHidden('triage')
->assertActionHidden('close')
->assertActionHidden('risk_accept');
->assertActionHidden('request_exception');
});
it('returns 404 when forged foreign-tenant workflow actions are mounted for protected actions', function (): void {
@ -83,7 +87,7 @@
expect(fn () => $component->instance()->mountTableAction('start_progress', (string) $foreignFinding->getKey()))
->toThrow(NotFoundHttpException::class);
expect(fn () => $component->instance()->mountTableAction('risk_accept', (string) $foreignFinding->getKey()))
expect(fn () => $component->instance()->mountTableAction('request_exception', (string) $foreignFinding->getKey()))
->toThrow(NotFoundHttpException::class);
expect($foreignFinding->refresh()->status)->toBe(Finding::STATUS_TRIAGED);

View File

@ -29,7 +29,7 @@
->assertActionVisible('assign')
->assertActionVisible('resolve')
->assertActionVisible('close')
->assertActionVisible('risk_accept');
->assertActionVisible('request_exception');
Livewire::test(ViewFinding::class, ['record' => $triagedFinding->getKey()])
->assertActionVisible('start_progress');

View File

@ -11,6 +11,8 @@
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\OperationRunResource;
@ -165,6 +167,7 @@
BaselineSnapshotResource::class => BaselineSnapshotResource::actionSurfaceDeclaration(),
EntraGroupResource::class => EntraGroupResource::actionSurfaceDeclaration(),
EvidenceSnapshotResource::class => EvidenceSnapshotResource::actionSurfaceDeclaration(),
FindingExceptionResource::class => FindingExceptionResource::actionSurfaceDeclaration(),
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
@ -241,6 +244,32 @@
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse();
});
it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$declaration = FindingExceptionResource::actionSurfaceDeclaration();
expect((string) ($declaration->exemption(ActionSurfaceSlot::ListRowMoreMenu)?->reason ?? ''))
->toContain('avoids a More menu');
expect((string) ($declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup)?->reason ?? ''))
->toContain('omit bulk actions');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$livewire = Livewire::test(ListFindingExceptions::class)
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
expect(collect($rowActions)->contains(static fn ($action): bool => $action instanceof ActionGroup))->toBeFalse();
expect(collect($rowActions)->map(static fn ($action): ?string => $action->getName())->filter()->values()->all())
->toEqualCanonicalizing(['renew_exception', 'revoke_exception']);
expect($table->getBulkActions())->toBeEmpty();
});
it('documents the guided alert delivery empty state without introducing a list-header CTA', function (): void {
$declaration = AlertDeliveryResource::actionSurfaceDeclaration();

View File

@ -20,7 +20,7 @@
$root.'/public/build',
];
$statusLikeTokenPattern = '/[\'"](?:queued|running|completed|pending|succeeded|partial|failed|cancelled|canceled|applied|dry_run|manual_required|mapped_existing|created|created_copy|skipped|blocking|acknowledged|new|low|medium|high)[\'"]/';
$statusLikeTokenPattern = '/[\'"](?:queued|running|completed|pending|active|expiring|expired|rejected|revoked|superseded|succeeded|partial|failed|cancelled|canceled|applied|dry_run|manual_required|mapped_existing|created|created_copy|skipped|blocking|acknowledged|new|risk_accepted|low|medium|high)[\'"]/';
$inlineColorStartPattern = '/->color\\s*\\(\\s*(?:fn|function)\\b/';
$inlineLabelStartPattern = '/->formatStateUsing\\s*\\(\\s*(?:fn|function)\\b/';

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Resources\FindingExceptionResource;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
use App\Support\WorkspaceIsolation\TenantOwnedTables;
@ -106,3 +107,16 @@ function tenantOwnedFamilySource(string $className): string
expect($exceptionMetadata[$surfaceName]['still_required_checks'])->not->toBeEmpty();
}
});
it('keeps finding exception discovery tenant-scoped and global-search disabled', function (): void {
$source = tenantOwnedFamilySource(FindingExceptionResource::class);
expect(preg_match('/protected\s+static\s+bool\s+\$isGloballySearchable\s*=\s*false;/', $source) === 1)
->toBeTrue('FindingExceptionResource must keep global search disabled until a tenant-safe global search flow is introduced.');
expect(preg_match('/getTenantOwnedEloquentQuery\s*\(\)\s*->with\(static::relationshipsForView\(\)\)/s', $source) === 1)
->toBeTrue('FindingExceptionResource must derive list queries from the canonical tenant-owned query helper and scoped relationship loader.');
expect(preg_match('/resolveTenantOwnedRecordOrFail\s*\(\$record,\s*parent::getEloquentQuery\(\)->with\(static::relationshipsForView\(\)\)\)/s', $source) === 1)
->toBeTrue('FindingExceptionResource must resolve detail records through the shared tenant-owned resolver with the same scoped relationships.');
});

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Finding;
use App\Models\FindingException;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows only entitled tenants in the canonical queue and supports tenant and validity filters', function (): void {
[$approver, $tenantA] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$tenantB = \App\Models\Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $approver, role: 'owner', workspaceRole: 'manager');
$tenantC = \App\Models\Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
$makeException = function (\App\Models\Tenant $tenant, array $attributes): FindingException {
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
return FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $requester->getKey(),
'owner_user_id' => (int) $requester->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Queue visibility test',
'requested_at' => now()->subDays(4),
'review_due_at' => now()->addDays(3),
'evidence_summary' => ['reference_count' => 0],
], $attributes));
};
$expiring = $makeException($tenantA, [
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'effective_from' => now()->subDays(3),
'approved_at' => now()->subDays(3),
'expires_at' => now()->addDays(2),
]);
app(FindingRiskGovernanceResolver::class)->syncExceptionState($expiring);
$rejected = $makeException($tenantB, [
'status' => FindingException::STATUS_REJECTED,
'current_validity_state' => FindingException::VALIDITY_REJECTED,
'rejection_reason' => 'Rejected for queue test',
'rejected_at' => now()->subDay(),
]);
$unauthorized = $makeException($tenantC, []);
$this->actingAs($approver);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::test(FindingExceptionsQueue::class)
->assertCanSeeTableRecords([$expiring, $rejected])
->assertCanNotSeeTableRecords([$unauthorized])
->filterTable('tenant_id', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$rejected])
->assertCanNotSeeTableRecords([$expiring])
->filterTable('status', FindingException::STATUS_REJECTED)
->assertCanSeeTableRecords([$rejected]);
Livewire::withQueryParams([
'tenant' => (string) $tenantB->external_id,
])
->test(FindingExceptionsQueue::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertActionVisible('view_tenant_register');
Livewire::withQueryParams([
'exception' => (int) $expiring->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertSee('Expiring')
->assertSee($tenantA->name);
});

View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Services\ReviewPackService;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
it('stores only valid governed accepted risk in review pack summaries and exports', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
]);
OperationRun::factory()->forTenant($tenant)->create();
/** @var FindingExceptionService $exceptionService */
$exceptionService = app(FindingExceptionService::class);
$createApprovedException = function (Finding $finding, string $expiresAt) use ($exceptionService, $user, $tenant, $approver): Finding {
$requested = $exceptionService->request($finding, $tenant, $user, [
'owner_user_id' => (int) $user->getKey(),
'request_reason' => 'Temporary exception',
'review_due_at' => now()->addDays(5)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
$exceptionService->approve($requested, $approver, [
'effective_from' => now()->subDays(10)->toDateTimeString(),
'expires_at' => $expiresAt,
'approval_reason' => 'Approved with controls',
]);
return $finding;
};
$createApprovedException(
Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_RISK_ACCEPTED]),
now()->addDays(14)->toDateTimeString(),
);
$expiredFinding = $createApprovedException(
Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_RISK_ACCEPTED]),
now()->subDay()->toDateTimeString(),
);
app(FindingRiskGovernanceResolver::class)->syncExceptionState($expiredFinding->findingException()->firstOrFail());
$revokedFinding = $createApprovedException(
Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_RISK_ACCEPTED]),
now()->addDays(14)->toDateTimeString(),
);
$exceptionService->revoke($revokedFinding->findingException()->firstOrFail(), $user, [
'revocation_reason' => 'Controls removed',
]);
Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var EvidenceSnapshotService $snapshotService */
$snapshotService = app(EvidenceSnapshotService::class);
$payload = $snapshotService->buildSnapshotPayload($tenant);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'fingerprint' => $payload['fingerprint'],
'completeness_state' => $payload['completeness'],
'summary' => $payload['summary'],
'generated_at' => now(),
]);
foreach ($payload['items'] as $item) {
$snapshot->items()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'dimension_key' => $item['dimension_key'],
'state' => $item['state'],
'required' => $item['required'],
'source_kind' => $item['source_kind'],
'source_record_type' => $item['source_record_type'],
'source_record_id' => $item['source_record_id'],
'source_fingerprint' => $item['source_fingerprint'],
'measured_at' => $item['measured_at'],
'freshness_at' => $item['freshness_at'],
'summary_payload' => $item['summary_payload'],
'sort_order' => $item['sort_order'],
]);
}
/** @var ReviewPackService $reviewPackService */
$reviewPackService = app(ReviewPackService::class);
$pack = $reviewPackService->generate($tenant, $user, [
'include_pii' => true,
'include_operations' => true,
]);
$job = new GenerateReviewPackJob(
reviewPackId: (int) $pack->getKey(),
operationRunId: (int) $pack->operation_run_id,
);
app()->call([$job, 'handle']);
$pack->refresh();
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and($pack->status)->toBe(ReviewPackStatus::Ready->value)
->and($pack->summary['risk_acceptance'] ?? null)->toBe([
'status_marked_count' => 4,
'valid_governed_count' => 1,
'warning_count' => 3,
'expired_count' => 1,
'revoked_count' => 1,
'missing_exception_count' => 1,
]);
$zipContent = Storage::disk('exports')->get((string) $pack->file_path);
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-risk-');
file_put_contents($tempFile, $zipContent);
$zip = new \ZipArchive;
$zip->open($tempFile);
$summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR);
expect($summary['risk_acceptance'] ?? null)->toBe([
'status_marked_count' => 4,
'valid_governed_count' => 1,
'warning_count' => 3,
'expired_count' => 1,
'revoked_count' => 1,
'missing_exception_count' => 1,
]);
$zip->close();
unlink($tempFile);
});

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Models\FindingException;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps finding exception lifecycle states to canonical badge semantics', function (): void {
$pending = BadgeCatalog::spec(BadgeDomain::FindingExceptionStatus, FindingException::STATUS_PENDING);
expect($pending->label)->toBe('Pending')
->and($pending->color)->toBe('warning');
$expiring = BadgeCatalog::spec(BadgeDomain::FindingExceptionStatus, FindingException::STATUS_EXPIRING);
expect($expiring->label)->toBe('Expiring')
->and($expiring->color)->toBe('warning');
$expired = BadgeCatalog::spec(BadgeDomain::FindingExceptionStatus, FindingException::STATUS_EXPIRED);
expect($expired->label)->toBe('Expired')
->and($expired->color)->toBe('danger');
$revoked = BadgeCatalog::spec(BadgeDomain::FindingExceptionStatus, FindingException::STATUS_REVOKED);
expect($revoked->label)->toBe('Revoked')
->and($revoked->color)->toBe('danger');
});
it('maps governance validity states to canonical badge semantics', function (): void {
$valid = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_VALID);
expect($valid->label)->toBe('Valid')
->and($valid->color)->toBe('success');
$expiring = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_EXPIRING);
expect($expiring->label)->toBe('Expiring')
->and($expiring->color)->toBe('warning');
$expired = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_EXPIRED);
expect($expired->label)->toBe('Expired')
->and($expired->color)->toBe('danger');
$missingSupport = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_MISSING_SUPPORT);
expect($missingSupport->label)->toBe('Missing support')
->and($missingSupport->color)->toBe('gray');
});

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prevents updating finding exception decisions after creation', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary exception request',
'requested_at' => now(),
'review_due_at' => now()->addWeek(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => 'Temporary exception request',
'metadata' => [],
'decided_at' => now(),
]);
expect(fn () => $decision->update(['reason' => 'Changed']))
->toThrow(LogicException::class, 'Finding exception decisions are append-only.');
});
it('prevents deleting finding exception decisions after creation', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary exception request',
'requested_at' => now(),
'review_due_at' => now()->addWeek(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => 'Temporary exception request',
'metadata' => [],
'decided_at' => now(),
]);
expect(fn () => $decision->delete())
->toThrow(LogicException::class, 'Finding exception decisions are append-only.');
});

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionEvidenceReference;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('casts evidence reference summary payload and measured timestamp', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary exception request',
'requested_at' => now(),
'review_due_at' => now()->addWeek(),
'evidence_summary' => ['reference_count' => 1],
]);
$reference = $exception->evidenceReferences()->create([
'tenant_id' => (int) $tenant->getKey(),
'source_type' => 'evidence_snapshot',
'source_id' => 'snapshot-001',
'source_fingerprint' => 'fp-001',
'label' => 'Snapshot summary',
'summary_payload' => ['summary' => 'Intelligible even if the live evidence disappears'],
'measured_at' => now()->subHour(),
]);
$freshReference = $reference->fresh();
expect($freshReference)->toBeInstanceOf(FindingExceptionEvidenceReference::class)
->and($freshReference?->summary_payload)->toBe(['summary' => 'Intelligible even if the live evidence disappears'])
->and($freshReference?->measured_at)->not->toBeNull()
->and($freshReference?->exception)->toBeInstanceOf(FindingException::class)
->and($freshReference?->tenant)->toBeInstanceOf(\App\Models\Tenant::class);
});

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('links finding exceptions to finding and decision history', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary exception request',
'requested_at' => now(),
'review_due_at' => now()->addWeek(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => 'Temporary exception request',
'metadata' => [],
'decided_at' => now(),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
expect($exception->fresh()?->finding)->toBeInstanceOf(Finding::class)
->and($exception->fresh()?->decisions)->toHaveCount(1)
->and($exception->fresh()?->currentDecision)->toBeInstanceOf(FindingExceptionDecision::class);
});
it('keeps decision rows append only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary exception request',
'requested_at' => now(),
'review_due_at' => now()->addWeek(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => 'Temporary exception request',
'metadata' => [],
'decided_at' => now(),
]);
expect(fn () => $decision->update(['reason' => 'Changed']))
->toThrow(LogicException::class, 'Finding exception decisions are append-only.');
});

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use App\Services\Findings\FindingExceptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
uses(RefreshDatabase::class);
it('creates a pending exception request for an open finding', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
Carbon::setTestNow('2026-03-19 10:00:00');
$exception = app(FindingExceptionService::class)->request($finding, $tenant, $user, [
'owner_user_id' => (int) $user->getKey(),
'request_reason' => 'Operational risk accepted temporarily',
'review_due_at' => now()->addDays(14)->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
]);
expect($exception->status)->toBe(FindingException::STATUS_PENDING)
->and($exception->requested_by_user_id)->toBe((int) $user->getKey())
->and($exception->request_reason)->toBe('Operational risk accepted temporarily')
->and($exception->currentDecision?->decision_type)->toBe('requested');
Carbon::setTestNow();
});
it('blocks overlapping pending requests for the same finding', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
$service = app(FindingExceptionService::class);
$service->request($finding, $tenant, $user, [
'owner_user_id' => (int) $user->getKey(),
'request_reason' => 'First request',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
]);
expect(fn () => $service->request($finding, $tenant, $user, [
'owner_user_id' => (int) $user->getKey(),
'request_reason' => 'Second request',
'review_due_at' => now()->addDays(10)->toDateTimeString(),
]))->toThrow(InvalidArgumentException::class, 'An exception request is already pending for this finding.');
});
it('blocks self approval and approves with finding mutation otherwise', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = \App\Models\User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
$service = app(FindingExceptionService::class);
$exception = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Needs temporary exception',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
]);
expect(fn () => $service->approve($exception, $requester, [
'effective_from' => now()->addHour()->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
]))->toThrow(InvalidArgumentException::class, 'Requesters cannot approve their own exception requests.');
$approved = $service->approve($exception->fresh(), $approver, [
'effective_from' => now()->addHour()->toDateTimeString(),
'expires_at' => now()->addDays(30)->toDateTimeString(),
'approval_reason' => 'Approved with compensating controls',
]);
expect($approved->status)->toBe(FindingException::STATUS_ACTIVE)
->and($approved->currentDecision?->decision_type)->toBe('approved')
->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED);
});