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
1112 lines
52 KiB
PHP
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();
|
|
}
|
|
}
|