TenantAtlas/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php
ahmido e02799b383 feat: implement spec 198 monitoring page state contract (#238)
## Summary
- implement Spec 198 monitoring page-state contracts across Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix
- align selected-record and draft/apply behavior with query/session restoration semantics, including canonical navigation and tenant-filter normalization helpers
- add Spec 198 feature and browser coverage, update closure/spec artifacts, and refresh affected regression tests that asserted pre-contract behavior

## Verification
- focused Spec 198 feature pack passed through Sail
- Spec 198 browser smoke passed through Sail
- existing Spec 190 and Spec 194 browser smokes passed through Sail
- targeted fallout tests were updated and rerun during full-suite triage

## Notes
- Livewire v4 / Filament v5 compliant only; no legacy API reintroduction
- no provider registration changes; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- no global-search behavior changed for any resource
- destructive queue decision actions remain confirmation-gated and authorization-backed
- no new Filament assets were added; existing deploy step for `php artisan filament:assets` remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #238
2026-04-15 21:59:42 +00:00

636 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Models\AuditLog as AuditLogModel;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
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\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class AuditLog extends Page implements HasTable
{
use InteractsWithTable;
protected const MONITORING_PAGE_STATE_CONTRACT = [
'surfaceKey' => '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' => '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'],
'localOnlyStateKeys' => [],
];
public ?int $selectedAuditLogId = null;
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<int, Tenant>|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<string, mixed>
*/
public static function monitoringPageStateContract(): array
{
return self::MONITORING_PAGE_STATE_CONTRACT;
}
public function mount(): void
{
$this->authorizePageAccess();
$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<Action>
*/
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),
]);
}
$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<int, Tenant>
*/
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);
}
})
->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() ?? [],
['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<string, mixed>
*/
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
{
$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<string, string>
*/
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<string, string>
*/
private function actionFilterOptions(): array
{
$values = (clone $this->auditBaseQuery())
->reorder()
->select('action')
->distinct()
->orderBy('action')
->pluck('action')
->all();
return FilterOptionCatalog::auditActions($values);
}
/**
* @return array<string, string>
*/
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<string, string>
*/
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);
}
}