TenantAtlas/app/Filament/Resources/FindingResource.php
ahmido 7ac53f4cc4 feat(111): findings workflow + SLA settings (#135)
Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation.

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

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

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #135
2026-02-25 01:48:01 +00:00

1112 lines
52 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\FindingResource\Pages;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
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;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use InvalidArgumentException;
use Throwable;
use UnitEnum;
class FindingResource extends Resource
{
protected static ?string $model = Finding::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
if (! $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant)) {
return false;
}
if ($record instanceof Finding) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Finding')
->schema([
TextEntry::make('finding_type')->badge()->label('Type'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
TextEntry::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
TextEntry::make('scope_key')->label('Scope')->copyable(),
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
TextEntry::make('subject_type')->label('Subject type'),
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
TextEntry::make('baseline_operation_run_id')
->label('Baseline run')
->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
: null)
->openUrlInNewTab(),
TextEntry::make('current_operation_run_id')
->label('Current run')
->url(fn (Finding $record): ?string => $record->current_operation_run_id
? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
: null)
->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
TextEntry::make('sla_days')->label('SLA days')->placeholder('—'),
TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'),
TextEntry::make('owner_user_id')
->label('Owner')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->ownerUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('assignee_user_id')
->label('Assignee')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->assigneeUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'),
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'),
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'),
TextEntry::make('closed_by_user_id')
->label('Closed by')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('created_at')->label('Created')->dateTime(),
])
->columns(2)
->columnSpanFull(),
Section::make('Diff')
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
->schema([
ViewEntry::make('settings_diff')
->label('')
->view('filament.infolists.entries.normalized-diff')
->state(function (Finding $record): array {
$tenant = Tenant::current();
if (! $tenant) {
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
}
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
$baselineVersion = is_numeric($baselineId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
: null;
$currentVersion = is_numeric($currentId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
: null;
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
if (($addedCount + $removedCount + $changedCount) === 0) {
Arr::set(
$diff,
'summary.message',
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
);
}
return $diff;
})
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
->columnSpanFull(),
ViewEntry::make('scope_tags_diff')
->label('')
->view('filament.infolists.entries.scope-tags-diff')
->state(function (Finding $record): array {
$tenant = Tenant::current();
if (! $tenant) {
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
}
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
$baselineVersion = is_numeric($baselineId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
: null;
$currentVersion = is_numeric($currentId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
: null;
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
})
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
->columnSpanFull(),
ViewEntry::make('assignments_diff')
->label('')
->view('filament.infolists.entries.assignments-diff')
->state(function (Finding $record): array {
$tenant = Tenant::current();
if (! $tenant) {
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
}
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
$baselineVersion = is_numeric($baselineId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
: null;
$currentVersion = is_numeric($currentId)
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
: null;
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
})
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
->columnSpanFull(),
])
->collapsed()
->columnSpanFull(),
Section::make('Evidence (Sanitized)')
->schema([
ViewEntry::make('evidence_jsonb')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
Tables\Columns\TextColumn::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
Tables\Columns\TextColumn::make('due_at')
->label('Due')
->dateTime()
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('assigneeUser.name')
->label('Assignee')
->placeholder('—'),
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
])
->filters([
Tables\Filters\Filter::make('open')
->label('Open')
->default()
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
Tables\Filters\Filter::make('overdue')
->label('Overdue')
->query(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery())
->whereNotNull('due_at')
->where('due_at', '<', now())),
Tables\Filters\Filter::make('high_severity')
->label('High severity')
->query(fn (Builder $query): Builder => $query->whereIn('severity', [
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
])),
Tables\Filters\Filter::make('my_assigned')
->label('My assigned')
->query(function (Builder $query): Builder {
$userId = auth()->id();
if (! is_numeric($userId)) {
return $query->whereRaw('1 = 0');
}
return $query->where('assignee_user_id', (int) $userId);
}),
Tables\Filters\SelectFilter::make('status')
->options([
Finding::STATUS_NEW => 'New',
Finding::STATUS_TRIAGED => 'Triaged',
Finding::STATUS_ACKNOWLEDGED => 'Triaged (legacy acknowledged)',
Finding::STATUS_IN_PROGRESS => 'In progress',
Finding::STATUS_REOPENED => 'Reopened',
Finding::STATUS_RESOLVED => 'Resolved',
Finding::STATUS_CLOSED => 'Closed',
Finding::STATUS_RISK_ACCEPTED => 'Risk accepted',
])
->label('Status'),
Tables\Filters\SelectFilter::make('finding_type')
->options([
Finding::FINDING_TYPE_DRIFT => 'Drift',
Finding::FINDING_TYPE_PERMISSION_POSTURE => 'Permission posture',
Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles',
])
->label('Type'),
Tables\Filters\Filter::make('scope_key')
->form([
TextInput::make('scope_key')
->label('Scope key')
->placeholder('Inventory selection hash')
->maxLength(255),
])
->query(function (Builder $query, array $data): Builder {
$scopeKey = $data['scope_key'] ?? null;
if (! is_string($scopeKey) || $scopeKey === '') {
return $query;
}
return $query->where('scope_key', $scopeKey);
}),
Tables\Filters\Filter::make('run_ids')
->label('Run IDs')
->form([
TextInput::make('baseline_operation_run_id')
->label('Baseline run id')
->numeric(),
TextInput::make('current_operation_run_id')
->label('Current run id')
->numeric(),
])
->query(function (Builder $query, array $data): Builder {
$baselineRunId = $data['baseline_operation_run_id'] ?? null;
if (is_numeric($baselineRunId)) {
$query->where('baseline_operation_run_id', (int) $baselineRunId);
}
$currentRunId = $data['current_operation_run_id'] ?? null;
if (is_numeric($currentRunId)) {
$query->where('current_operation_run_id', (int) $currentRunId);
}
return $query;
}),
])
->actions([
Actions\ViewAction::make(),
Actions\ActionGroup::make([
...static::workflowActions(),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('triage_selected')
->label('Triage selected')
->icon('heroicon-o-check')
->color('gray')
->requiresConfirmation()
->action(function (Collection $records, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$triagedCount = 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 (! in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++;
continue;
}
try {
$workflow->triage($record, $tenant, $user);
$triagedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk triage completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('assign_selected')
->label('Assign selected')
->icon('heroicon-o-user-plus')
->color('gray')
->requiresConfirmation()
->form([
Select::make('assignee_user_id')
->label('Assignee')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Select::make('owner_user_id')
->label('Owner')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$assigneeUserId = is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null;
$ownerUserId = is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null;
$assignedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
$assignedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk assign completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('resolve_selected')
->label('Resolve selected')
->icon('heroicon-o-check-badge')
->color('success')
->requiresConfirmation()
->form([
Textarea::make('resolved_reason')
->label('Resolution reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$reason = (string) ($data['resolved_reason'] ?? '');
$resolvedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->resolve($record, $tenant, $user, $reason);
$resolvedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk resolve completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('close_selected')
->label('Close selected')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Close reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$reason = (string) ($data['closed_reason'] ?? '');
$closedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->close($record, $tenant, $user, $reason);
$closedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Closed {$closedCount} finding".($closedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk close completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('risk_accept_selected')
->label('Risk accept selected')
->icon('heroicon-o-shield-check')
->color('warning')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Risk acceptance reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$reason = (string) ($data['closed_reason'] ?? '');
$acceptedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->riskAccept($record, $tenant, $user, $reason);
$acceptedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk risk accept completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
])->label('More'),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
->addSelect([
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
])
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
public static function getPages(): array
{
return [
'index' => Pages\ListFindings::route('/'),
'view' => Pages\ViewFinding::route('/{record}'),
];
}
/**
* @return array<int, Actions\Action>
*/
public static function workflowActions(): array
{
return [
static::triageAction(),
static::startProgressAction(),
static::assignAction(),
static::resolveAction(),
static::closeAction(),
static::riskAcceptAction(),
static::reopenAction(),
];
}
public static function triageAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('triage')
->label('Triage')
->icon('heroicon-o-check')
->color('gray')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding triaged',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->triage($finding, $tenant, $user),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function startProgressAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('start_progress')
->label('Start progress')
->icon('heroicon-o-play')
->color('info')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding moved to in progress',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->startProgress($finding, $tenant, $user),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function assignAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('assign')
->label('Assign')
->icon('heroicon-o-user-plus')
->color('gray')
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->fillForm(fn (Finding $record): array => [
'assignee_user_id' => $record->assignee_user_id,
'owner_user_id' => $record->owner_user_id,
])
->form([
Select::make('assignee_user_id')
->label('Assignee')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Select::make('owner_user_id')
->label('Owner')
->placeholder('Unassigned')
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding assignment updated',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign(
$finding,
$tenant,
$user,
is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null,
is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null,
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function resolveAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('resolve')
->label('Resolve')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->requiresConfirmation()
->form([
Textarea::make('resolved_reason')
->label('Resolution reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding resolved',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
$finding,
$tenant,
$user,
(string) ($data['resolved_reason'] ?? ''),
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function closeAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('close')
->label('Close')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Close reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding closed',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close(
$finding,
$tenant,
$user,
(string) ($data['closed_reason'] ?? ''),
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function riskAcceptAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('risk_accept')
->label('Risk accept')
->icon('heroicon-o-shield-check')
->color('warning')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Risk acceptance reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding marked as risk accepted',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
$finding,
$tenant,
$user,
(string) ($data['closed_reason'] ?? ''),
),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function reopenAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('reopen')
->label('Reopen')
->icon('heroicon-o-arrow-uturn-left')
->color('warning')
->requiresConfirmation()
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
successTitle: 'Finding reopened',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen($finding, $tenant, $user),
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
/**
* @param callable(Finding, Tenant, User): Finding $callback
*/
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
Notification::make()
->title('Finding belongs to a different tenant')
->danger()
->send();
return;
}
try {
$callback($record, $tenant, $user);
} catch (InvalidArgumentException $e) {
Notification::make()
->title('Workflow action failed')
->body($e->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title($successTitle)
->success()
->send();
}
/**
* @return array<int, string>
*/
private static function tenantMemberOptions(): array
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return [];
}
return TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
->orderBy('users.name')
->pluck('users.name', 'users.id')
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
->all();
}
}