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 */ public static function workflowActions(): array { return [ static::triageAction(), static::startProgressAction(), static::assignAction(), static::resolveAction(), static::closeAction(), static::riskAcceptAction(), static::reopenAction(), ]; } public static function triageAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('triage') ->label('Triage') ->icon('heroicon-o-check') ->color('gray') ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, Finding::STATUS_ACKNOWLEDGED, ], true)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, successTitle: 'Finding triaged', callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->triage($finding, $tenant, $user), ); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } public static function startProgressAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('start_progress') ->label('Start progress') ->icon('heroicon-o-play') ->color('info') ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED, ], true)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, successTitle: 'Finding moved to in progress', callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->startProgress($finding, $tenant, $user), ); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } public static function assignAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('assign') ->label('Assign') ->icon('heroicon-o-user-plus') ->color('gray') ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->fillForm(fn (Finding $record): array => [ 'assignee_user_id' => $record->assignee_user_id, 'owner_user_id' => $record->owner_user_id, ]) ->form([ Select::make('assignee_user_id') ->label('Assignee') ->placeholder('Unassigned') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), Select::make('owner_user_id') ->label('Owner') ->placeholder('Unassigned') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), ]) ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, successTitle: 'Finding assignment updated', callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign( $finding, $tenant, $user, is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null, is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null, ), ); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } public static function resolveAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('resolve') ->label('Resolve') ->icon('heroicon-o-check-badge') ->color('success') ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->requiresConfirmation() ->form([ Textarea::make('resolved_reason') ->label('Resolution reason') ->rows(3) ->required() ->maxLength(255), ]) ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, successTitle: 'Finding resolved', callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve( $finding, $tenant, $user, (string) ($data['resolved_reason'] ?? ''), ), ); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_RESOLVE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } public static function closeAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('close') ->label('Close') ->icon('heroicon-o-x-circle') ->color('danger') ->requiresConfirmation() ->form([ Textarea::make('closed_reason') ->label('Close reason') ->rows(3) ->required() ->maxLength(255), ]) ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, successTitle: 'Finding closed', callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close( $finding, $tenant, $user, (string) ($data['closed_reason'] ?? ''), ), ); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_CLOSE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } public static function riskAcceptAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('risk_accept') ->label('Risk accept') ->icon('heroicon-o-shield-check') ->color('warning') ->requiresConfirmation() ->form([ Textarea::make('closed_reason') ->label('Risk acceptance reason') ->rows(3) ->required() ->maxLength(255), ]) ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, successTitle: 'Finding marked as risk accepted', callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept( $finding, $tenant, $user, (string) ($data['closed_reason'] ?? ''), ), ); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } public static function reopenAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('reopen') ->label('Reopen') ->icon('heroicon-o-arrow-uturn-left') ->color('warning') ->requiresConfirmation() ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( record: $record, successTitle: 'Finding reopened', callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen($finding, $tenant, $user), ); }) ) ->preserveVisibility() ->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(); } /** * @param callable(Finding, Tenant, User): Finding $callback */ private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void { $tenant = Tenant::current(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return; } if ((int) $record->tenant_id !== (int) $tenant->getKey()) { Notification::make() ->title('Finding belongs to a different tenant') ->danger() ->send(); return; } try { $callback($record, $tenant, $user); } catch (InvalidArgumentException $e) { Notification::make() ->title('Workflow action failed') ->body($e->getMessage()) ->danger() ->send(); return; } Notification::make() ->title($successTitle) ->success() ->send(); } /** * @return array */ private static function tenantMemberOptions(): array { $tenant = Tenant::current(); if (! $tenant instanceof Tenant) { return []; } return TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->join('users', 'users.id', '=', 'tenant_memberships.user_id') ->orderBy('users.name') ->pluck('users.name', 'users.id') ->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name]) ->all(); } }