user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } if (! $user->canAccessTenant($tenant)) { return false; } return $user->can(Capabilities::EVIDENCE_VIEW, $tenant); } public static function canView(Model $record): bool { $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { return false; } if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) { return false; } return ! $record instanceof EvidenceSnapshot || ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id); } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.'); } public static function getEloquentQuery(): Builder { return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']); } public static function resolveScopedRecordOrFail(int|string|null $record): Model { return static::resolveTenantOwnedRecordOrFail($record); } public static function form(Schema $schema): Schema { return $schema; } public static function infolist(Schema $schema): Schema { return $schema->schema([ Section::make('Snapshot') ->schema([ TextEntry::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus)) ->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)), TextEntry::make('completeness_state') ->label('Completeness') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)), TextEntry::make('tenant.name')->label('Tenant'), TextEntry::make('generated_at')->dateTime()->placeholder('—'), TextEntry::make('expires_at')->dateTime()->placeholder('—'), TextEntry::make('operationRun.id') ->label('Operation run') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null) ->openUrlInNewTab(), TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'), TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'), ]) ->columns(2), Section::make('Summary') ->schema([ TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'), TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'), TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'), TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'), TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'), ]) ->columns(2), Section::make('Evidence dimensions') ->schema([ RepeatableEntry::make('items') ->hiddenLabel() ->schema([ TextEntry::make('dimension_key')->label('Dimension') ->formatStateUsing(fn (string $state): string => Str::headline($state)), TextEntry::make('state') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)), TextEntry::make('source_kind')->label('Source') ->formatStateUsing(fn (string $state): string => Str::headline($state)), TextEntry::make('freshness_at')->dateTime()->placeholder('—'), ViewEntry::make('summary_payload_highlights') ->label('Summary') ->view('filament.infolists.entries.evidence-dimension-summary') ->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record)) ->columnSpanFull(), ViewEntry::make('summary_payload_raw') ->label('Raw summary JSON') ->view('filament.infolists.entries.snapshot-json') ->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : []) ->columnSpanFull(), ]) ->columns(4), ]), ]); } public static function table(Table $table): Table { return $table ->defaultSort('created_at', 'desc') ->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\Columns\TextColumn::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus)) ->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)) ->sortable(), Tables\Columns\TextColumn::make('completeness_state') ->label('Completeness') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)) ->sortable(), Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'), Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'), Tables\Columns\TextColumn::make('summary.missing_dimensions')->label('Missing'), ]) ->filters([ Tables\Filters\SelectFilter::make('status') ->options([ 'queued' => 'Queued', 'generating' => 'Generating', 'active' => 'Active', 'superseded' => 'Superseded', 'expired' => 'Expired', 'failed' => 'Failed', ]), Tables\Filters\SelectFilter::make('completeness_state') ->options([ 'complete' => 'Complete', 'partial' => 'Partial', 'missing' => 'Missing', 'stale' => 'Stale', ]), ]) ->actions([ Actions\Action::make('view_snapshot') ->label('View snapshot') ->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])), UiEnforcement::forTableAction( Actions\Action::make('expire') ->label('Expire snapshot') ->color('danger') ->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record)) ->requiresConfirmation() ->action(function (EvidenceSnapshot $record): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } app(EvidenceSnapshotService::class)->expire($record, $user); Notification::make()->success()->title('Snapshot expired')->send(); }), fn (EvidenceSnapshot $record): EvidenceSnapshot => $record, ) ->preserveVisibility() ->requireCapability(Capabilities::EVIDENCE_MANAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(), ]) ->bulkActions([]) ->emptyStateHeading('No evidence snapshots yet') ->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.') ->emptyStateActions([ UiEnforcement::forAction( Actions\Action::make('create_first_snapshot') ->label('Create first snapshot') ->icon('heroicon-o-plus') ->action(fn (): mixed => static::executeGeneration([])), ) ->requireCapability(Capabilities::EVIDENCE_MANAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply(), ]); } public static function getPages(): array { return [ 'index' => Pages\ListEvidenceSnapshots::route('/'), 'view' => new PageRegistration( page: Pages\ViewEvidenceSnapshot::class, route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class) ->whereNumber('record') ->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel)) ->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)), ), ]; } /** * @return array{summary:?string,highlights:list,items:list} */ private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array { $payload = is_array($item->summary_payload) ? $item->summary_payload : []; return match ($item->dimension_key) { 'findings_summary' => static::findingsSummaryPresentation($payload), 'permission_posture' => static::permissionPosturePresentation($payload), 'entra_admin_roles' => static::entraAdminRolesPresentation($payload), 'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload), 'operations_summary' => static::operationsSummaryPresentation($payload), default => static::genericSummaryPresentation($payload), }; } /** * @param array $payload * @return array{summary:?string,highlights:list,items:list} */ private static function findingsSummaryPresentation(array $payload): array { $count = (int) ($payload['count'] ?? 0); $openCount = (int) ($payload['open_count'] ?? 0); $severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : []; $entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : []; return [ 'summary' => sprintf('%d findings, %d open.', $count, $openCount), 'highlights' => [ ['label' => 'Findings', 'value' => (string) $count], ['label' => 'Open findings', 'value' => (string) $openCount], ['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))], ['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))], ['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))], ['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))], ], 'items' => collect($entries) ->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null) ->filter() ->take(5) ->values() ->all(), ]; } /** * @param array $payload * @return array{summary:?string,highlights:list,items:list} */ private static function permissionPosturePresentation(array $payload): array { $requiredCount = (int) ($payload['required_count'] ?? 0); $grantedCount = (int) ($payload['granted_count'] ?? 0); $postureScore = $payload['posture_score'] ?? null; $reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : []; return [ 'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount), 'highlights' => [ ['label' => 'Granted permissions', 'value' => (string) $grantedCount], ['label' => 'Required permissions', 'value' => (string) $requiredCount], ['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore], ], 'items' => static::namedItemsFromArray( Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])), 'No missing permission details captured.' ), ]; } /** * @param array $payload * @return array{summary:?string,highlights:list,items:list} */ private static function entraAdminRolesPresentation(array $payload): array { $roleCount = (int) ($payload['role_count'] ?? 0); return [ 'summary' => sprintf('%d privileged Entra roles captured.', $roleCount), 'highlights' => [ ['label' => 'Role count', 'value' => (string) $roleCount], ], 'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'), ]; } /** * @param array $payload * @return array{summary:?string,highlights:list,items:list} */ private static function baselineDriftPosturePresentation(array $payload): array { $driftCount = (int) ($payload['drift_count'] ?? 0); $openDriftCount = (int) ($payload['open_drift_count'] ?? 0); return [ 'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount), 'highlights' => [ ['label' => 'Drift findings', 'value' => (string) $driftCount], ['label' => 'Open drift findings', 'value' => (string) $openDriftCount], ], 'items' => [], ]; } /** * @param array $payload * @return array{summary:?string,highlights:list,items:list} */ private static function operationsSummaryPresentation(array $payload): array { $operationCount = (int) ($payload['operation_count'] ?? 0); $failedCount = (int) ($payload['failed_count'] ?? 0); $partialCount = (int) ($payload['partial_count'] ?? 0); $entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : []; return [ 'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount), 'highlights' => [ ['label' => 'Operations', 'value' => (string) $operationCount], ['label' => 'Failed operations', 'value' => (string) $failedCount], ['label' => 'Partial operations', 'value' => (string) $partialCount], ], 'items' => collect($entries) ->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null) ->filter() ->take(5) ->values() ->all(), ]; } /** * @param array $payload * @return array{summary:?string,highlights:list,items:list} */ private static function genericSummaryPresentation(array $payload): array { $highlights = collect($payload) ->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value)) ->take(6) ->map(fn (mixed $value, string|int $key): array => [ 'label' => Str::headline((string) $key), 'value' => static::stringifySummaryValue($value), ]) ->values() ->all(); return [ 'summary' => empty($highlights) ? 'No summary details captured.' : null, 'highlights' => $highlights, 'items' => [], ]; } /** * @return list */ private static function namedItemsFromArray(mixed $items, string $emptyFallback): array { if (! is_array($items) || $items === []) { return [$emptyFallback]; } $labels = collect($items) ->map(function (mixed $item): ?string { if (is_string($item)) { return trim($item) !== '' ? $item : null; } if (! is_array($item)) { return null; } foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) { $value = $item[$key] ?? null; if (is_string($value) && trim($value) !== '') { return $value; } } return null; }) ->filter() ->take(5) ->values() ->all(); return $labels === [] ? [$emptyFallback] : $labels; } /** * @param array $entry */ private static function findingEntryLabel(array $entry): ?string { $title = $entry['title'] ?? null; $severity = $entry['severity'] ?? null; $status = $entry['status'] ?? null; if (! is_string($title) || trim($title) === '') { return null; } $parts = [trim($title)]; if (is_string($severity) && trim($severity) !== '') { $parts[] = Str::headline($severity); } if (is_string($status) && trim($status) !== '') { $parts[] = Str::headline($status); } return implode(' · ', $parts); } /** * @param array $entry */ private static function operationEntryLabel(array $entry): ?string { $type = $entry['type'] ?? null; if (! is_string($type) || trim($type) === '') { return null; } $parts = [static::operationTypeLabel($type)]; $stateLabel = static::operationEntryStateLabel($entry); if ($stateLabel !== null) { $parts[] = $stateLabel; } return implode(' · ', $parts); } public static function canExpireRecord(EvidenceSnapshot $record): bool { return (string) $record->status !== EvidenceSnapshotStatus::Expired->value; } private static function operationTypeLabel(string $type): string { $label = OperationCatalog::label($type); return $label === 'Unknown operation' ? 'Operation' : $label; } /** * @param array $entry */ private static function operationEntryStateLabel(array $entry): ?string { $status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null; $outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null; return match ($status) { OperationRunStatus::Queued->value => 'Queued', OperationRunStatus::Running->value => 'Running', OperationRunStatus::Completed->value => match ($outcome) { OperationRunOutcome::Succeeded->value => 'Completed', OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value], OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value], OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value], OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value], default => 'Completed', }, default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null, }; } private static function stringifySummaryValue(mixed $value): string { return match (true) { $value === null => '—', is_bool($value) => $value ? 'Yes' : 'No', is_scalar($value) => (string) $value, default => '—', }; } /** * @param array $data */ public static function executeGeneration(array $data): void { $tenant = Filament::getTenant(); $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send(); return; } $snapshot = app(EvidenceSnapshotService::class)->generate( tenant: $tenant, user: $user, allowStale: (bool) ($data['allow_stale'] ?? false), ); if (! $snapshot->wasRecentlyCreated) { Notification::make() ->success() ->title('Snapshot already available') ->body('A matching active snapshot already exists. No new run was started.') ->actions([ Actions\Action::make('view_snapshot') ->label('View snapshot') ->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)), ]) ->send(); return; } Notification::make() ->success() ->title('Create snapshot queued') ->body('The snapshot is being generated in the background.') ->actions([ Actions\Action::make('view_run') ->label('View run') ->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)), ]) ->send(); } }