'audit_log', 'surfaceType' => 'selected_record_monitoring', 'stateFields' => [ [ 'stateKey' => 'event', 'stateClass' => 'inspect', 'carrier' => 'query_param', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true, 'tenantSensitive' => false, 'invalidFallback' => 'clear_selection_and_continue', ], [ 'stateKey' => 'supportAccess', 'stateClass' => 'contextual_prefilter', 'carrier' => 'query_param', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true, 'tenantSensitive' => false, 'invalidFallback' => 'discard_and_continue', ], [ 'stateKey' => 'tenant_id', 'stateClass' => 'contextual_prefilter', 'carrier' => 'session', 'queryRole' => 'durable_restorable', 'shareable' => false, 'restorableOnRefresh' => true, 'tenantSensitive' => true, 'invalidFallback' => 'discard_and_continue', ], [ 'stateKey' => 'tableSearch', 'stateClass' => 'shareable_restorable', 'carrier' => 'session', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true, 'tenantSensitive' => false, 'invalidFallback' => 'discard_and_continue', ], ], 'hydrationRule' => [ 'precedenceOrder' => ['query', 'session', 'default'], 'appliesOnInitialMountOnly' => true, 'activeStateBecomesAuthoritativeAfterMount' => true, 'clearsOnTenantSwitch' => ['tenant_id', 'action', 'actor_label', 'resource_type'], 'invalidRequestedStateFallback' => 'clear_selection_and_continue', ], 'inspectContract' => [ 'primaryModel' => AuditLogModel::class, 'selectedStateKey' => 'selectedAuditLogId', 'openedBy' => ['query_param', 'inspect_action'], 'presentation' => 'inline_detail', 'shareable' => true, 'invalidSelectionFallback' => 'clear_selection_and_continue', ], 'shareableStateKeys' => ['event', 'supportAccess'], 'localOnlyStateKeys' => [], ]; public ?int $selectedAuditLogId = null; public bool $supportAccessOnly = false; protected static bool $isDiscovered = false; protected static bool $shouldRegisterNavigation = false; protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; protected static ?string $navigationLabel = 'Audit Log'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list'; protected static ?string $slug = 'audit-log'; protected static ?string $title = 'Audit Log'; protected string $view = 'filament.pages.monitoring.audit-log'; /** * @var array|null */ private ?array $authorizedTenants = null; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::HistoryAudit) ->withDefaults(new ActionSurfaceDefaults( moreGroupLabel: 'More', exportIsDefaultBulkActionForReadOnly: false, )) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the Monitoring scope visible and expose selected-event detail actions.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Audit history is immutable and intentionally omits bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table exposes a clear-filters CTA when no audit events match the current view.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.'); } /** * @return array */ public static function monitoringPageStateContract(): array { return self::MONITORING_PAGE_STATE_CONTRACT; } public function mount(): void { $this->authorizePageAccess(); $this->supportAccessOnly = request()->boolean('supportAccess'); $requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null; app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request()); $this->mountInteractsWithTable(); if ($requestedEventId !== null) { $this->selectedAuditLogId = $this->resolveSelectedAuditLogId($requestedEventId); } } /** * @return array */ protected function getHeaderActions(): array { $actions = app(OperateHubShell::class)->headerActions( scopeActionName: 'operate_hub_scope_audit_log', returnActionName: 'operate_hub_return_audit_log', ); $navigationContext = CanonicalNavigationContext::fromRequest(request()); if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { array_splice($actions, 1, 0, [ Action::make('operate_hub_back_to_origin_audit_log') ->label($navigationContext->backLinkLabel) ->icon('heroicon-o-arrow-left') ->color('gray') ->url($navigationContext->backLinkUrl), ]); } array_splice($actions, 1, 0, [ Action::make('support_access_history_filter') ->label($this->supportAccessOnly ? 'Show all audit events' : 'Support access history') ->icon($this->supportAccessOnly ? 'heroicon-o-list-bullet' : 'heroicon-o-lifebuoy') ->color('gray') ->url($this->auditLogUrl([ 'supportAccess' => $this->supportAccessOnly ? null : true, 'event' => null, ])), Action::make('export_support_access_history') ->label('Export support access history') ->icon('heroicon-o-arrow-down-tray') ->color('gray') ->action(fn (): StreamedResponse => $this->exportSupportAccessHistory()), ]); $selectedAudit = $this->selectedAuditRecord(); $selectedAuditLink = $selectedAudit instanceof AuditLogModel ? $this->auditTargetLink($selectedAudit) : null; if ($selectedAudit instanceof AuditLogModel) { array_splice($actions, 1, 0, array_values(array_filter([ Action::make('close_selected_audit_event') ->label('Close details') ->icon('heroicon-o-x-mark') ->color('gray') ->url($this->auditLogUrl(['event' => null])), $selectedAuditLink !== null ? Action::make('open_selected_audit_target') ->label($selectedAuditLink['label']) ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->url($selectedAuditLink['url']) : null, ]))); } return $actions; } public function updatedTableFilters(): void { $this->normalizeSelectedAuditLogId(); } public function updatedTableSearch(): void { $this->normalizeSelectedAuditLogId(); } public function table(Table $table): Table { return $table ->query(fn (): Builder => $this->auditBaseQuery()) ->defaultSort('recorded_at', 'desc') ->paginated(TablePaginationProfiles::customPage()) ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() ->columns([ TextColumn::make('outcome') ->label('Outcome') ->badge() ->getStateUsing(fn (AuditLogModel $record): string => $record->normalizedOutcome()->value) ->formatStateUsing(fn (string $state): string => BadgeRenderer::label(BadgeDomain::AuditOutcome)($state)) ->color(fn (string $state): string => BadgeRenderer::color(BadgeDomain::AuditOutcome)($state)) ->icon(fn (string $state): ?string => BadgeRenderer::icon(BadgeDomain::AuditOutcome)($state)) ->iconColor(fn (string $state): ?string => BadgeRenderer::iconColor(BadgeDomain::AuditOutcome)($state)), TextColumn::make('summary') ->label('Event') ->getStateUsing(fn (AuditLogModel $record): string => $record->summaryText()) ->description(fn (AuditLogModel $record): string => AuditActionId::labelFor((string) $record->action)) ->searchable() ->wrap(), TextColumn::make('actor_label') ->label('Actor') ->getStateUsing(fn (AuditLogModel $record): string => $record->actorDisplayLabel()) ->description(fn (AuditLogModel $record): string => BadgeRenderer::label(BadgeDomain::AuditActorType)($record->actorSnapshot()->type->value)) ->searchable(), TextColumn::make('target_label') ->label('Target') ->getStateUsing(fn (AuditLogModel $record): string => $record->targetDisplayLabel() ?? 'No target snapshot') ->searchable() ->toggleable(), TextColumn::make('tenant.name') ->label('Tenant') ->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace') ->toggleable(), TextColumn::make('recorded_at') ->label('Recorded') ->since() ->sortable(), ]) ->filters([ SelectFilter::make('tenant_id') ->label('Tenant') ->options(fn (): array => $this->tenantFilterOptions()) ->default(fn (): ?string => $this->defaultTenantFilter()) ->searchable(), SelectFilter::make('action') ->label('Event type') ->options(fn (): array => $this->actionFilterOptions()) ->searchable(), SelectFilter::make('outcome') ->label('Outcome') ->options(FilterOptionCatalog::auditOutcomes()), SelectFilter::make('actor_label') ->label('Actor') ->options(fn (): array => $this->actorFilterOptions()) ->searchable(), SelectFilter::make('resource_type') ->label('Target type') ->options(fn (): array => $this->targetTypeFilterOptions()), FilterPresets::dateRange('recorded_at', 'Recorded', 'recorded_at'), ]) ->actions([ Action::make('inspect') ->label('Inspect event') ->icon('heroicon-o-eye') ->color('gray') ->url(fn (AuditLogModel $record): string => $this->auditLogUrl(['event' => (int) $record->getKey()])), ]) ->bulkActions([]) ->emptyStateHeading('No audit events match this view') ->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.') ->emptyStateIcon('heroicon-o-funnel') ->emptyStateActions([ Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') ->action(function (): void { $this->selectedAuditLogId = null; $this->resetTable(); }), ]); } /** * @return array */ public function authorizedTenants(): array { if ($this->authorizedTenants !== null) { return $this->authorizedTenants; } $user = auth()->user(); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if (! $user instanceof User || ! is_numeric($workspaceId)) { return $this->authorizedTenants = []; } $tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel())) ->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId) ->filter(static fn (Tenant $tenant): bool => $tenant->isActive()) ->keyBy(fn (Tenant $tenant): int => (int) $tenant->getKey()) ->all(); return $this->authorizedTenants = $tenants; } private function authorizePageAccess(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspace = is_numeric($workspaceId) ? Workspace::query()->whereKey((int) $workspaceId)->first() : null; if (! $workspace instanceof Workspace) { throw new NotFoundHttpException; } $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $workspace)) { throw new NotFoundHttpException; } if (! $resolver->can($user, $workspace, Capabilities::AUDIT_VIEW)) { abort(403); } } private function auditBaseQuery(): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $authorizedTenantIds = array_map( static fn (Tenant $tenant): int => (int) $tenant->getKey(), $this->authorizedTenants(), ); return AuditLogModel::query() ->with(['tenant', 'workspace', 'operationRun']) ->forWorkspace((int) $workspaceId) ->where(function (Builder $query) use ($authorizedTenantIds): void { $query->whereNull('tenant_id'); if ($authorizedTenantIds !== []) { $query->orWhereIn('tenant_id', $authorizedTenantIds); } }) ->when($this->supportAccessOnly, function (Builder $query): void { $query->whereIn('action', SupportAccessGrant::supportAccessAuditActions()); }) ->latestFirst(); } private function resolveAuditLog(int $auditLogId): AuditLogModel { $record = $this->auditBaseQuery() ->whereKey($auditLogId) ->first(); if (! $record instanceof AuditLogModel) { throw new NotFoundHttpException; } return $record; } public function selectedAuditRecord(): ?AuditLogModel { if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) { return null; } $this->normalizeSelectedAuditLogId(); if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) { return null; } try { return $this->resolveAuditLog($this->selectedAuditLogId); } catch (NotFoundHttpException) { return null; } } /** * @return array{label: string, url: string}|null */ public function selectedAuditTargetLink(): ?array { $record = $this->selectedAuditRecord(); if (! $record instanceof AuditLogModel) { return null; } return $this->auditTargetLink($record); } /** * @return array{label: string, url: string}|null */ private function auditTargetLink(AuditLogModel $record): ?array { return app(RelatedNavigationResolver::class)->auditTargetLink($record); } private function auditLogUrl(array $overrides = []): string { $parameters = array_merge( $this->navigationContext()?->toQuery() ?? [], ['supportAccess' => $this->supportAccessOnly ? true : null], ['event' => $this->selectedAuditLogId], $overrides, ); return route( 'admin.monitoring.audit-log', array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []), ); } private function navigationContext(): ?CanonicalNavigationContext { return CanonicalNavigationContext::fromRequest(request()); } private function normalizeSelectedAuditLogId(): void { if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) { $this->selectedAuditLogId = null; return; } $this->selectedAuditLogId = $this->resolveSelectedAuditLogId($this->selectedAuditLogId); } private function resolveSelectedAuditLogId(int $auditLogId): ?int { try { $record = $this->resolveAuditLog($auditLogId); } catch (NotFoundHttpException) { return null; } return $this->selectedAuditVisible((int) $record->getKey()) ? (int) $record->getKey() : null; } private function selectedAuditVisible(int $auditLogId): bool { $record = $this->resolveAuditLog($auditLogId); return $this->matchesSelectedAuditFilters($record) && $this->matchesSelectedAuditSearch($record); } /** * @return array */ private function currentTableFiltersState(): array { $persisted = session()->get($this->getTableFiltersSessionKey(), []); return array_replace_recursive( is_array($persisted) ? $persisted : [], $this->tableFilters ?? [], ); } private function currentTableSearchState(): string { $search = trim((string) ($this->tableSearch ?? '')); if ($search !== '') { return $search; } $persisted = session()->get($this->getTableSearchSessionKey(), ''); return trim(is_string($persisted) ? $persisted : ''); } private function matchesSelectedAuditFilters(AuditLogModel $record): bool { if ( $this->supportAccessOnly && ! in_array((string) $record->action, SupportAccessGrant::supportAccessAuditActions(), true) ) { return false; } $filters = $this->currentTableFiltersState(); $tenantFilter = data_get($filters, 'tenant_id.value'); if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) { return false; } $actionFilter = data_get($filters, 'action.value'); if (is_string($actionFilter) && $actionFilter !== '' && (string) $record->action !== $actionFilter) { return false; } $outcomeFilter = data_get($filters, 'outcome.value'); if (is_string($outcomeFilter) && $outcomeFilter !== '' && $record->normalizedOutcome()->value !== $outcomeFilter) { return false; } $actorFilter = data_get($filters, 'actor_label.value'); if (is_string($actorFilter) && $actorFilter !== '' && (string) $record->actor_label !== $actorFilter) { return false; } $resourceTypeFilter = data_get($filters, 'resource_type.value'); if (is_string($resourceTypeFilter) && $resourceTypeFilter !== '' && (string) $record->resource_type !== $resourceTypeFilter) { return false; } return true; } private function matchesSelectedAuditSearch(AuditLogModel $record): bool { $search = Str::lower($this->currentTableSearchState()); if ($search === '') { return true; } $haystack = Str::lower(implode(' ', [ $record->summaryText(), $record->actorDisplayLabel(), $record->targetDisplayLabel() ?? '', ])); return str_contains($haystack, $search); } /** * @return array */ private function tenantFilterOptions(): array { return collect($this->authorizedTenants()) ->mapWithKeys(static fn (Tenant $tenant): array => [ (string) $tenant->getKey() => $tenant->getFilamentName(), ]) ->all(); } private function defaultTenantFilter(): ?string { $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); if (! $activeTenant instanceof Tenant) { return null; } return array_key_exists((int) $activeTenant->getKey(), $this->authorizedTenants()) ? (string) $activeTenant->getKey() : null; } /** * @return array */ private function actionFilterOptions(): array { $values = (clone $this->auditBaseQuery()) ->reorder() ->select('action') ->distinct() ->orderBy('action') ->pluck('action') ->all(); return FilterOptionCatalog::auditActions($values); } /** * @return array */ private function actorFilterOptions(): array { return (clone $this->auditBaseQuery()) ->reorder() ->whereNotNull('actor_label') ->select('actor_label') ->distinct() ->orderBy('actor_label') ->pluck('actor_label', 'actor_label') ->all(); } /** * @return array */ private function targetTypeFilterOptions(): array { $values = (clone $this->auditBaseQuery()) ->reorder() ->whereNotNull('resource_type') ->select('resource_type') ->distinct() ->orderBy('resource_type') ->pluck('resource_type') ->all(); return FilterOptionCatalog::auditTargetTypes($values); } public function exportSupportAccessHistory(): StreamedResponse { $filename = 'support-access-history-'.now()->format('Ymd-His').'.csv'; return response()->streamDownload(function (): void { $handle = fopen('php://output', 'w'); if ($handle === false) { return; } fputcsv($handle, [ 'recorded_at', 'action', 'outcome', 'actor', 'target', 'scope', 'reason', 'grant_id', ]); $this->supportAccessAuditQuery() ->reorder() ->orderBy('recorded_at') ->orderBy('id') ->cursor() ->each(function (AuditLogModel $record) use ($handle): void { $metadata = is_array($record->metadata) ? $record->metadata : []; fputcsv($handle, [ $this->csvCell((string) $record->recorded_at?->toISOString()), $this->csvCell((string) $record->action), $this->csvCell($record->normalizedOutcome()->value), $this->csvCell($record->actorDisplayLabel()), $this->csvCell($record->targetDisplayLabel() ?? ''), $this->csvCell((string) ($metadata['scope'] ?? '')), $this->csvCell((string) ($metadata['reason'] ?? '')), $this->csvCell((string) ($metadata['support_access_grant_id'] ?? '')), ]); }); fclose($handle); }, $filename, [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } private function supportAccessAuditQuery(): Builder { return $this->auditBaseQuery() ->whereIn('action', SupportAccessGrant::supportAccessAuditActions()); } private function csvCell(string $value): string { $trimmed = trim($value); if ($trimmed !== '' && in_array($trimmed[0], ['=', '+', '-', '@'], true)) { return "'".$trimmed; } return $value; } }