Implemented the final operator workflow for the Governance Inbox. This includes refactoring the inbox page, updating finding resources, adding UI enforcement policies, updating related blade views, and adding comprehensive tests for operator workflow and scope contracts. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #418
2568 lines
119 KiB
PHP
2568 lines
119 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
|
|
use App\Filament\Resources\FindingResource\Pages;
|
|
use App\Filament\Support\NormalizedDiffSurface;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\User;
|
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
|
use App\Services\Findings\FindingExceptionService;
|
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
|
use App\Services\Findings\FindingWorkflowService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Filament\FilterOptionCatalog;
|
|
use App\Support\Filament\FilterPresets;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use App\Support\Findings\FindingOutcomeSemantics;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
|
use App\Support\Navigation\NavigationScope;
|
|
use App\Support\Navigation\RelatedContextEntry;
|
|
use App\Support\Navigation\RelatedNavigationResolver;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Rbac\UiTooltips;
|
|
use App\Support\RedactionIntegrity;
|
|
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 App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
|
use BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Forms\Components\DateTimePicker;
|
|
use Filament\Forms\Components\Repeater;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
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 Illuminate\Support\Str;
|
|
use InvalidArgumentException;
|
|
use Throwable;
|
|
use UnitEnum;
|
|
|
|
class FindingResource extends Resource
|
|
{
|
|
use InteractsWithTenantOwnedRecords;
|
|
use ResolvesPanelTenantContext;
|
|
use WorkspaceScopedEnvironmentRoutes;
|
|
|
|
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';
|
|
|
|
public static function shouldRegisterNavigation(): bool
|
|
{
|
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
|
&& parent::shouldRegisterNavigation();
|
|
}
|
|
|
|
public static function getNavigationLabel(): string
|
|
{
|
|
return __('localization.navigation.findings');
|
|
}
|
|
|
|
public static function getNavigationGroup(): string
|
|
{
|
|
return __('localization.navigation.governance');
|
|
}
|
|
|
|
public static function getModelLabel(): string
|
|
{
|
|
return __('localization.navigation.findings');
|
|
}
|
|
|
|
public static function getPluralModelLabel(): string
|
|
{
|
|
return __('localization.navigation.findings');
|
|
}
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $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 ManagedEnvironment || ! $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->managed_environment_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 workflow actions are grouped under "More"; the only inline row action is the related-record drill-down.')
|
|
->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('Status and next action')
|
|
->schema([
|
|
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('finding_terminal_outcome')
|
|
->label('Terminal outcome')
|
|
->state(fn (Finding $record): ?string => static::terminalOutcomeLabel($record))
|
|
->visible(fn (Finding $record): bool => static::terminalOutcomeLabel($record) !== null),
|
|
TextEntry::make('finding_verification_state')
|
|
->label('Verification')
|
|
->state(fn (Finding $record): ?string => static::verificationStateLabel($record))
|
|
->visible(fn (Finding $record): bool => static::verificationStateLabel($record) !== null),
|
|
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('finding_due_attention')
|
|
->label('Due state')
|
|
->badge()
|
|
->state(fn (Finding $record): ?string => static::dueAttentionLabelFor($record))
|
|
->color(fn (Finding $record): string => static::dueAttentionColorFor($record))
|
|
->visible(fn (Finding $record): bool => static::dueAttentionLabelFor($record) !== null),
|
|
TextEntry::make('finding_governance_validity_leading')
|
|
->label('Governance')
|
|
->badge()
|
|
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null),
|
|
TextEntry::make('finding_responsibility_state_leading')
|
|
->label('Responsibility state')
|
|
->badge()
|
|
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
|
|
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
|
|
TextEntry::make('finding_primary_narrative')
|
|
->label('Current reading')
|
|
->state(fn (Finding $record): string => static::primaryNarrative($record))
|
|
->columnSpanFull(),
|
|
TextEntry::make('finding_governance_warning_leading')
|
|
->label('Governance warning')
|
|
->state(fn (Finding $record): ?string => static::governanceWarning($record))
|
|
->color(fn (Finding $record): string => static::governanceWarningColor($record))
|
|
->columnSpanFull()
|
|
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
|
|
TextEntry::make('finding_historical_context')
|
|
->label('Historical context')
|
|
->state(fn (Finding $record): ?string => static::historicalContext($record))
|
|
->columnSpanFull()
|
|
->visible(fn (Finding $record): bool => static::historicalContext($record) !== null),
|
|
TextEntry::make('finding_primary_next_action')
|
|
->label('Next action')
|
|
->state(fn (Finding $record): ?string => static::primaryNextAction($record))
|
|
->columnSpanFull()
|
|
->visible(fn (Finding $record): bool => static::primaryNextAction($record) !== null),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Responsibility')
|
|
->schema([
|
|
TextEntry::make('finding_responsibility_state')
|
|
->label('Responsibility state')
|
|
->badge()
|
|
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
|
|
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
|
|
TextEntry::make('owner_user_id_leading')
|
|
->label('Accountable owner')
|
|
->state(fn (Finding $record): string => static::accountableOwnerDisplayFor($record)),
|
|
TextEntry::make('assignee_user_id_leading')
|
|
->label('Active assignee')
|
|
->state(fn (Finding $record): string => static::activeAssigneeDisplayFor($record)),
|
|
TextEntry::make('finding_responsibility_summary')
|
|
->label('Current split')
|
|
->state(fn (Finding $record): string => static::responsibilitySummary($record))
|
|
->columnSpanFull(),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Artifact source')
|
|
->schema([
|
|
TextEntry::make('artifact_source_family')
|
|
->label('Source family')
|
|
->badge()
|
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_family'))
|
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
TextEntry::make('artifact_source_kind')
|
|
->label('Source kind')
|
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_kind'))
|
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
TextEntry::make('artifact_source_target')
|
|
->label('Source target')
|
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
|
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
TextEntry::make('artifact_source_target_identifier')
|
|
->label('Target identifier')
|
|
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'source_target_identifier'))
|
|
->copyable()
|
|
->placeholder('—'),
|
|
TextEntry::make('artifact_detector_key')
|
|
->label('Detector')
|
|
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'detector_key'))
|
|
->placeholder('—'),
|
|
TextEntry::make('artifact_control_key')
|
|
->label('Control')
|
|
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
|
|
->formatStateUsing(fn (string $state): string => Str::headline($state))
|
|
->placeholder('—'),
|
|
TextEntry::make('artifact_provider_key')
|
|
->label('Provider')
|
|
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'provider_key'))
|
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
TextEntry::make('artifact_provider_object_type')
|
|
->label('Provider object type')
|
|
->state(fn (Finding $record): ?string => static::artifactProviderDetailValue($record, 'provider_object_type'))
|
|
->placeholder('—'),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Finding')
|
|
->schema([
|
|
TextEntry::make('finding_type')->badge()->label('Provider finding 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('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('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')
|
|
->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—')
|
|
->placeholder('—'),
|
|
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('closed_reason')
|
|
->label('Closed/risk reason')
|
|
->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—')
|
|
->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('Risk governance')
|
|
->schema([
|
|
TextEntry::make('finding_governance_status')
|
|
->label('Exception status')
|
|
->badge()
|
|
->state(fn (Finding $record): ?string => $record->findingException?->status)
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
|
->placeholder('—'),
|
|
TextEntry::make('finding_governance_validity')
|
|
->label('Validity')
|
|
->badge()
|
|
->state(function (Finding $record): ?string {
|
|
if ($record->findingException instanceof FindingException) {
|
|
return $record->findingException->current_validity_state;
|
|
}
|
|
|
|
return (string) $record->status === Finding::STATUS_RISK_ACCEPTED
|
|
? FindingException::VALIDITY_MISSING_SUPPORT
|
|
: null;
|
|
})
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->placeholder('—'),
|
|
TextEntry::make('finding_governance_warning')
|
|
->label('Governance warning')
|
|
->state(fn (Finding $record): ?string => static::governanceWarning($record))
|
|
->color(fn (Finding $record): string => static::governanceWarningColor($record))
|
|
->columnSpanFull()
|
|
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
|
|
TextEntry::make('finding_governance_owner')
|
|
->label('Exception owner')
|
|
->state(fn (Finding $record): ?string => $record->findingException?->owner?->name)
|
|
->placeholder('—'),
|
|
TextEntry::make('finding_governance_approver')
|
|
->label('Approver')
|
|
->state(fn (Finding $record): ?string => $record->findingException?->approver?->name)
|
|
->placeholder('—'),
|
|
TextEntry::make('finding_governance_review_due')
|
|
->label('Review due')
|
|
->state(fn (Finding $record): mixed => $record->findingException?->review_due_at)
|
|
->dateTime()
|
|
->placeholder('—'),
|
|
TextEntry::make('finding_governance_expires')
|
|
->label('Expires')
|
|
->state(fn (Finding $record): mixed => $record->findingException?->expires_at)
|
|
->dateTime()
|
|
->placeholder('—'),
|
|
])
|
|
->columns(2)
|
|
->visible(fn (Finding $record): bool => $record->findingException instanceof FindingException || (string) $record->status === Finding::STATUS_RISK_ACCEPTED),
|
|
|
|
Section::make('Evidence')
|
|
->schema([
|
|
TextEntry::make('redaction_integrity_note')
|
|
->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 => static::hasRenderableDiffSection($record))
|
|
->schema([
|
|
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 NormalizedDiffSurface::build(static::unavailableDiffState('No tenant context'), 'finding');
|
|
}
|
|
|
|
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
|
|
|
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
|
return NormalizedDiffSurface::build(static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'), 'finding');
|
|
}
|
|
|
|
$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 NormalizedDiffSurface::build($diff, 'finding');
|
|
})
|
|
->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 = 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::driftSummaryKind($record) === '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::driftSummaryKind($record) === '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 hasRenderableDiffSection(Finding $record): bool
|
|
{
|
|
if ($record->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
|
return false;
|
|
}
|
|
|
|
return match (static::driftSummaryKind($record)) {
|
|
'policy_snapshot',
|
|
'policy_scope_tags',
|
|
'policy_assignments' => true,
|
|
'rbac_role_definition' => is_array(Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition')),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
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, ManagedEnvironment $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('managed_environment_id', $tenant->getKey())->find((int) $baselineId)
|
|
: null;
|
|
|
|
$currentVersion = is_numeric($currentId)
|
|
? PolicyVersion::query()->where('managed_environment_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<int, mixed>, removed: array<int, mixed>, changed: array<int, mixed>}
|
|
*/
|
|
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')
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingType))
|
|
->color(BadgeRenderer::color(BadgeDomain::FindingType))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingType)),
|
|
Tables\Columns\TextColumn::make('subject_display_name')
|
|
->label('Subject')
|
|
->placeholder('—')
|
|
->searchable()
|
|
->limit(40)
|
|
->wrap()
|
|
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
|
->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
|
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
|
Tables\Columns\TextColumn::make('severity')
|
|
->badge()
|
|
->sortable()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
|
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))
|
|
->description(fn (Finding $record): string => static::statusDescription($record)),
|
|
Tables\Columns\TextColumn::make('governance_validity')
|
|
->label('Governance')
|
|
->badge()
|
|
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
|
->placeholder('—')
|
|
->description(fn (Finding $record): ?string => static::governanceListDescription($record)),
|
|
Tables\Columns\TextColumn::make('responsibility_state')
|
|
->label('Responsibility')
|
|
->badge()
|
|
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
|
|
->color(fn (Finding $record): string => static::responsibilityStateColor($record))
|
|
->description(fn (Finding $record): string => static::responsibilitySummary($record)),
|
|
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_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('—')
|
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record))
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
Tables\Columns\TextColumn::make('ownerUser.name')
|
|
->label('Accountable owner')
|
|
->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('assigneeUser.name')
|
|
->label('Active 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')
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->filters([
|
|
Tables\Filters\Filter::make('open')
|
|
->label('Active workflow')
|
|
->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 work')
|
|
->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\Filter::make('my_accountability')
|
|
->label('My accountability')
|
|
->query(function (Builder $query): Builder {
|
|
$userId = auth()->id();
|
|
|
|
if (! is_numeric($userId)) {
|
|
return $query->whereRaw('1 = 0');
|
|
}
|
|
|
|
return $query->where('owner_user_id', (int) $userId);
|
|
}),
|
|
Tables\Filters\SelectFilter::make('status')
|
|
->options(FilterOptionCatalog::findingStatuses())
|
|
->label('Status'),
|
|
Tables\Filters\SelectFilter::make('terminal_outcome')
|
|
->label('Terminal outcome')
|
|
->options(FilterOptionCatalog::findingTerminalOutcomes())
|
|
->query(fn (Builder $query, array $data): Builder => static::applyTerminalOutcomeFilter($query, $data['value'] ?? null)),
|
|
Tables\Filters\SelectFilter::make('verification_state')
|
|
->label('Verification')
|
|
->options(FilterOptionCatalog::findingVerificationStates())
|
|
->query(fn (Builder $query, array $data): Builder => static::applyVerificationStateFilter($query, $data['value'] ?? null)),
|
|
Tables\Filters\SelectFilter::make('workflow_family')
|
|
->label('Workflow family')
|
|
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return match ($value) {
|
|
'active' => $query->whereIn('status', Finding::openStatusesForQuery()),
|
|
'accepted_risk' => $query->where('status', Finding::STATUS_RISK_ACCEPTED),
|
|
'historical' => $query->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED]),
|
|
default => $query,
|
|
};
|
|
}),
|
|
Tables\Filters\SelectFilter::make('governance_validity')
|
|
->label('Governance')
|
|
->options(FilterOptionCatalog::findingExceptionValidityStates())
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
if ($value === FindingException::VALIDITY_MISSING_SUPPORT) {
|
|
return $query
|
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
|
->whereDoesntHave('findingException');
|
|
}
|
|
|
|
return $query
|
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
|
->whereHas('findingException', function (Builder $exceptionQuery) use ($value): void {
|
|
$exceptionQuery->where('current_validity_state', $value);
|
|
});
|
|
}),
|
|
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('Operation IDs')
|
|
->form([
|
|
TextInput::make('baseline_operation_run_id')
|
|
->label('Baseline operation ID')
|
|
->numeric(),
|
|
TextInput::make('current_operation_run_id')
|
|
->label('Current operation 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::getUrl('view', ['record' => $record]))
|
|
->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 ManagedEnvironment || ! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$triagedCount = 0;
|
|
$skippedCount = 0;
|
|
$failedCount = 0;
|
|
|
|
foreach ($records as $record) {
|
|
if (! $record instanceof Finding) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! in_array((string) $record->status, [
|
|
Finding::STATUS_NEW,
|
|
Finding::STATUS_REOPENED,
|
|
], 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('Active assignee')
|
|
->placeholder('Unassigned')
|
|
->helperText('Assign the person currently expected to perform or coordinate the remediation work.')
|
|
->options(fn (): array => static::tenantMemberOptions())
|
|
->searchable(),
|
|
Select::make('owner_user_id')
|
|
->label('Accountable owner')
|
|
->placeholder('Unassigned')
|
|
->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.')
|
|
->options(fn (): array => static::tenantMemberOptions())
|
|
->searchable(),
|
|
])
|
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $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;
|
|
$classificationCounts = [];
|
|
|
|
foreach ($records as $record) {
|
|
if (! $record instanceof Finding) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! $record->hasOpenStatus()) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$record = static::resolveProtectedFindingRecordOrFail($record);
|
|
$classification = $workflow->responsibilityChangeClassification(
|
|
beforeOwnerUserId: is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null,
|
|
beforeAssigneeUserId: is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null,
|
|
afterOwnerUserId: $ownerUserId,
|
|
afterAssigneeUserId: $assigneeUserId,
|
|
);
|
|
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
|
$assignedCount++;
|
|
$classificationCounts[$classification ?? 'unchanged'] = ($classificationCounts[$classification ?? 'unchanged'] ?? 0) + 1;
|
|
} catch (Throwable) {
|
|
$failedCount++;
|
|
}
|
|
}
|
|
|
|
$body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.';
|
|
$classificationSummary = static::bulkResponsibilityClassificationSummary($classificationCounts);
|
|
if ($classificationSummary !== null) {
|
|
$body .= ' '.$classificationSummary;
|
|
}
|
|
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(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected')
|
|
->icon('heroicon-o-check-badge')
|
|
->color('success')
|
|
->requiresConfirmation()
|
|
->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading)
|
|
->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription)
|
|
->form([
|
|
Select::make('resolved_reason')
|
|
->label('Resolution outcome')
|
|
->options(static::resolveReasonOptions())
|
|
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
|
->native(false)
|
|
->required()
|
|
->selectablePlaceholder(false),
|
|
])
|
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $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->managed_environment_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').' pending verification.';
|
|
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(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected')
|
|
->icon('heroicon-o-x-circle')
|
|
->color('warning')
|
|
->requiresConfirmation()
|
|
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
|
|
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
|
|
->form([
|
|
Select::make('closed_reason')
|
|
->label('Close reason')
|
|
->options(static::closeReasonOptions())
|
|
->helperText('Use the canonical administrative closure outcome for this finding.')
|
|
->native(false)
|
|
->required()
|
|
->selectablePlaceholder(false),
|
|
])
|
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $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->managed_environment_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(),
|
|
|
|
])->label('More'),
|
|
])
|
|
->emptyStateHeading('No findings match this view')
|
|
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface findings and governance follow-up.')
|
|
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
return static::getTenantOwnedEloquentQuery()
|
|
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
|
->withSubjectDisplayName();
|
|
}
|
|
|
|
public static function resolveScopedRecordOrFail(int|string $key): Model
|
|
{
|
|
return static::resolveTenantOwnedRecordOrFail(
|
|
$key,
|
|
parent::getEloquentQuery()
|
|
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
|
->withSubjectDisplayName(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{
|
|
* key: string,
|
|
* label: string,
|
|
* value: string,
|
|
* secondaryValue: ?string,
|
|
* targetUrl: ?string,
|
|
* targetKind: string,
|
|
* availability: string,
|
|
* unavailableReason: ?string,
|
|
* contextBadge: ?string,
|
|
* priority: int,
|
|
* actionLabel: string
|
|
* }>
|
|
*/
|
|
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, bool $fresh = false): ?RelatedContextEntry
|
|
{
|
|
$resolver = app(RelatedNavigationResolver::class);
|
|
|
|
return $fresh
|
|
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)
|
|
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
|
}
|
|
|
|
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
|
{
|
|
$incomingContext = CanonicalNavigationContext::fromRequest(request());
|
|
|
|
if (
|
|
$incomingContext instanceof CanonicalNavigationContext
|
|
&& str_starts_with($incomingContext->sourceSurface, 'baseline_compare_matrix')
|
|
&& $incomingContext->backLinkUrl !== null
|
|
) {
|
|
return $incomingContext;
|
|
}
|
|
|
|
$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 ManagedEnvironment ? [
|
|
'tableFilters' => [
|
|
'managed_environment_id' => ['value' => (string) $tenant->getKey()],
|
|
],
|
|
] : [],
|
|
);
|
|
}
|
|
|
|
private static function artifactDescriptorValue(Finding $record, string $key): string
|
|
{
|
|
return (string) (static::artifactDescriptorNullableValue($record, $key) ?? 'unknown');
|
|
}
|
|
|
|
private static function artifactDescriptorNullableValue(Finding $record, string $key): ?string
|
|
{
|
|
$descriptor = $record->artifactSourceDescriptor()->toArray();
|
|
$value = $descriptor[$key] ?? null;
|
|
|
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
|
}
|
|
|
|
private static function artifactProviderDetailValue(Finding $record, string $key): ?string
|
|
{
|
|
$detail = $record->artifactProviderDetail()->toArray();
|
|
$value = $detail[$key] ?? null;
|
|
|
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
|
}
|
|
|
|
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::requestExceptionAction(),
|
|
static::renewExceptionAction(),
|
|
static::revokeExceptionAction(),
|
|
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,
|
|
], true))
|
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
|
static::runWorkflowMutation(
|
|
record: $record,
|
|
successTitle: 'Finding triaged',
|
|
callback: fn (Finding $finding, ManagedEnvironment $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,
|
|
], true))
|
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
|
static::runWorkflowMutation(
|
|
record: $record,
|
|
successTitle: 'Finding moved to in progress',
|
|
callback: fn (Finding $finding, ManagedEnvironment $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('Active assignee')
|
|
->placeholder('Unassigned')
|
|
->helperText('Assign the person currently expected to perform or coordinate the remediation work.')
|
|
->options(fn (): array => static::tenantMemberOptions())
|
|
->searchable(),
|
|
Select::make('owner_user_id')
|
|
->label('Accountable owner')
|
|
->placeholder('Unassigned')
|
|
->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.')
|
|
->options(fn (): array => static::tenantMemberOptions())
|
|
->searchable(),
|
|
])
|
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
|
static::runResponsibilityMutation($record, $data, $workflow);
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply();
|
|
}
|
|
|
|
public static function resolveAction(): Actions\Action
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('resolve_finding');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('resolve')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-check-badge')
|
|
->color('success')
|
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->form([
|
|
Select::make('resolved_reason')
|
|
->label('Resolution outcome')
|
|
->options(static::resolveReasonOptions())
|
|
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
|
->native(false)
|
|
->required()
|
|
->selectablePlaceholder(false),
|
|
])
|
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
|
static::runWorkflowMutation(
|
|
record: $record,
|
|
successTitle: $rule->successTitle,
|
|
callback: fn (Finding $finding, ManagedEnvironment $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
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('close_finding');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('close')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-x-circle')
|
|
->color('warning')
|
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->form([
|
|
Select::make('closed_reason')
|
|
->label('Close reason')
|
|
->options(static::closeReasonOptions())
|
|
->helperText('Use the canonical administrative closure outcome for this finding.')
|
|
->native(false)
|
|
->required()
|
|
->selectablePlaceholder(false),
|
|
])
|
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
|
static::runWorkflowMutation(
|
|
record: $record,
|
|
successTitle: $rule->successTitle,
|
|
callback: fn (Finding $finding, ManagedEnvironment $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 requestExceptionAction(): Actions\Action
|
|
{
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('request_exception')
|
|
->label('Request exception')
|
|
->icon('heroicon-o-shield-exclamation')
|
|
->color('warning')
|
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
|
->requiresConfirmation()
|
|
->form([
|
|
Select::make('owner_user_id')
|
|
->label('Exception owner')
|
|
->required()
|
|
->helperText('Owns the exception record, not the finding outcome.')
|
|
->options(fn (): array => static::tenantMemberOptions())
|
|
->searchable(),
|
|
Textarea::make('request_reason')
|
|
->label('Request reason')
|
|
->rows(4)
|
|
->required()
|
|
->maxLength(2000),
|
|
DateTimePicker::make('review_due_at')
|
|
->label('Review due at')
|
|
->required()
|
|
->seconds(false),
|
|
DateTimePicker::make('expires_at')
|
|
->label('Expires at')
|
|
->seconds(false),
|
|
Repeater::make('evidence_references')
|
|
->label('Evidence references')
|
|
->schema([
|
|
TextInput::make('label')
|
|
->label('Label')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('source_type')
|
|
->label('Source type')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('source_id')
|
|
->label('Source ID')
|
|
->maxLength(255),
|
|
TextInput::make('source_fingerprint')
|
|
->label('Fingerprint')
|
|
->maxLength(255),
|
|
DateTimePicker::make('measured_at')
|
|
->label('Measured at')
|
|
->seconds(false),
|
|
])
|
|
->defaultItems(0)
|
|
->collapsed(),
|
|
])
|
|
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
|
static::runExceptionRequestMutation($record, $data, $service);
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply();
|
|
}
|
|
|
|
public static function renewExceptionAction(): Actions\Action
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('renew_exception');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('renew_exception')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false)
|
|
->fillForm(fn (Finding $record): array => [
|
|
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
|
|
])
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->form([
|
|
Select::make('owner_user_id')
|
|
->label('Exception owner')
|
|
->required()
|
|
->helperText('Owns the exception record, not the finding outcome.')
|
|
->options(fn (): array => static::tenantMemberOptions())
|
|
->searchable(),
|
|
Textarea::make('request_reason')
|
|
->label('Renewal reason')
|
|
->rows(4)
|
|
->required()
|
|
->maxLength(2000),
|
|
DateTimePicker::make('review_due_at')
|
|
->label('Review due at')
|
|
->required()
|
|
->seconds(false),
|
|
DateTimePicker::make('expires_at')
|
|
->label('Requested expiry')
|
|
->seconds(false),
|
|
Repeater::make('evidence_references')
|
|
->label('Evidence references')
|
|
->schema([
|
|
TextInput::make('label')
|
|
->label('Label')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('source_type')
|
|
->label('Source type')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('source_id')
|
|
->label('Source ID')
|
|
->maxLength(255),
|
|
TextInput::make('source_fingerprint')
|
|
->label('Fingerprint')
|
|
->maxLength(255),
|
|
DateTimePicker::make('measured_at')
|
|
->label('Measured at')
|
|
->seconds(false),
|
|
])
|
|
->defaultItems(0)
|
|
->collapsed(),
|
|
])
|
|
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
|
static::runExceptionRenewalMutation($record, $data, $service);
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply();
|
|
}
|
|
|
|
public static function revokeExceptionAction(): Actions\Action
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('revoke_exception');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('revoke_exception')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-no-symbol')
|
|
->color('danger')
|
|
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->form([
|
|
Textarea::make('revocation_reason')
|
|
->label('Revocation reason')
|
|
->rows(4)
|
|
->required()
|
|
->maxLength(2000),
|
|
])
|
|
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
|
static::runExceptionRevocationMutation($record, $data, $service);
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply();
|
|
}
|
|
|
|
public static function reopenAction(): Actions\Action
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('reopen_finding');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('reopen')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
|
->fillForm([
|
|
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
|
])
|
|
->form([
|
|
Select::make('reopen_reason')
|
|
->label('Reopen reason')
|
|
->options(static::reopenReasonOptions())
|
|
->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.')
|
|
->native(false)
|
|
->required()
|
|
->selectablePlaceholder(false),
|
|
])
|
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
|
static::runWorkflowMutation(
|
|
record: $record,
|
|
successTitle: $rule->successTitle,
|
|
callback: fn (Finding $finding, ManagedEnvironment $tenant, User $user): Finding => $workflow->reopen(
|
|
$finding,
|
|
$tenant,
|
|
$user,
|
|
(string) ($data['reopen_reason'] ?? ''),
|
|
),
|
|
);
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply();
|
|
}
|
|
|
|
/**
|
|
* @param callable(Finding, ManagedEnvironment, User): Finding $callback
|
|
*/
|
|
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
|
{
|
|
$pageRecord = $record;
|
|
$tenant = static::resolveWorkflowTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
static::notifyWorkflowContextUnavailable();
|
|
|
|
return;
|
|
}
|
|
|
|
$record = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
|
|
|
if ((int) $record->managed_environment_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);
|
|
|
|
$pageRecord->refresh();
|
|
} catch (InvalidArgumentException $e) {
|
|
Notification::make()
|
|
->title('Workflow action failed')
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title($successTitle)
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private static function runResponsibilityMutation(Finding $record, array $data, FindingWorkflowService $workflow): void
|
|
{
|
|
$pageRecord = $record;
|
|
$tenant = static::resolveWorkflowTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
static::notifyWorkflowContextUnavailable();
|
|
|
|
return;
|
|
}
|
|
|
|
$record = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
|
|
|
if ((int) $record->managed_environment_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;
|
|
}
|
|
|
|
$beforeOwnerUserId = is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null;
|
|
$beforeAssigneeUserId = is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null;
|
|
$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;
|
|
|
|
try {
|
|
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
|
|
|
$pageRecord->refresh();
|
|
} catch (InvalidArgumentException $e) {
|
|
Notification::make()
|
|
->title('Responsibility update failed')
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$classification = $workflow->responsibilityChangeClassification(
|
|
beforeOwnerUserId: $beforeOwnerUserId,
|
|
beforeAssigneeUserId: $beforeAssigneeUserId,
|
|
afterOwnerUserId: $ownerUserId,
|
|
afterAssigneeUserId: $assigneeUserId,
|
|
);
|
|
|
|
Notification::make()
|
|
->title($classification === null ? 'Finding responsibility unchanged' : 'Finding responsibility updated')
|
|
->body($workflow->responsibilityChangeSummary(
|
|
beforeOwnerUserId: $beforeOwnerUserId,
|
|
beforeAssigneeUserId: $beforeAssigneeUserId,
|
|
afterOwnerUserId: $ownerUserId,
|
|
afterAssigneeUserId: $assigneeUserId,
|
|
))
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private static function runExceptionRequestMutation(Finding $record, array $data, FindingExceptionService $service): void
|
|
{
|
|
$tenant = static::resolveWorkflowTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
static::notifyWorkflowContextUnavailable();
|
|
|
|
return;
|
|
}
|
|
|
|
$record = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
|
|
|
try {
|
|
$createdException = $service->request($record, $tenant, $user, $data);
|
|
} catch (InvalidArgumentException $exception) {
|
|
Notification::make()
|
|
->title('Exception request failed')
|
|
->body($exception->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Exception request submitted')
|
|
->body('Exception ownership stays separate from the finding owner.')
|
|
->success()
|
|
->actions([
|
|
Actions\Action::make('view_exception')
|
|
->label('View exception')
|
|
->url(static::findingExceptionViewUrl($createdException, $tenant)),
|
|
])
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private static function runExceptionRenewalMutation(Finding $record, array $data, FindingExceptionService $service): void
|
|
{
|
|
$tenant = static::resolveWorkflowTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
static::notifyWorkflowContextUnavailable();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$renewedException = $service->renew(static::resolveCurrentFindingExceptionOrFail($record, $tenant), $user, $data);
|
|
} catch (InvalidArgumentException $exception) {
|
|
Notification::make()
|
|
->title('Renewal request failed')
|
|
->body($exception->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Renewal request submitted')
|
|
->body('Exception ownership stays separate from the finding owner.')
|
|
->success()
|
|
->actions([
|
|
Actions\Action::make('view_exception')
|
|
->label('View exception')
|
|
->url(static::findingExceptionViewUrl($renewedException, $tenant)),
|
|
])
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private static function runExceptionRevocationMutation(Finding $record, array $data, FindingExceptionService $service): void
|
|
{
|
|
$tenant = static::resolveWorkflowTenantForRecord($record);
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
|
static::notifyWorkflowContextUnavailable();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$revokedException = $service->revoke(static::resolveCurrentFindingExceptionOrFail($record, $tenant), $user, $data);
|
|
} catch (InvalidArgumentException $exception) {
|
|
Notification::make()
|
|
->title('Exception revocation failed')
|
|
->body($exception->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Exception revoked')
|
|
->success()
|
|
->actions([
|
|
Actions\Action::make('view_exception')
|
|
->label('View exception')
|
|
->url(static::findingExceptionViewUrl($revokedException, $tenant)),
|
|
])
|
|
->send();
|
|
}
|
|
|
|
private static function freshWorkflowRecord(Finding $record): Finding
|
|
{
|
|
return static::resolveProtectedFindingRecordOrFail($record);
|
|
}
|
|
|
|
private static function freshWorkflowStatus(Finding $record): string
|
|
{
|
|
return (string) static::freshWorkflowRecord($record)->status;
|
|
}
|
|
|
|
private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record, ?ManagedEnvironment $tenant = null): Finding
|
|
{
|
|
$resolvedRecord = $tenant instanceof ManagedEnvironment
|
|
? static::resolveTenantOwnedRecordOrFail(
|
|
$record instanceof Model ? $record->getKey() : $record,
|
|
parent::getEloquentQuery()
|
|
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
|
->withSubjectDisplayName(),
|
|
$tenant,
|
|
)
|
|
: static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
|
|
|
if (! $resolvedRecord instanceof Finding) {
|
|
abort(404);
|
|
}
|
|
|
|
return $resolvedRecord;
|
|
}
|
|
|
|
private static function resolveWorkflowTenantForRecord(Finding $record): ?ManagedEnvironment
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
|
|
if ($tenant instanceof ManagedEnvironment) {
|
|
return $tenant;
|
|
}
|
|
|
|
$tenantId = $record->managed_environment_id;
|
|
|
|
if (! is_numeric($tenantId)) {
|
|
return null;
|
|
}
|
|
|
|
return ManagedEnvironment::query()
|
|
->withTrashed()
|
|
->find((int) $tenantId);
|
|
}
|
|
|
|
private static function notifyWorkflowContextUnavailable(): void
|
|
{
|
|
Notification::make()
|
|
->title('Workflow action unavailable')
|
|
->body('Reload the environment-scoped finding page and try again.')
|
|
->danger()
|
|
->send();
|
|
}
|
|
|
|
private static function currentFindingException(Finding $record, ?ManagedEnvironment $tenant = null): ?FindingException
|
|
{
|
|
$finding = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
|
|
|
return static::resolvedFindingException($finding);
|
|
}
|
|
|
|
private static function loadedFindingException(Finding $finding): ?FindingException
|
|
{
|
|
$exception = $finding->relationLoaded('findingException')
|
|
? $finding->findingException
|
|
: null;
|
|
|
|
if (! $exception instanceof FindingException) {
|
|
return null;
|
|
}
|
|
|
|
$exception->loadMissing('currentDecision');
|
|
|
|
return $exception;
|
|
}
|
|
|
|
private static function resolvedFindingException(Finding $finding): ?FindingException
|
|
{
|
|
$exception = $finding->relationLoaded('findingException')
|
|
? $finding->findingException
|
|
: $finding->findingException()->with('currentDecision')->first();
|
|
|
|
if (! $exception instanceof FindingException) {
|
|
return null;
|
|
}
|
|
|
|
$exception->loadMissing('currentDecision');
|
|
|
|
return $exception;
|
|
}
|
|
|
|
private static function resolveCurrentFindingExceptionOrFail(Finding $record, ?ManagedEnvironment $tenant = null): FindingException
|
|
{
|
|
$exception = static::currentFindingException($record, $tenant);
|
|
|
|
if (! $exception instanceof FindingException) {
|
|
throw new InvalidArgumentException('This finding does not have an exception to manage.');
|
|
}
|
|
|
|
return $exception;
|
|
}
|
|
|
|
private static function findingExceptionViewUrl(\App\Models\FindingException $exception, ManagedEnvironment $tenant): string
|
|
{
|
|
$panelId = Filament::getCurrentPanel()?->getId();
|
|
|
|
if ($panelId === 'admin') {
|
|
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
|
|
}
|
|
|
|
return FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $classificationCounts
|
|
*/
|
|
private static function bulkResponsibilityClassificationSummary(array $classificationCounts): ?string
|
|
{
|
|
$parts = [];
|
|
|
|
foreach ($classificationCounts as $classification => $count) {
|
|
$parts[] = static::responsibilityClassificationLabel($classification).': '.$count;
|
|
}
|
|
|
|
if ($parts === []) {
|
|
return null;
|
|
}
|
|
|
|
return implode('. ', $parts).'.';
|
|
}
|
|
|
|
private static function responsibilityClassificationLabel(string $classification): string
|
|
{
|
|
return match ($classification) {
|
|
'owner_only' => 'Owner only',
|
|
'assignee_only' => 'Assignee only',
|
|
'owner_and_assignee' => 'Owner and assignee',
|
|
'clear_owner' => 'Cleared owner',
|
|
'clear_assignee' => 'Cleared assignee',
|
|
default => 'Unchanged',
|
|
};
|
|
}
|
|
|
|
private static function responsibilityStateColor(Finding $finding): string
|
|
{
|
|
return match ($finding->responsibilityState()) {
|
|
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'danger',
|
|
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'warning',
|
|
default => 'success',
|
|
};
|
|
}
|
|
|
|
public static function accountableOwnerDisplayFor(Finding $finding): string
|
|
{
|
|
return $finding->ownerUser?->name ?? 'Unassigned';
|
|
}
|
|
|
|
public static function activeAssigneeDisplayFor(Finding $finding): string
|
|
{
|
|
return $finding->assigneeUser?->name ?? 'Unassigned';
|
|
}
|
|
|
|
private static function responsibilitySummary(Finding $finding): string
|
|
{
|
|
$ownerName = $finding->ownerUser?->name;
|
|
$assigneeName = $finding->assigneeUser?->name;
|
|
|
|
return match ($finding->responsibilityState()) {
|
|
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => $assigneeName !== null
|
|
? "No accountable owner is set. {$assigneeName} is currently carrying the active remediation work."
|
|
: 'No accountable owner or active assignee is set.',
|
|
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => "{$ownerName} owns the outcome, but active remediation is still unassigned.",
|
|
default => $ownerName === $assigneeName
|
|
? "{$ownerName} owns the outcome and is also the active assignee."
|
|
: "{$ownerName} owns the outcome. {$assigneeName} is the active assignee.",
|
|
};
|
|
}
|
|
|
|
private static function governanceListDescription(Finding $finding): ?string
|
|
{
|
|
$parts = array_values(array_filter([
|
|
static::governanceWarning($finding),
|
|
static::resolvedFindingException($finding)?->owner?->name !== null
|
|
? 'Exception owner: '.static::resolvedFindingException($finding)?->owner?->name
|
|
: null,
|
|
]));
|
|
|
|
if ($parts === []) {
|
|
return null;
|
|
}
|
|
|
|
return implode(' ', $parts);
|
|
}
|
|
|
|
private static function governanceWarning(Finding $finding): ?string
|
|
{
|
|
return app(FindingRiskGovernanceResolver::class)
|
|
->resolveWarningMessage($finding, static::resolvedFindingException($finding));
|
|
}
|
|
|
|
private static function governanceWarningColor(Finding $finding): string
|
|
{
|
|
$exception = static::resolvedFindingException($finding);
|
|
|
|
if (static::governanceValidityState($finding) === FindingException::VALIDITY_EXPIRING) {
|
|
return 'warning';
|
|
}
|
|
|
|
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
|
return 'warning';
|
|
}
|
|
|
|
return 'danger';
|
|
}
|
|
|
|
private static function governanceValidityState(Finding $finding): ?string
|
|
{
|
|
return app(FindingRiskGovernanceResolver::class)
|
|
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
|
}
|
|
|
|
private static function findingOutcomeSemantics(): FindingOutcomeSemantics
|
|
{
|
|
return app(FindingOutcomeSemantics::class);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* terminal_outcome_key: ?string,
|
|
* label: ?string,
|
|
* verification_state: string,
|
|
* verification_label: ?string,
|
|
* report_bucket: ?string
|
|
* }
|
|
*/
|
|
private static function findingOutcome(Finding $finding): array
|
|
{
|
|
return static::findingOutcomeSemantics()->describe($finding);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function resolveReasonOptions(): array
|
|
{
|
|
return [
|
|
Finding::RESOLVE_REASON_REMEDIATED => 'Remediated',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function closeReasonOptions(): array
|
|
{
|
|
return [
|
|
Finding::CLOSE_REASON_FALSE_POSITIVE => 'False positive',
|
|
Finding::CLOSE_REASON_DUPLICATE => 'Duplicate',
|
|
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => 'No longer applicable',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function reopenReasonOptions(): array
|
|
{
|
|
return [
|
|
Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION => 'Recurred after resolution',
|
|
Finding::REOPEN_REASON_VERIFICATION_FAILED => 'Verification failed',
|
|
Finding::REOPEN_REASON_MANUAL_REASSESSMENT => 'Manual reassessment',
|
|
];
|
|
}
|
|
|
|
private static function resolveReasonLabel(?string $reason): ?string
|
|
{
|
|
return static::resolveReasonOptions()[$reason] ?? match ($reason) {
|
|
Finding::RESOLVE_REASON_NO_LONGER_DRIFTING => 'No longer drifting',
|
|
Finding::RESOLVE_REASON_PERMISSION_GRANTED => 'Permission granted',
|
|
Finding::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY => 'Permission removed from registry',
|
|
Finding::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED => 'Role assignment removed',
|
|
Finding::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD => 'GA count within threshold',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private static function closeReasonLabel(?string $reason): ?string
|
|
{
|
|
return static::closeReasonOptions()[$reason] ?? match ($reason) {
|
|
Finding::CLOSE_REASON_ACCEPTED_RISK => 'Accepted risk',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private static function reopenReasonLabel(?string $reason): ?string
|
|
{
|
|
return static::reopenReasonOptions()[$reason] ?? null;
|
|
}
|
|
|
|
private static function terminalOutcomeLabel(Finding $finding): ?string
|
|
{
|
|
return static::findingOutcome($finding)['label'] ?? null;
|
|
}
|
|
|
|
private static function verificationStateLabel(Finding $finding): ?string
|
|
{
|
|
return static::findingOutcome($finding)['verification_label'] ?? null;
|
|
}
|
|
|
|
private static function statusDescription(Finding $finding): string
|
|
{
|
|
return static::terminalOutcomeLabel($finding) ?? static::primaryNarrative($finding);
|
|
}
|
|
|
|
private static function applyTerminalOutcomeFilter(Builder $query, mixed $value): Builder
|
|
{
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return match ($value) {
|
|
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => $query
|
|
->where('status', Finding::STATUS_RESOLVED)
|
|
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
|
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => $query
|
|
->where('status', Finding::STATUS_RESOLVED)
|
|
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
|
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => $query
|
|
->where('status', Finding::STATUS_CLOSED)
|
|
->where('closed_reason', Finding::CLOSE_REASON_FALSE_POSITIVE),
|
|
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => $query
|
|
->where('status', Finding::STATUS_CLOSED)
|
|
->where('closed_reason', Finding::CLOSE_REASON_DUPLICATE),
|
|
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $query
|
|
->where('status', Finding::STATUS_CLOSED)
|
|
->where('closed_reason', Finding::CLOSE_REASON_NO_LONGER_APPLICABLE),
|
|
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => $query
|
|
->where('status', Finding::STATUS_RISK_ACCEPTED),
|
|
default => $query,
|
|
};
|
|
}
|
|
|
|
private static function applyVerificationStateFilter(Builder $query, mixed $value): Builder
|
|
{
|
|
if (! is_string($value) || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return match ($value) {
|
|
FindingOutcomeSemantics::VERIFICATION_PENDING => $query
|
|
->where('status', Finding::STATUS_RESOLVED)
|
|
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
|
FindingOutcomeSemantics::VERIFICATION_VERIFIED => $query
|
|
->where('status', Finding::STATUS_RESOLVED)
|
|
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
|
FindingOutcomeSemantics::VERIFICATION_NOT_APPLICABLE => $query->where(function (Builder $verificationQuery): void {
|
|
$verificationQuery
|
|
->where('status', '!=', Finding::STATUS_RESOLVED)
|
|
->orWhereNull('resolved_reason')
|
|
->orWhereNotIn('resolved_reason', Finding::resolveReasonKeys());
|
|
}),
|
|
default => $query,
|
|
};
|
|
}
|
|
|
|
private static function primaryNarrative(Finding $finding): string
|
|
{
|
|
return app(FindingRiskGovernanceResolver::class)
|
|
->resolvePrimaryNarrative($finding, static::resolvedFindingException($finding));
|
|
}
|
|
|
|
private static function historicalContext(Finding $finding): ?string
|
|
{
|
|
return app(FindingRiskGovernanceResolver::class)
|
|
->resolveHistoricalContext($finding);
|
|
}
|
|
|
|
private static function primaryNextAction(Finding $finding): ?string
|
|
{
|
|
return app(FindingRiskGovernanceResolver::class)
|
|
->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding));
|
|
}
|
|
|
|
public static function dueAttentionLabelFor(Finding $finding): ?string
|
|
{
|
|
if (! $finding->hasOpenStatus() || ! $finding->due_at) {
|
|
return null;
|
|
}
|
|
|
|
if ($finding->due_at->isPast()) {
|
|
return 'Overdue';
|
|
}
|
|
|
|
if ($finding->due_at->lessThanOrEqualTo(now()->addDays(3))) {
|
|
return 'Due soon';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static function dueAttentionColorFor(Finding $finding): string
|
|
{
|
|
return match (static::dueAttentionLabelFor($finding)) {
|
|
'Overdue' => 'danger',
|
|
'Due soon' => 'warning',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private static function tenantMemberOptions(): array
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return [];
|
|
}
|
|
|
|
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
|
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
|
|
|
return User::query()
|
|
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
|
->orderBy('name')
|
|
->orderBy('email')
|
|
->get(['id', 'name', 'email'])
|
|
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
|
->mapWithKeys(fn (User $user): array => [
|
|
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array{open: int, overdue: int, high_severity: int, risk_accepted: int, total: int}
|
|
*/
|
|
public static function findingStatsForCurrentTenant(): array
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return ['open' => 0, 'overdue' => 0, 'high_severity' => 0, 'risk_accepted' => 0, 'total' => 0];
|
|
}
|
|
|
|
$now = now()->toDateTimeString();
|
|
|
|
$counts = Finding::query()
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->selectRaw('count(*) as total')
|
|
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as open")
|
|
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') and due_at is not null and due_at < ? then 1 else 0 end) as overdue", [$now])
|
|
->selectRaw("sum(case when severity in ('high', 'critical') and status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as high_severity")
|
|
->selectRaw("sum(case when status = 'risk_accepted' then 1 else 0 end) as risk_accepted")
|
|
->first();
|
|
|
|
return [
|
|
'open' => (int) ($counts?->open ?? 0),
|
|
'overdue' => (int) ($counts?->overdue ?? 0),
|
|
'high_severity' => (int) ($counts?->high_severity ?? 0),
|
|
'risk_accepted' => (int) ($counts?->risk_accepted ?? 0),
|
|
'total' => (int) ($counts?->total ?? 0),
|
|
];
|
|
}
|
|
}
|