395 lines
14 KiB
PHP
395 lines
14 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\FilterOptionCatalog;
|
|
use App\Support\Filament\FilterPresets;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
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\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 Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use UnitEnum;
|
|
|
|
class AuditLog extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
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)
|
|
->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.');
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->authorizePageAccess();
|
|
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
|
$this->mountInteractsWithTable();
|
|
|
|
if ($this->selectedAuditLogId !== null) {
|
|
$this->selectedAuditLog();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$actions = app(OperateHubShell::class)->headerActions(
|
|
scopeActionName: 'operate_hub_scope_audit_log',
|
|
returnActionName: 'operate_hub_return_audit_log',
|
|
);
|
|
|
|
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
|
$actions[] = Action::make('clear_selected_audit_event')
|
|
->label('Close details')
|
|
->color('gray')
|
|
->action(function (): void {
|
|
$this->clearSelectedAuditLog();
|
|
});
|
|
|
|
$relatedLink = $this->selectedAuditLink();
|
|
|
|
if (is_array($relatedLink)) {
|
|
$actions[] = Action::make('open_selected_audit_target')
|
|
->label($relatedLink['label'])
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray')
|
|
->url($relatedLink['url']);
|
|
}
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
|
|
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')
|
|
->action(function (AuditLogModel $record): void {
|
|
$this->selectedAuditLogId = (int) $record->getKey();
|
|
}),
|
|
])
|
|
->bulkActions([])
|
|
->emptyStateHeading('No audit events match this view')
|
|
->emptyStateDescription('Clear the current search or filters to return to the workspace 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();
|
|
}),
|
|
]);
|
|
}
|
|
|
|
public function clearSelectedAuditLog(): void
|
|
{
|
|
$this->selectedAuditLogId = null;
|
|
}
|
|
|
|
public function selectedAuditLog(): ?AuditLogModel
|
|
{
|
|
if (! is_numeric($this->selectedAuditLogId)) {
|
|
return null;
|
|
}
|
|
|
|
$record = $this->auditBaseQuery()
|
|
->whereKey((int) $this->selectedAuditLogId)
|
|
->first();
|
|
|
|
if (! $record instanceof AuditLogModel) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
return $record;
|
|
}
|
|
|
|
/**
|
|
* @return array{label: string, url: string}|null
|
|
*/
|
|
public function selectedAuditLink(): ?array
|
|
{
|
|
$record = $this->selectedAuditLog();
|
|
|
|
if (! $record instanceof AuditLogModel) {
|
|
return null;
|
|
}
|
|
|
|
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
|
}
|
|
|
|
/**
|
|
* @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();
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|