getId() === 'admin') { return false; } return parent::shouldRegisterNavigation(); } public static function canViewAny(): bool { $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } if (! $user->canAccessTenant($tenant)) { return false; } return $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant); } public static function canView(Model $record): bool { $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } if (! $user->canAccessTenant($tenant)) { return false; } if (! $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant)) { return false; } if ($record instanceof Finding) { return (int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id; } 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::ClickableRow->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('drift_surface_label') ->label('Drift surface') ->badge() ->color('gray') ->state(fn (Finding $record): ?string => static::driftSurfaceLabel($record)) ->visible(fn (Finding $record): bool => static::driftSurfaceLabel($record) !== null), TextEntry::make('evidence_fidelity') ->label('Fidelity') ->badge() ->formatStateUsing(fn (?string $state): string => is_string($state) && $state !== '' ? $state : 'meta') ->color(fn (?string $state): string => match ((string) $state) { 'content' => 'success', 'meta' => 'gray', default => 'gray', }), 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('—') ->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()), TextEntry::make('subject_type') ->label('Subject type') ->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)), TextEntry::make('subject_external_id')->label('External ID')->copyable(), TextEntry::make('baseline_operation_run_id') ->label('Baseline run') ->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (Finding $record): ?string => $record->baseline_operation_run_id ? OperationRunLinks::tenantlessView((int) $record->baseline_operation_run_id, static::findingRunNavigationContext($record)) : null) ->openUrlInNewTab(), TextEntry::make('current_operation_run_id') ->label('Current run') ->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (Finding $record): ?string => $record->current_operation_run_id ? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record)) : 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('Evidence') ->schema([ TextEntry::make('redaction_integrity_note') ->label('Integrity note') ->state(fn (Finding $record): ?string => static::redactionIntegrityNoteForRecord($record)) ->columnSpanFull() ->visible(fn (Finding $record): bool => static::redactionIntegrityNoteForRecord($record) !== null), TextEntry::make('baseline_evidence_fidelity') ->label('Baseline fidelity') ->badge() ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'baseline.provenance.fidelity')) ->color(fn (?string $state): string => match ((string) $state) { 'content' => 'success', 'meta' => 'gray', default => 'gray', }) ->placeholder('—'), TextEntry::make('baseline_evidence_source') ->label('Baseline source') ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'baseline.provenance.source')) ->placeholder('—'), TextEntry::make('baseline_evidence_observed_at') ->label('Baseline observed at') ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'baseline.provenance.observed_at')) ->placeholder('—') ->copyable(), TextEntry::make('current_evidence_fidelity') ->label('Current fidelity') ->badge() ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'current.provenance.fidelity')) ->color(fn (?string $state): string => match ((string) $state) { 'content' => 'success', 'meta' => 'gray', default => 'gray', }) ->placeholder('—'), TextEntry::make('current_evidence_source') ->label('Current source') ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'current.provenance.source')) ->placeholder('—'), TextEntry::make('current_evidence_observed_at') ->label('Current observed at') ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'current.provenance.observed_at')) ->placeholder('—') ->copyable(), ]) ->columns(3) ->visible(function (Finding $record): bool { $evidence = is_array($record->evidence_jsonb) ? $record->evidence_jsonb : []; return Arr::has($evidence, 'baseline.provenance') || Arr::has($evidence, 'current.provenance'); }) ->columnSpanFull(), Section::make('Related context') ->schema([ ViewEntry::make('related_context') ->label('') ->view('filament.infolists.entries.related-context') ->state(fn (Finding $record): array => static::relatedContextEntries($record)) ->columnSpanFull(), ]) ->columnSpanFull(), Section::make('Diff') ->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT) ->schema([ TextEntry::make('diff_unavailable') ->label('') ->state(fn (Finding $record): string => static::driftDiffUnavailableMessage($record)) ->visible(fn (Finding $record): bool => ! static::canRenderDriftDiff($record)) ->columnSpanFull(), ViewEntry::make('rbac_role_definition_diff') ->label('') ->view('filament.infolists.entries.rbac-role-definition-diff') ->state(fn (Finding $record): array => Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition', [])) ->visible(fn (Finding $record): bool => static::driftSummaryKind($record) === 'rbac_role_definition' && is_array(Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition'))) ->columnSpanFull(), ViewEntry::make('settings_diff') ->label('') ->view('filament.infolists.entries.normalized-diff') ->state(function (Finding $record): array { $tenant = static::resolveTenantContextForCurrentPanel(); if (! $tenant) { return static::unavailableDiffState('No tenant context'); } [$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant); if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) { return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'); } $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 => static::canRenderDriftDiff($record) && 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 = static::resolveTenantContextForCurrentPanel(); if (! $tenant) { return static::unavailableDiffState('No tenant context'); } [$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant); if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) { return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'); } return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion); }) ->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && 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 = static::resolveTenantContextForCurrentPanel(); if (! $tenant) { return static::unavailableDiffState('No tenant context'); } [$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant); if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) { return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'); } return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion); }) ->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && 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(), ]); } private static function driftChangeType(Finding $record): string { $changeType = Arr::get($record->evidence_jsonb ?? [], 'change_type'); return is_string($changeType) ? trim($changeType) : ''; } private static function driftSummaryKind(Finding $record): string { $summaryKind = Arr::get($record->evidence_jsonb ?? [], 'summary.kind'); return is_string($summaryKind) ? trim($summaryKind) : ''; } private static function isRbacRoleDefinitionDrift(Finding $record): bool { return static::driftSummaryKind($record) === 'rbac_role_definition' || (string) Arr::get($record->evidence_jsonb ?? [], 'policy_type') === 'intuneRoleDefinition'; } private static function driftSurfaceLabel(Finding $record): ?string { if (static::isRbacRoleDefinitionDrift($record)) { return __('findings.drift.rbac_role_definition'); } return null; } private static function subjectTypeLabel(Finding $record, mixed $state): string { $policyType = Arr::get($record->evidence_jsonb ?? [], 'policy_type'); if (is_string($policyType) && $policyType !== '') { $translated = __('findings.subject_types.'.$policyType); if ($translated !== 'findings.subject_types.'.$policyType) { return $translated; } } $value = is_string($state) ? trim($state) : ''; return $value !== '' ? $value : '—'; } private static function driftContextDescription(Finding $record): ?string { if (! static::isRbacRoleDefinitionDrift($record)) { return null; } $parts = [__('findings.drift.rbac_role_definition')]; $diffKind = Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition.diff_kind'); if (is_string($diffKind) && $diffKind !== '') { $parts[] = __('findings.rbac.'.$diffKind); } return implode(' | ', array_filter($parts, fn (?string $part): bool => is_string($part) && $part !== '')); } public static function findingSubheading(Finding $record): ?string { $parts = []; if (static::isRbacRoleDefinitionDrift($record)) { $parts[] = __('findings.rbac.detail_heading'); $parts[] = __('findings.rbac.detail_subheading'); } $integrity = static::redactionIntegrityNoteForRecord($record); if (is_string($integrity) && trim($integrity) !== '') { $parts[] = $integrity; } return $parts !== [] ? implode(' ', $parts) : null; } private static function hasBaselinePolicyVersionReference(Finding $record): bool { return is_numeric(Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id')); } private static function hasCurrentPolicyVersionReference(Finding $record): bool { return is_numeric(Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id')); } private static function canRenderDriftDiff(Finding $record): bool { if (static::driftSummaryKind($record) === 'rbac_role_definition') { return is_array(Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition')); } return match (static::driftChangeType($record)) { 'missing_policy' => static::hasBaselinePolicyVersionReference($record), 'unexpected_policy' => static::hasCurrentPolicyVersionReference($record), default => static::hasBaselinePolicyVersionReference($record) && static::hasCurrentPolicyVersionReference($record), }; } public static function redactionIntegrityNoteForRecord(Finding $record): ?string { $evidence = is_array($record->evidence_jsonb) ? $record->evidence_jsonb : []; return RedactionIntegrity::noteForFindingEvidence($evidence); } private static function driftDiffUnavailableMessage(Finding $record): string { if (static::driftSummaryKind($record) === 'rbac_role_definition') { return 'RBAC evidence unavailable — normalized role definition evidence is missing.'; } return match (static::driftChangeType($record)) { 'missing_policy' => 'Diff unavailable — missing baseline policy version reference.', 'unexpected_policy' => 'Diff unavailable — missing current policy version reference.', default => 'Diff unavailable — missing baseline/current policy version references.', }; } /** * @return array{0: ?PolicyVersion, 1: ?PolicyVersion} */ private static function resolveDriftDiffVersions(Finding $record, Tenant $tenant): array { $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 [$baselineVersion, $currentVersion]; } private static function hasRequiredDiffVersions( Finding $record, ?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, ): bool { return match (static::driftChangeType($record)) { 'missing_policy' => $baselineVersion instanceof PolicyVersion, 'unexpected_policy' => $currentVersion instanceof PolicyVersion, default => $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion, }; } /** * @return array{summary: array{message: string}, added: array, removed: array, changed: array} */ private static function unavailableDiffState(string $message): array { return [ 'summary' => ['message' => $message], 'added' => [], 'removed' => [], 'changed' => [], ]; } public static function table(Table $table): Table { return $table ->defaultSort('created_at', 'desc') ->paginated(TablePaginationProfiles::resource()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->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('evidence_fidelity') ->label('Fidelity') ->badge() ->formatStateUsing(fn (?string $state): string => is_string($state) && $state !== '' ? $state : 'meta') ->color(fn (?string $state): string => match ((string) $state) { 'content' => 'success', 'meta' => 'gray', default => 'gray', }) ->sortable() ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('subject_display_name') ->label('Subject') ->placeholder('—') ->searchable() ->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()) ->description(fn (Finding $record): ?string => static::driftContextDescription($record)), Tables\Columns\TextColumn::make('subject_type') ->label('Subject type') ->searchable() ->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)) ->toggleable(isToggledHiddenByDefault: true), 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(FilterOptionCatalog::findingStatuses()) ->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\SelectFilter::make('evidence_fidelity') ->label('Fidelity') ->options([ 'content' => 'Content', 'meta' => 'Meta', ]), 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; }), FilterPresets::dateRange('created_at', 'Created', 'created_at'), ]) ->recordUrl(static fn (Finding $record): ?string => static::canView($record) ? static::getUrl('view', ['record' => $record]) : null) ->actions([ static::primaryRelatedAction(), 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 = static::resolveTenantContextForCurrentPanel(); $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 { $record = static::resolveProtectedFindingRecordOrFail($record); $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 = static::resolveTenantContextForCurrentPanel(); $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 { $record = static::resolveProtectedFindingRecordOrFail($record); $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 = static::resolveTenantContextForCurrentPanel(); $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 { $record = static::resolveProtectedFindingRecordOrFail($record); $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 = static::resolveTenantContextForCurrentPanel(); $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 { $record = static::resolveProtectedFindingRecordOrFail($record); $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 = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return; } $reason = (string) ($data['closed_reason'] ?? ''); $acceptedCount = 0; $skippedCount = 0; $failedCount = 0; foreach ($records as $record) { if (! $record instanceof Finding) { $skippedCount++; continue; } if ((int) $record->tenant_id !== (int) $tenant->getKey()) { $skippedCount++; continue; } if (! $record->hasOpenStatus()) { $skippedCount++; continue; } try { $record = static::resolveProtectedFindingRecordOrFail($record); $workflow->riskAccept($record, $tenant, $user, $reason); $acceptedCount++; } catch (Throwable) { $failedCount++; } } $body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.'; if ($skippedCount > 0) { $body .= " Skipped {$skippedCount}."; } if ($failedCount > 0) { $body .= " Failed {$failedCount}."; } Notification::make() ->title('Bulk risk accept completed') ->body($body) ->status($failedCount > 0 ? 'warning' : 'success') ->send(); }) ->deselectRecordsAfterCompletion(), ) ->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(), ])->label('More'), ]) ->emptyStateHeading('No findings match this view') ->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface new findings.') ->emptyStateIcon('heroicon-o-exclamation-triangle'); } public static function getEloquentQuery(): Builder { return static::getTenantOwnedEloquentQuery() ->with(['assigneeUser', 'ownerUser', 'closedByUser']) ->withSubjectDisplayName(); } public static function resolveScopedRecordOrFail(int|string $key): Model { return static::resolveTenantOwnedRecordOrFail( $key, parent::getEloquentQuery() ->with(['assigneeUser', 'ownerUser', 'closedByUser']) ->withSubjectDisplayName(), ); } /** * @return list */ public static function relatedContextEntries(Finding $record): array { return app(RelatedNavigationResolver::class) ->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $record); } private static function primaryRelatedAction(): Actions\Action { return Actions\Action::make('primary_drill_down') ->label(fn (Finding $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record') ->url(fn (Finding $record): ?string => static::primaryRelatedEntry($record)?->targetUrl) ->hidden(fn (Finding $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false)) ->color('gray'); } private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry { return app(RelatedNavigationResolver::class) ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record); } private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext { $tenant = $record->tenant; return new CanonicalNavigationContext( sourceSurface: 'finding.detail_section', canonicalRouteName: 'admin.operations.view', tenantId: $tenant?->getKey(), backLinkLabel: 'Back to finding', backLinkUrl: static::getUrl('view', ['record' => $record], tenant: $tenant), filterPayload: $tenant instanceof Tenant ? [ 'tableFilters' => [ 'tenant_id' => ['value' => (string) $tenant->getKey()], ], ] : [], ); } 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(static::freshWorkflowStatus($record), [ 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(static::freshWorkflowStatus($record), [ 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 => static::freshWorkflowRecord($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 => static::freshWorkflowRecord($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') ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) ->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') ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus()) ->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(static::freshWorkflowStatus($record))) ->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 { $record = static::resolveProtectedFindingRecordOrFail($record); $tenant = static::resolveTenantContextForCurrentPanel(); $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; } if ((int) $record->workspace_id !== (int) $tenant->workspace_id) { Notification::make() ->title('Finding belongs to a different workspace') ->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(); } private static function freshWorkflowRecord(Finding $record): Finding { return static::resolveProtectedFindingRecordOrFail($record); } private static function freshWorkflowStatus(Finding $record): string { return (string) static::freshWorkflowRecord($record)->status; } private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record): Finding { $resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record); if (! $resolvedRecord instanceof Finding) { abort(404); } return $resolvedRecord; } /** * @return array */ private static function tenantMemberOptions(): array { $tenant = static::resolveTenantContextForCurrentPanel(); 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(); } }