feat: lay audit log foundation #163
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -62,6 +62,7 @@ ## Active Technologies
|
||||
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
|
||||
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
|
||||
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -81,8 +82,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 134-audit-log-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 133-detail-page-template: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 132-guid-context-resolver: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 131-cross-resource-navigation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
@ -33,7 +34,7 @@ class TenantpilotPurgeNonPersistentData extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
|
||||
protected $description = 'Permanently delete non-persistent tenant data like policies, backups, and runs while preserving durable audit logs.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
@ -88,10 +89,6 @@ public function handle(): int
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
RestoreRun::withTrashed()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->forceDelete();
|
||||
@ -150,7 +147,7 @@ private function countsForTenant(Tenant $tenant): array
|
||||
return [
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs_retained' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
@ -164,6 +161,8 @@ private function countsForTenant(Tenant $tenant): array
|
||||
*/
|
||||
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
{
|
||||
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
|
||||
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
@ -179,15 +178,16 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
Str::uuid()->toString(),
|
||||
])),
|
||||
'summary_counts' => [
|
||||
'total' => array_sum($counts),
|
||||
'processed' => array_sum($counts),
|
||||
'succeeded' => array_sum($counts),
|
||||
'total' => array_sum($deletedRows),
|
||||
'processed' => array_sum($deletedRows),
|
||||
'succeeded' => array_sum($deletedRows),
|
||||
'failed' => 0,
|
||||
],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'source' => 'tenantpilot:purge-nonpersistent',
|
||||
'deleted_rows' => $counts,
|
||||
'deleted_rows' => $deletedRows,
|
||||
'audit_logs_retained' => $counts['audit_logs_retained'] ?? 0,
|
||||
],
|
||||
'started_at' => now(),
|
||||
'completed_at' => now(),
|
||||
|
||||
@ -4,14 +4,45 @@
|
||||
|
||||
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
|
||||
class AuditLog extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -28,14 +59,336 @@ class AuditLog extends Page
|
||||
|
||||
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
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,17 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Audit\AuditEventBuilder;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
@ -13,12 +21,265 @@ class AuditLog extends Model
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'actor_type' => AuditActorType::class,
|
||||
'metadata' => 'array',
|
||||
'outcome' => AuditOutcome::class,
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $auditLog): void {
|
||||
if ($auditLog->workspace_id === null && is_numeric($auditLog->tenant_id)) {
|
||||
$workspaceId = Tenant::query()
|
||||
->whereKey((int) $auditLog->tenant_id)
|
||||
->value('workspace_id');
|
||||
|
||||
if (is_numeric($workspaceId)) {
|
||||
$auditLog->workspace_id = (int) $workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
$derived = app(AuditEventBuilder::class)->fillMissingDerivedAttributes($auditLog->getAttributes());
|
||||
|
||||
$auditLog->forceFill([
|
||||
'workspace_id' => $derived['workspace_id'] ?? $auditLog->workspace_id,
|
||||
'actor_type' => $derived['actor_type'] ?? $auditLog->actor_type,
|
||||
'actor_label' => $derived['actor_label'] ?? $auditLog->actor_label,
|
||||
'resource_type' => $derived['resource_type'] ?? $auditLog->resource_type,
|
||||
'resource_id' => $derived['resource_id'] ?? $auditLog->resource_id,
|
||||
'target_label' => $derived['target_label'] ?? $auditLog->target_label,
|
||||
'status' => $derived['status'] ?? $auditLog->status,
|
||||
'outcome' => $derived['outcome'] ?? $auditLog->outcome,
|
||||
'summary' => $derived['summary'] ?? $auditLog->summary,
|
||||
'metadata' => $derived['metadata'] ?? $auditLog->metadata,
|
||||
'operation_run_id' => $derived['operation_run_id'] ?? $auditLog->operation_run_id,
|
||||
'recorded_at' => $derived['recorded_at'] ?? $auditLog->recorded_at,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
|
||||
{
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
public function scopeLatestFirst(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByDesc('recorded_at')->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function summaryText(): string
|
||||
{
|
||||
if (filled($this->summary)) {
|
||||
return $this->formattedSummary((string) $this->summary);
|
||||
}
|
||||
|
||||
return AuditActionId::summaryFor(
|
||||
action: (string) $this->action,
|
||||
targetLabel: $this->targetDisplayLabel(),
|
||||
targetType: is_string($this->resource_type) ? $this->resource_type : null,
|
||||
context: is_array($this->metadata) ? $this->metadata : [],
|
||||
);
|
||||
}
|
||||
|
||||
public function normalizedOutcome(): AuditOutcome
|
||||
{
|
||||
return $this->outcome instanceof AuditOutcome
|
||||
? $this->outcome
|
||||
: AuditOutcome::normalize($this->outcome ?? $this->status);
|
||||
}
|
||||
|
||||
public function actorSnapshot(): AuditActorSnapshot
|
||||
{
|
||||
$type = $this->actor_type instanceof AuditActorType
|
||||
? $this->actor_type
|
||||
: (is_string($this->actor_type) && trim($this->actor_type) !== ''
|
||||
? AuditActorType::normalize($this->actor_type)
|
||||
: AuditActorType::infer(
|
||||
action: is_string($this->action) ? $this->action : null,
|
||||
actorId: is_numeric($this->actor_id) ? (int) $this->actor_id : null,
|
||||
actorEmail: is_string($this->actor_email) ? $this->actor_email : null,
|
||||
actorName: is_string($this->actor_name ?? null)
|
||||
? (string) $this->actor_name
|
||||
: null,
|
||||
context: is_array($this->metadata) ? $this->metadata : [],
|
||||
));
|
||||
|
||||
return AuditActorSnapshot::fromLegacy(
|
||||
type: $type,
|
||||
id: is_numeric($this->actor_id) ? (int) $this->actor_id : null,
|
||||
email: is_string($this->actor_email) ? $this->actor_email : null,
|
||||
label: is_string($this->actor_label ?? $this->actor_name ?? null)
|
||||
? (string) ($this->actor_label ?? $this->actor_name)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
public function targetSnapshot(): AuditTargetSnapshot
|
||||
{
|
||||
$type = is_string($this->resource_type) ? $this->resource_type : null;
|
||||
$id = is_string($this->resource_id) || is_numeric($this->resource_id)
|
||||
? (string) $this->resource_id
|
||||
: null;
|
||||
$label = is_string($this->target_label) ? $this->target_label : null;
|
||||
|
||||
if ($type === 'workspace_setting') {
|
||||
$label = $this->formattedWorkspaceSettingLabel($label ?? $id);
|
||||
}
|
||||
|
||||
return new AuditTargetSnapshot(
|
||||
type: $type,
|
||||
id: $id,
|
||||
label: $label,
|
||||
);
|
||||
}
|
||||
|
||||
public function actorDisplayLabel(): string
|
||||
{
|
||||
return $this->actorSnapshot()->labelOrFallback();
|
||||
}
|
||||
|
||||
public function targetDisplayLabel(): ?string
|
||||
{
|
||||
return $this->targetSnapshot()->labelOrFallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, value: string|int|float|bool}>
|
||||
*/
|
||||
public function contextItems(): array
|
||||
{
|
||||
$metadata = is_array($this->metadata) ? $this->metadata : [];
|
||||
$items = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ([
|
||||
'reason',
|
||||
'reason_code',
|
||||
'before_status',
|
||||
'after_status',
|
||||
'from_role',
|
||||
'to_role',
|
||||
'item_count',
|
||||
'policy_count',
|
||||
'assignment_count',
|
||||
'status',
|
||||
'source',
|
||||
'attempted_action',
|
||||
] as $key) {
|
||||
$value = $metadata[$key] ?? null;
|
||||
|
||||
if (! is_scalar($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'label' => ucfirst(str_replace('_', ' ', $key)),
|
||||
'value' => $value,
|
||||
];
|
||||
$seen[] = $key;
|
||||
}
|
||||
|
||||
foreach ($metadata as $key => $value) {
|
||||
if (in_array($key, $seen, true) || in_array($key, ['before', 'after'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_scalar($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'label' => ucfirst(str_replace('_', ' ', (string) $key)),
|
||||
'value' => $value,
|
||||
];
|
||||
|
||||
if (count($items) >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|int|null>
|
||||
*/
|
||||
public function technicalMetadata(): array
|
||||
{
|
||||
return array_filter([
|
||||
'Event type' => $this->action,
|
||||
'Legacy status' => $this->status,
|
||||
'Outcome' => $this->normalizedOutcome()->value,
|
||||
'Actor id' => is_numeric($this->actor_id) ? (int) $this->actor_id : null,
|
||||
'Target type' => $this->resource_type,
|
||||
'Target id' => $this->resource_id,
|
||||
'Operation run' => is_numeric($this->operation_run_id) ? (int) $this->operation_run_id : null,
|
||||
'Tenant scope' => is_numeric($this->tenant_id) ? (int) $this->tenant_id : null,
|
||||
'Workspace scope' => is_numeric($this->workspace_id) ? (int) $this->workspace_id : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
private function formattedSummary(string $summary): string
|
||||
{
|
||||
if ($this->resource_type !== 'workspace_setting') {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$rawTarget = $this->rawWorkspaceSettingTarget();
|
||||
$formattedTarget = $this->formattedWorkspaceSettingLabel($rawTarget);
|
||||
|
||||
if ($rawTarget === null || $formattedTarget === null || $rawTarget === $formattedTarget) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
return str_replace($rawTarget, $formattedTarget, $summary);
|
||||
}
|
||||
|
||||
private function rawWorkspaceSettingTarget(): ?string
|
||||
{
|
||||
if ($this->resource_type !== 'workspace_setting') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($this->target_label) && trim($this->target_label) !== '') {
|
||||
return trim($this->target_label);
|
||||
}
|
||||
|
||||
if (is_string($this->resource_id) && trim($this->resource_id) !== '') {
|
||||
return trim($this->resource_id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function formattedWorkspaceSettingLabel(?string $value): ?string
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::of(str_replace('.', ' ', trim($value)))->headline()->toString();
|
||||
}
|
||||
}
|
||||
|
||||
393
app/Services/Audit/AuditEventBuilder.php
Normal file
393
app/Services/Audit/AuditEventBuilder.php
Normal file
@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use App\Support\OperationCatalog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AuditEventBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildRecordAttributes(
|
||||
string $action,
|
||||
array $context = [],
|
||||
?Workspace $workspace = null,
|
||||
?Tenant $tenant = null,
|
||||
?AuditActorSnapshot $actor = null,
|
||||
?AuditTargetSnapshot $target = null,
|
||||
string|AuditOutcome|null $outcome = null,
|
||||
?CarbonInterface $recordedAt = null,
|
||||
?string $summary = null,
|
||||
?int $operationRunId = null,
|
||||
): array {
|
||||
$metadata = $this->extractMetadata($context);
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata);
|
||||
|
||||
$resolvedTarget = $this->resolveTargetSnapshot(
|
||||
$target,
|
||||
$workspace,
|
||||
$tenant,
|
||||
$sanitizedMetadata,
|
||||
);
|
||||
|
||||
$resolvedActor = $this->resolveActorSnapshot(
|
||||
$action,
|
||||
$actor,
|
||||
$sanitizedMetadata,
|
||||
);
|
||||
|
||||
$resolvedOutcome = $outcome instanceof AuditOutcome
|
||||
? $outcome
|
||||
: AuditOutcome::normalize($outcome ?? ($sanitizedMetadata['status'] ?? null));
|
||||
|
||||
$resolvedOperationRunId = $this->resolveOperationRunId(
|
||||
$operationRunId,
|
||||
$resolvedTarget,
|
||||
$sanitizedMetadata,
|
||||
);
|
||||
|
||||
$resolvedSummary = $summary ?? AuditActionId::summaryFor(
|
||||
action: $action,
|
||||
targetLabel: $resolvedTarget->labelOrFallback(),
|
||||
targetType: $resolvedTarget->type,
|
||||
context: $sanitizedMetadata,
|
||||
);
|
||||
|
||||
return [
|
||||
'tenant_id' => $tenant?->getKey(),
|
||||
'workspace_id' => $workspace?->getKey() ?? $tenant?->workspace_id,
|
||||
'actor_id' => is_numeric($resolvedActor->id) ? (int) $resolvedActor->id : null,
|
||||
'actor_email' => $resolvedActor->email,
|
||||
'actor_name' => $resolvedActor->label,
|
||||
'actor_type' => $resolvedActor->type->value,
|
||||
'actor_label' => $resolvedActor->labelOrFallback(),
|
||||
'action' => trim($action),
|
||||
'resource_type' => $resolvedTarget->type,
|
||||
'resource_id' => $resolvedTarget->id !== null ? (string) $resolvedTarget->id : null,
|
||||
'target_label' => $resolvedTarget->labelOrFallback(),
|
||||
'status' => $resolvedOutcome->value,
|
||||
'outcome' => $resolvedOutcome->value,
|
||||
'summary' => $resolvedSummary,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'operation_run_id' => $resolvedOperationRunId,
|
||||
'recorded_at' => ($recordedAt ?? CarbonImmutable::now())->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function fillMissingDerivedAttributes(array $attributes): array
|
||||
{
|
||||
$tenant = null;
|
||||
if (is_numeric($attributes['tenant_id'] ?? null)) {
|
||||
$tenant = Tenant::query()->whereKey((int) $attributes['tenant_id'])->first();
|
||||
}
|
||||
|
||||
$workspace = null;
|
||||
if (is_numeric($attributes['workspace_id'] ?? null)) {
|
||||
$workspace = Workspace::query()->whereKey((int) $attributes['workspace_id'])->first();
|
||||
}
|
||||
|
||||
$metadata = $this->normalizeMetadata($attributes['metadata'] ?? null);
|
||||
|
||||
$actorType = filled($attributes['actor_type'] ?? null)
|
||||
? AuditActorType::normalize($attributes['actor_type'])
|
||||
: AuditActorType::infer(
|
||||
action: is_string($attributes['action'] ?? null) ? (string) $attributes['action'] : null,
|
||||
actorId: is_numeric($attributes['actor_id'] ?? null) ? (int) $attributes['actor_id'] : null,
|
||||
actorEmail: is_string($attributes['actor_email'] ?? null) ? (string) $attributes['actor_email'] : null,
|
||||
actorName: is_string($attributes['actor_name'] ?? null) ? (string) $attributes['actor_name'] : null,
|
||||
context: $metadata,
|
||||
);
|
||||
|
||||
$actor = AuditActorSnapshot::fromLegacy(
|
||||
type: $actorType,
|
||||
id: is_numeric($attributes['actor_id'] ?? null) ? (int) $attributes['actor_id'] : null,
|
||||
email: is_string($attributes['actor_email'] ?? null) ? (string) $attributes['actor_email'] : null,
|
||||
label: is_string($attributes['actor_label'] ?? $attributes['actor_name'] ?? null)
|
||||
? (string) ($attributes['actor_label'] ?? $attributes['actor_name'])
|
||||
: null,
|
||||
);
|
||||
|
||||
$target = new AuditTargetSnapshot(
|
||||
type: is_string($attributes['resource_type'] ?? null) ? (string) $attributes['resource_type'] : null,
|
||||
id: is_string($attributes['resource_id'] ?? null) || is_numeric($attributes['resource_id'] ?? null)
|
||||
? (string) $attributes['resource_id']
|
||||
: null,
|
||||
label: is_string($attributes['target_label'] ?? null) ? (string) $attributes['target_label'] : null,
|
||||
);
|
||||
|
||||
return array_replace(
|
||||
$attributes,
|
||||
$this->buildRecordAttributes(
|
||||
action: (string) ($attributes['action'] ?? 'audit.event'),
|
||||
context: $metadata,
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
target: $target,
|
||||
outcome: (string) ($attributes['outcome'] ?? $attributes['status'] ?? AuditOutcome::Info->value),
|
||||
recordedAt: isset($attributes['recorded_at']) && $attributes['recorded_at'] !== null
|
||||
? CarbonImmutable::parse((string) $attributes['recorded_at'])
|
||||
: null,
|
||||
summary: is_string($attributes['summary'] ?? null) ? (string) $attributes['summary'] : null,
|
||||
operationRunId: is_numeric($attributes['operation_run_id'] ?? null)
|
||||
? (int) $attributes['operation_run_id']
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractMetadata(array $context): array
|
||||
{
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
if (! is_array($metadata)) {
|
||||
$metadata = [];
|
||||
}
|
||||
|
||||
return $metadata + $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function resolveActorSnapshot(
|
||||
string $action,
|
||||
?AuditActorSnapshot $actor,
|
||||
array $metadata,
|
||||
): AuditActorSnapshot {
|
||||
if ($actor instanceof AuditActorSnapshot) {
|
||||
return $actor;
|
||||
}
|
||||
|
||||
return AuditActorSnapshot::fromLegacy(
|
||||
type: AuditActorType::infer($action, null, null, null, $metadata),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function resolveTargetSnapshot(
|
||||
?AuditTargetSnapshot $target,
|
||||
?Workspace $workspace,
|
||||
?Tenant $tenant,
|
||||
array $metadata,
|
||||
): AuditTargetSnapshot {
|
||||
$type = $target?->type;
|
||||
$id = $target?->id;
|
||||
|
||||
if (! filled($type) || ! filled($id)) {
|
||||
[$type, $id] = $this->inferTargetIdentity($type, $id, $metadata);
|
||||
}
|
||||
|
||||
$label = $target?->label;
|
||||
|
||||
if (! filled($label)) {
|
||||
$label = $this->resolveTargetLabel($type, $id, $workspace, $tenant);
|
||||
}
|
||||
|
||||
return new AuditTargetSnapshot(
|
||||
type: $type,
|
||||
id: $id,
|
||||
label: $label,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{0: ?string, 1: int|string|null}
|
||||
*/
|
||||
private function inferTargetIdentity(?string $type, int|string|null $id, array $metadata): array
|
||||
{
|
||||
if (filled($type) && filled($id)) {
|
||||
return [$type, $id];
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'finding_id' => 'finding',
|
||||
'baseline_profile_id' => 'baseline_profile',
|
||||
'baseline_snapshot_id' => 'baseline_snapshot',
|
||||
'backup_set_id' => 'backup_set',
|
||||
'backup_schedule_id' => 'backup_schedule',
|
||||
'restore_run_id' => 'restore_run',
|
||||
'operation_run_id' => 'operation_run',
|
||||
'workspace_id' => 'workspace',
|
||||
'tenant_id' => 'tenant',
|
||||
'alert_rule_id' => 'alert_rule',
|
||||
'alert_destination_id' => 'alert_destination',
|
||||
] as $key => $resolvedType) {
|
||||
if (! filled($metadata[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [$type ?? $resolvedType, (string) $metadata[$key]];
|
||||
}
|
||||
|
||||
return [$type, $id];
|
||||
}
|
||||
|
||||
private function resolveOperationRunId(
|
||||
?int $operationRunId,
|
||||
AuditTargetSnapshot $target,
|
||||
array $metadata,
|
||||
): ?int {
|
||||
if ($operationRunId !== null) {
|
||||
return $this->existingOperationRunId($operationRunId);
|
||||
}
|
||||
|
||||
if (is_numeric($metadata['operation_run_id'] ?? null)) {
|
||||
return $this->existingOperationRunId((int) $metadata['operation_run_id']);
|
||||
}
|
||||
|
||||
if ($target->type === 'operation_run' && is_numeric($target->id)) {
|
||||
return $this->existingOperationRunId((int) $target->id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeMetadata(mixed $metadata): array
|
||||
{
|
||||
if (is_array($metadata)) {
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
if (! is_string($metadata) || trim($metadata) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($metadata, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function existingOperationRunId(?int $operationRunId): ?int
|
||||
{
|
||||
if ($operationRunId === null || $operationRunId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRun::query()->whereKey($operationRunId)->exists()
|
||||
? $operationRunId
|
||||
: null;
|
||||
}
|
||||
|
||||
private function resolveTargetLabel(
|
||||
?string $type,
|
||||
int|string|null $id,
|
||||
?Workspace $workspace,
|
||||
?Tenant $tenant,
|
||||
): ?string {
|
||||
if (! filled($type) && ! filled($id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($type === 'workspace' && $workspace instanceof Workspace) {
|
||||
return $workspace->name;
|
||||
}
|
||||
|
||||
if ($type === 'tenant' && $tenant instanceof Tenant) {
|
||||
return $tenant->name;
|
||||
}
|
||||
|
||||
if (! filled($type) || ! filled($id)) {
|
||||
return (new AuditTargetSnapshot($type, $id))->labelOrFallback();
|
||||
}
|
||||
|
||||
$numericId = is_numeric($id) ? (int) $id : null;
|
||||
|
||||
return match ($type) {
|
||||
'backup_schedule' => $numericId !== null
|
||||
? BackupSchedule::query()->whereKey($numericId)->value('name') ?: sprintf('Backup schedule #%d', $numericId)
|
||||
: null,
|
||||
'backup_set' => $numericId !== null
|
||||
? BackupSet::query()->withTrashed()->whereKey($numericId)->value('name') ?: sprintf('Backup set #%d', $numericId)
|
||||
: null,
|
||||
'baseline_profile' => $numericId !== null
|
||||
? BaselineProfile::query()->whereKey($numericId)->value('name') ?: sprintf('Baseline profile #%d', $numericId)
|
||||
: null,
|
||||
'finding' => $numericId !== null
|
||||
? $this->resolveFindingLabel($numericId)
|
||||
: null,
|
||||
'operation_run' => $numericId !== null
|
||||
? $this->resolveOperationRunLabel($numericId)
|
||||
: null,
|
||||
'restore_run' => $numericId !== null
|
||||
? sprintf('Restore run #%d', $numericId)
|
||||
: null,
|
||||
'alert_rule' => $numericId !== null
|
||||
? AlertRule::query()->whereKey($numericId)->value('name') ?: sprintf('Alert rule #%d', $numericId)
|
||||
: null,
|
||||
'alert_destination' => $numericId !== null
|
||||
? AlertDestination::query()->whereKey($numericId)->value('name') ?: sprintf('Alert destination #%d', $numericId)
|
||||
: null,
|
||||
'workspace_setting' => is_string($id) ? $this->formatWorkspaceSettingLabel($id) : null,
|
||||
default => (new AuditTargetSnapshot($type, $id))->labelOrFallback(),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveFindingLabel(int $id): string
|
||||
{
|
||||
/** @var Finding|null $finding */
|
||||
$finding = Finding::query()->whereKey($id)->first();
|
||||
|
||||
if (! $finding instanceof Finding) {
|
||||
return sprintf('Finding #%d', $id);
|
||||
}
|
||||
|
||||
$findingType = str_replace('_', ' ', (string) $finding->finding_type);
|
||||
|
||||
return sprintf('%s finding #%d', ucfirst($findingType), $id);
|
||||
}
|
||||
|
||||
private function resolveOperationRunLabel(int $id): string
|
||||
{
|
||||
/** @var OperationRun|null $run */
|
||||
$run = OperationRun::query()->whereKey($id)->first();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return sprintf('Operation run #%d', $id);
|
||||
}
|
||||
|
||||
return sprintf('%s #%d', OperationCatalog::label((string) $run->type), $id);
|
||||
}
|
||||
|
||||
private function formatWorkspaceSettingLabel(string $value): string
|
||||
{
|
||||
return Str::of(str_replace('.', ' ', trim($value)))->headline()->toString();
|
||||
}
|
||||
}
|
||||
54
app/Services/Audit/AuditRecorder.php
Normal file
54
app/Services/Audit/AuditRecorder.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
final class AuditRecorder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditEventBuilder $builder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function record(
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
?Workspace $workspace = null,
|
||||
?Tenant $tenant = null,
|
||||
?AuditActorSnapshot $actor = null,
|
||||
?AuditTargetSnapshot $target = null,
|
||||
string|AuditOutcome|null $outcome = null,
|
||||
?CarbonInterface $recordedAt = null,
|
||||
?string $summary = null,
|
||||
?int $operationRunId = null,
|
||||
): AuditLog {
|
||||
$actionValue = $action instanceof AuditActionId ? $action->value : trim($action);
|
||||
|
||||
return AuditLog::query()->create(
|
||||
$this->builder->buildRecordAttributes(
|
||||
action: $actionValue,
|
||||
context: $context,
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
target: $target,
|
||||
outcome: $outcome,
|
||||
recordedAt: $recordedAt,
|
||||
summary: $summary,
|
||||
operationRunId: $operationRunId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,17 +4,23 @@
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class WorkspaceAuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditRecorder $auditRecorder,
|
||||
) {}
|
||||
|
||||
public function log(
|
||||
Workspace $workspace,
|
||||
string $action,
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
?User $actor = null,
|
||||
string $status = 'success',
|
||||
@ -23,26 +29,34 @@ public function log(
|
||||
?int $actorId = null,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
): AuditLog {
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
?AuditActorType $actorType = null,
|
||||
?string $targetLabel = null,
|
||||
?string $summary = null,
|
||||
?int $operationRunId = null,
|
||||
): \App\Models\AuditLog {
|
||||
$resolvedActor = $actor instanceof User
|
||||
? AuditActorSnapshot::human($actor)
|
||||
: AuditActorSnapshot::fromLegacy(
|
||||
type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context),
|
||||
id: $actorId,
|
||||
email: $actorEmail,
|
||||
label: $actorName,
|
||||
);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => null,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'actor_id' => $actor?->getKey() ?? $actorId,
|
||||
'actor_email' => $actor?->email ?? $actorEmail,
|
||||
'actor_name' => $actor?->name ?? $actorName,
|
||||
'action' => $action,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
return $this->auditRecorder->record(
|
||||
action: $action,
|
||||
context: $context,
|
||||
workspace: $workspace,
|
||||
actor: $resolvedActor,
|
||||
target: new AuditTargetSnapshot(
|
||||
type: $resourceType,
|
||||
id: $resourceId,
|
||||
label: $targetLabel,
|
||||
),
|
||||
outcome: $status,
|
||||
recordedAt: CarbonImmutable::now(),
|
||||
summary: $summary,
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::ALERTS_MANAGE,
|
||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
|
||||
WorkspaceRole::Manager->value => [
|
||||
@ -58,6 +59,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::ALERTS_MANAGE,
|
||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
|
||||
WorkspaceRole::Operator->value => [
|
||||
@ -71,6 +73,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_SETTINGS_VIEW,
|
||||
Capabilities::ALERTS_VIEW,
|
||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
|
||||
WorkspaceRole::Readonly->value => [
|
||||
@ -78,6 +81,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_SETTINGS_VIEW,
|
||||
Capabilities::ALERTS_VIEW,
|
||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@ -1,18 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
use App\Services\Audit\AuditRecorder;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use Carbon\CarbonImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class AuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditRecorder $auditRecorder,
|
||||
) {}
|
||||
|
||||
public function log(
|
||||
Tenant $tenant,
|
||||
string $action,
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
?int $actorId = null,
|
||||
?string $actorEmail = null,
|
||||
@ -20,31 +29,37 @@ public function log(
|
||||
string $status = 'success',
|
||||
?string $resourceType = null,
|
||||
?string $resourceId = null,
|
||||
): AuditLog {
|
||||
$metadata = $context['metadata'] ?? [];
|
||||
unset($context['metadata']);
|
||||
|
||||
$metadata = is_array($metadata) ? $metadata : [];
|
||||
|
||||
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
|
||||
?AuditActorType $actorType = null,
|
||||
?string $targetLabel = null,
|
||||
?string $summary = null,
|
||||
?int $operationRunId = null,
|
||||
): \App\Models\AuditLog {
|
||||
$workspaceId = is_numeric($tenant->workspace_id) ? (int) $tenant->workspace_id : null;
|
||||
|
||||
if ($workspaceId === null) {
|
||||
throw new InvalidArgumentException('Tenant-scoped audit events require tenant workspace_id.');
|
||||
}
|
||||
|
||||
return AuditLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $workspaceId,
|
||||
'actor_id' => $actorId,
|
||||
'actor_email' => $actorEmail,
|
||||
'actor_name' => $actorName,
|
||||
'action' => $action,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_id' => $resourceId,
|
||||
'status' => $status,
|
||||
'metadata' => $sanitizedMetadata,
|
||||
'recorded_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
return $this->auditRecorder->record(
|
||||
action: $action,
|
||||
context: $context,
|
||||
workspace: $tenant->workspace,
|
||||
tenant: $tenant,
|
||||
actor: AuditActorSnapshot::fromLegacy(
|
||||
type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context),
|
||||
id: $actorId,
|
||||
email: $actorEmail,
|
||||
label: $actorName,
|
||||
),
|
||||
target: new AuditTargetSnapshot(
|
||||
type: $resourceType,
|
||||
id: $resourceId,
|
||||
label: $targetLabel,
|
||||
),
|
||||
outcome: $status,
|
||||
recordedAt: CarbonImmutable::now(),
|
||||
summary: $summary,
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,11 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
|
||||
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
|
||||
use App\Services\Audit\AuditRecorder;
|
||||
use App\Services\Operations\BulkIdempotencyFingerprint;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\BulkRunContext;
|
||||
@ -23,6 +27,10 @@
|
||||
|
||||
class OperationRunService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditRecorder $auditRecorder,
|
||||
) {}
|
||||
|
||||
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
||||
{
|
||||
if ($run->status !== OperationRunStatus::Queued->value) {
|
||||
@ -484,9 +492,12 @@ public function updateRun(
|
||||
|
||||
if ($previousStatus !== OperationRunStatus::Completed->value
|
||||
&& $run->status === OperationRunStatus::Completed->value
|
||||
&& $run->user instanceof User
|
||||
) {
|
||||
$run->user->notify(new OperationRunCompletedNotification($run));
|
||||
$this->writeTerminalAudit($run);
|
||||
|
||||
if ($run->user instanceof User) {
|
||||
$run->user->notify(new OperationRunCompletedNotification($run));
|
||||
}
|
||||
}
|
||||
|
||||
return $run;
|
||||
@ -873,4 +884,47 @@ protected function sanitizeNextSteps(array $nextSteps): array
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private function writeTerminalAudit(OperationRun $run): void
|
||||
{
|
||||
$tenant = $run->tenant;
|
||||
$workspace = $run->workspace;
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
|
||||
$action = match ($run->outcome) {
|
||||
OperationRunOutcome::Succeeded->value => 'operation.completed',
|
||||
OperationRunOutcome::PartiallySucceeded->value => 'operation.partial',
|
||||
OperationRunOutcome::Blocked->value => 'operation.blocked',
|
||||
default => 'operation.failed',
|
||||
};
|
||||
|
||||
$summary = match ($run->outcome) {
|
||||
OperationRunOutcome::Succeeded->value => sprintf('%s completed', $operationLabel),
|
||||
OperationRunOutcome::PartiallySucceeded->value => sprintf('%s partially completed', $operationLabel),
|
||||
OperationRunOutcome::Blocked->value => sprintf('%s blocked', $operationLabel),
|
||||
default => sprintf('%s failed', $operationLabel),
|
||||
};
|
||||
|
||||
$this->auditRecorder->record(
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'operation_type' => $run->type,
|
||||
'summary_counts' => $run->summary_counts,
|
||||
'failure_summary' => $run->failure_summary,
|
||||
'target_scope' => is_array($run->context) ? ($run->context['target_scope'] ?? null) : null,
|
||||
],
|
||||
],
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
actor: $run->user instanceof User ? AuditActorSnapshot::human($run->user) : null,
|
||||
target: new AuditTargetSnapshot(
|
||||
type: 'operation_run',
|
||||
id: (int) $run->getKey(),
|
||||
),
|
||||
outcome: $run->outcome,
|
||||
summary: $summary,
|
||||
operationRunId: (int) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
|
||||
final class SystemConsoleAuditLogger
|
||||
{
|
||||
@ -49,6 +50,8 @@ public function log(
|
||||
status: trim($status),
|
||||
resourceType: $run instanceof OperationRun ? 'operation_run' : null,
|
||||
resourceId: $run instanceof OperationRun ? (string) $run->getKey() : null,
|
||||
actorType: AuditActorType::Platform,
|
||||
operationRunId: $run instanceof OperationRun ? (int) $run->getKey() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,4 +63,172 @@ enum AuditActionId: string
|
||||
// Workspace selection / switch events (Spec 107).
|
||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||
case WorkspaceSelected = 'workspace.selected';
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function knownValues(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $case): string => $case->value,
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
public static function labelFor(string|self $action): string
|
||||
{
|
||||
$value = $action instanceof self ? $action->value : trim($action);
|
||||
|
||||
return self::labels()[$value] ?? self::humanize($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function summaryFor(
|
||||
string|self $action,
|
||||
?string $targetLabel = null,
|
||||
?string $targetType = null,
|
||||
array $context = [],
|
||||
): string {
|
||||
$value = $action instanceof self ? $action->value : trim($action);
|
||||
$summary = self::summaries()[$value] ?? self::labelFor($value);
|
||||
|
||||
if ($targetLabel !== null && $targetLabel !== '' && ! str_contains($summary, $targetLabel)) {
|
||||
$summary .= ' for '.$targetLabel;
|
||||
} elseif (($targetLabel === null || $targetLabel === '') && filled($targetType)) {
|
||||
$summary .= ' for '.self::humanize((string) $targetType);
|
||||
}
|
||||
|
||||
if (($context['after_status'] ?? null) !== null && ($context['before_status'] ?? null) !== null && ! str_contains($summary, 'status')) {
|
||||
return sprintf(
|
||||
'%s (%s -> %s)',
|
||||
$summary,
|
||||
(string) $context['before_status'],
|
||||
(string) $context['after_status'],
|
||||
);
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function labels(): array
|
||||
{
|
||||
return [
|
||||
self::WorkspaceMembershipAdd->value => 'Workspace member add',
|
||||
self::WorkspaceMembershipRoleChange->value => 'Workspace member role change',
|
||||
self::WorkspaceMembershipRemove->value => 'Workspace member removal',
|
||||
self::WorkspaceMembershipLastOwnerBlocked->value => 'Workspace last-owner protection',
|
||||
self::TenantMembershipAdd->value => 'Tenant member add',
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role change',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removal',
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection',
|
||||
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
|
||||
self::ManagedTenantOnboardingResume->value => 'Managed tenant onboarding resume',
|
||||
self::ManagedTenantOnboardingVerificationStart->value => 'Managed tenant onboarding verification start',
|
||||
self::ManagedTenantOnboardingActivation->value => 'Managed tenant onboarding activation',
|
||||
self::VerificationCompleted->value => 'Verification completed',
|
||||
self::VerificationCheckAcknowledged->value => 'Verification check acknowledged',
|
||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||
self::AlertDestinationEnabled->value => 'Alert destination enabled',
|
||||
self::AlertDestinationDisabled->value => 'Alert destination disabled',
|
||||
self::AlertDestinationTestRequested->value => 'Alert destination test requested',
|
||||
self::AlertRuleCreated->value => 'Alert rule created',
|
||||
self::AlertRuleUpdated->value => 'Alert rule updated',
|
||||
self::AlertRuleDeleted->value => 'Alert rule deleted',
|
||||
self::AlertRuleEnabled->value => 'Alert rule enabled',
|
||||
self::AlertRuleDisabled->value => 'Alert rule disabled',
|
||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
self::BaselineCaptureStarted->value => 'Baseline capture started',
|
||||
self::BaselineCaptureCompleted->value => 'Baseline capture completed',
|
||||
self::BaselineCaptureFailed->value => 'Baseline capture failed',
|
||||
self::BaselineCompareStarted->value => 'Baseline compare started',
|
||||
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
||||
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
||||
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
||||
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
||||
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
||||
self::WorkspaceAutoSelected->value => 'Workspace auto-selected',
|
||||
self::WorkspaceSelected->value => 'Workspace selected',
|
||||
'finding.triaged' => 'Finding triaged',
|
||||
'finding.in_progress' => 'Finding moved to in progress',
|
||||
'finding.assigned' => 'Finding assignment updated',
|
||||
'finding.resolved' => 'Finding resolved',
|
||||
'finding.closed' => 'Finding closed',
|
||||
'finding.risk_accepted' => 'Finding risk accepted',
|
||||
'finding.reopened' => 'Finding reopened',
|
||||
'baseline.capture.started' => 'Baseline capture started',
|
||||
'baseline.capture.completed' => 'Baseline capture completed',
|
||||
'baseline.capture.failed' => 'Baseline capture failed',
|
||||
'baseline.compare.started' => 'Baseline compare started',
|
||||
'baseline.compare.completed' => 'Baseline compare completed',
|
||||
'baseline.compare.failed' => 'Baseline compare failed',
|
||||
'baseline.evidence.resume.started' => 'Baseline evidence capture resumed',
|
||||
'backup.created' => 'Backup set created',
|
||||
'backup.updated' => 'Backup set updated',
|
||||
'backup.archived' => 'Backup set archived',
|
||||
'backup.items_added' => 'Backup set items added',
|
||||
'backup.assignments.included' => 'Backup set assignments included',
|
||||
'backup_schedule.run_started' => 'Backup schedule run started',
|
||||
'backup_schedule.run_finished' => 'Backup schedule run finished',
|
||||
'backup_schedule.run_failed' => 'Backup schedule run failed',
|
||||
'backup_schedule.run_skipped' => 'Backup schedule run skipped',
|
||||
'backup_schedule.retention_applied' => 'Backup schedule retention applied',
|
||||
'restore.started' => 'Restore started',
|
||||
'restore.previewed' => 'Restore preview completed',
|
||||
'restore.executed' => 'Restore executed',
|
||||
'restore.failed' => 'Restore failed',
|
||||
'restore.assignments.summary' => 'Restore assignment summary recorded',
|
||||
'restore.group_mapping.applied' => 'Restore group mapping applied',
|
||||
'operation.completed' => 'Operation completed',
|
||||
'operation.failed' => 'Operation failed',
|
||||
'operation.partial' => 'Operation partially completed',
|
||||
'operation.blocked' => 'Operation blocked',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function summaries(): array
|
||||
{
|
||||
return [
|
||||
self::WorkspaceMembershipAdd->value => 'Workspace member added',
|
||||
self::WorkspaceMembershipRoleChange->value => 'Workspace member role changed',
|
||||
self::WorkspaceMembershipRemove->value => 'Workspace member removed',
|
||||
self::WorkspaceMembershipLastOwnerBlocked->value => 'Workspace last-owner protection triggered',
|
||||
self::TenantMembershipAdd->value => 'Tenant member added',
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role changed',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removed',
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection triggered',
|
||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||
self::AlertRuleCreated->value => 'Alert rule created',
|
||||
self::AlertRuleUpdated->value => 'Alert rule updated',
|
||||
self::AlertRuleDeleted->value => 'Alert rule deleted',
|
||||
];
|
||||
}
|
||||
|
||||
private static function humanize(string $value): string
|
||||
{
|
||||
$normalized = str_replace(['.', '_', '-'], ' ', trim($value));
|
||||
$normalized = preg_replace('/\s+/', ' ', $normalized) ?? $normalized;
|
||||
|
||||
return ucfirst($normalized);
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Support/Audit/AuditActorSnapshot.php
Normal file
78
app/Support/Audit/AuditActorSnapshot.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
|
||||
final readonly class AuditActorSnapshot
|
||||
{
|
||||
public function __construct(
|
||||
public AuditActorType $type,
|
||||
public int|string|null $id = null,
|
||||
public ?string $label = null,
|
||||
public ?string $email = null,
|
||||
) {}
|
||||
|
||||
public static function human(User $user): self
|
||||
{
|
||||
return new self(
|
||||
type: AuditActorType::Human,
|
||||
id: (int) $user->getKey(),
|
||||
label: filled($user->name) ? (string) $user->name : (filled($user->email) ? (string) $user->email : null),
|
||||
email: filled($user->email) ? (string) $user->email : null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function platform(PlatformUser $user): self
|
||||
{
|
||||
return new self(
|
||||
type: AuditActorType::Platform,
|
||||
id: (int) $user->getKey(),
|
||||
label: filled($user->name) ? (string) $user->name : (filled($user->email) ? (string) $user->email : null),
|
||||
email: filled($user->email) ? (string) $user->email : null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromLegacy(
|
||||
AuditActorType $type,
|
||||
?int $id = null,
|
||||
?string $email = null,
|
||||
?string $label = null,
|
||||
): self {
|
||||
return new self(
|
||||
type: $type,
|
||||
id: $id,
|
||||
label: filled($label) ? trim((string) $label) : (filled($email) ? trim((string) $email) : null),
|
||||
email: filled($email) ? trim((string) $email) : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function labelOrFallback(): string
|
||||
{
|
||||
if (filled($this->label)) {
|
||||
return (string) $this->label;
|
||||
}
|
||||
|
||||
if (filled($this->email)) {
|
||||
return (string) $this->email;
|
||||
}
|
||||
|
||||
return $this->type->label();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actor_type: string, actor_id: int|string|null, actor_label: string, actor_email: ?string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'actor_type' => $this->type->value,
|
||||
'actor_id' => $this->id,
|
||||
'actor_label' => $this->labelOrFallback(),
|
||||
'actor_email' => $this->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Support/Audit/AuditActorType.php
Normal file
68
app/Support/Audit/AuditActorType.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
enum AuditActorType: string
|
||||
{
|
||||
case Human = 'human';
|
||||
case System = 'system';
|
||||
case Scheduled = 'scheduled';
|
||||
case Integration = 'integration';
|
||||
case Platform = 'platform';
|
||||
|
||||
public static function normalize(mixed $value): self
|
||||
{
|
||||
$normalized = is_string($value) ? strtolower(trim($value)) : null;
|
||||
|
||||
return match ($normalized) {
|
||||
self::Human->value => self::Human,
|
||||
self::Scheduled->value, 'scheduler', 'cron' => self::Scheduled,
|
||||
self::Integration->value => self::Integration,
|
||||
self::Platform->value => self::Platform,
|
||||
default => self::System,
|
||||
};
|
||||
}
|
||||
|
||||
public static function infer(
|
||||
?string $action,
|
||||
?int $actorId,
|
||||
?string $actorEmail,
|
||||
?string $actorName,
|
||||
array $context = [],
|
||||
): self {
|
||||
if ($actorId !== null || filled($actorEmail) || filled($actorName)) {
|
||||
return self::Human;
|
||||
}
|
||||
|
||||
$declared = $context['_actor_type'] ?? null;
|
||||
|
||||
if (is_string($declared) && trim($declared) !== '') {
|
||||
return self::normalize($declared);
|
||||
}
|
||||
|
||||
$normalizedAction = strtolower(trim((string) $action));
|
||||
|
||||
if (str_starts_with($normalizedAction, 'platform.')) {
|
||||
return self::Platform;
|
||||
}
|
||||
|
||||
if (str_contains($normalizedAction, 'schedule') || array_key_exists('backup_schedule_id', $context)) {
|
||||
return self::Scheduled;
|
||||
}
|
||||
|
||||
return self::System;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Human => 'Human',
|
||||
self::System => 'System',
|
||||
self::Scheduled => 'Scheduled job',
|
||||
self::Integration => 'Integration',
|
||||
self::Platform => 'Platform',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -10,12 +10,25 @@ final class AuditContextSanitizer
|
||||
{
|
||||
private const REDACTED = '[REDACTED]';
|
||||
|
||||
private const MAX_ITEMS = 50;
|
||||
|
||||
private const MAX_STRING_LENGTH = 500;
|
||||
|
||||
public static function sanitize(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$sanitized = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
$count++;
|
||||
|
||||
if ($count > self::MAX_ITEMS) {
|
||||
$sanitized['truncated'] = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (is_string($key) && self::classifier()->protectsField('audit', $key)) {
|
||||
$sanitized[$key] = self::REDACTED;
|
||||
|
||||
@ -43,7 +56,13 @@ private static function sanitizeString(string $value): string
|
||||
return $value;
|
||||
}
|
||||
|
||||
return self::classifier()->sanitizeAuditString($value);
|
||||
$sanitized = self::classifier()->sanitizeAuditString($value);
|
||||
|
||||
if (mb_strlen($sanitized) <= self::MAX_STRING_LENGTH) {
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
return mb_substr($sanitized, 0, self::MAX_STRING_LENGTH).' [truncated]';
|
||||
}
|
||||
|
||||
private static function classifier(): SecretClassificationService
|
||||
|
||||
44
app/Support/Audit/AuditOutcome.php
Normal file
44
app/Support/Audit/AuditOutcome.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
enum AuditOutcome: string
|
||||
{
|
||||
case Success = 'success';
|
||||
case Failed = 'failed';
|
||||
case Partial = 'partial';
|
||||
case Info = 'info';
|
||||
case Blocked = 'blocked';
|
||||
|
||||
public static function normalize(mixed $value): self
|
||||
{
|
||||
$normalized = is_string($value) ? strtolower(trim($value)) : null;
|
||||
|
||||
return match ($normalized) {
|
||||
self::Success->value, 'succeeded', 'completed', 'complete', 'ok' => self::Success,
|
||||
self::Failed->value, 'failure', 'error', 'errored' => self::Failed,
|
||||
self::Partial->value, 'partially_succeeded', 'partial_success', 'partial_failure' => self::Partial,
|
||||
self::Blocked->value, 'skipped', 'deferred', 'cancelled', 'canceled' => self::Blocked,
|
||||
self::Info->value, 'pending', 'queued', 'running' => self::Info,
|
||||
default => self::Info,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeValue(mixed $value): string
|
||||
{
|
||||
return self::normalize($value)->value;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Success => 'Success',
|
||||
self::Failed => 'Failed',
|
||||
self::Partial => 'Partial',
|
||||
self::Info => 'Info',
|
||||
self::Blocked => 'Blocked',
|
||||
};
|
||||
}
|
||||
}
|
||||
49
app/Support/Audit/AuditTargetSnapshot.php
Normal file
49
app/Support/Audit/AuditTargetSnapshot.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Audit;
|
||||
|
||||
final readonly class AuditTargetSnapshot
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $type = null,
|
||||
public int|string|null $id = null,
|
||||
public ?string $label = null,
|
||||
) {}
|
||||
|
||||
public function labelOrFallback(): ?string
|
||||
{
|
||||
if (filled($this->label)) {
|
||||
return (string) $this->label;
|
||||
}
|
||||
|
||||
if (! filled($this->type) && ! filled($this->id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = is_string($this->type) && trim($this->type) !== ''
|
||||
? trim(str_replace(['_', '.'], ' ', $this->type))
|
||||
: 'Record';
|
||||
|
||||
$type = ucfirst($type);
|
||||
|
||||
if (filled($this->id)) {
|
||||
return sprintf('%s #%s', $type, (string) $this->id);
|
||||
}
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{target_type: ?string, target_id: int|string|null, target_label: ?string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'target_type' => $this->type,
|
||||
'target_id' => $this->id,
|
||||
'target_label' => $this->labelOrFallback(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ final class BadgeCatalog
|
||||
* @var array<string, class-string<BadgeMapper>>
|
||||
*/
|
||||
private const DOMAIN_MAPPERS = [
|
||||
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
||||
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
|
||||
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
enum BadgeDomain: string
|
||||
{
|
||||
case AuditOutcome = 'audit_outcome';
|
||||
case AuditActorType = 'audit_actor_type';
|
||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||
case OperationRunStatus = 'operation_run_status';
|
||||
|
||||
26
app/Support/Badges/Domains/AuditActorTypeBadge.php
Normal file
26
app/Support/Badges/Domains/AuditActorTypeBadge.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class AuditActorTypeBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = AuditActorType::normalize(BadgeCatalog::normalizeState($value));
|
||||
|
||||
return match ($state) {
|
||||
AuditActorType::Human => new BadgeSpec('Human', 'info', 'heroicon-m-user'),
|
||||
AuditActorType::System => new BadgeSpec('System', 'gray', 'heroicon-m-cpu-chip'),
|
||||
AuditActorType::Scheduled => new BadgeSpec('Scheduled job', 'warning', 'heroicon-m-clock'),
|
||||
AuditActorType::Integration => new BadgeSpec('Integration', 'primary', 'heroicon-m-link'),
|
||||
AuditActorType::Platform => new BadgeSpec('Platform', 'gray', 'heroicon-m-shield-check'),
|
||||
};
|
||||
}
|
||||
}
|
||||
26
app/Support/Badges/Domains/AuditOutcomeBadge.php
Normal file
26
app/Support/Badges/Domains/AuditOutcomeBadge.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class AuditOutcomeBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = AuditOutcome::normalize(BadgeCatalog::normalizeState($value));
|
||||
|
||||
return match ($state) {
|
||||
AuditOutcome::Success => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'),
|
||||
AuditOutcome::Failed => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
AuditOutcome::Partial => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
AuditOutcome::Info => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
|
||||
AuditOutcome::Blocked => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,9 @@
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\Finding;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
@ -16,6 +19,58 @@
|
||||
|
||||
final class FilterOptionCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function auditActorTypes(): array
|
||||
{
|
||||
return self::badgeOptions(BadgeDomain::AuditActorType, array_map(
|
||||
static fn (AuditActorType $type): string => $type->value,
|
||||
AuditActorType::cases(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed>|null $actions
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function auditActions(?iterable $actions = null): array
|
||||
{
|
||||
return collect($actions ?? AuditActionId::knownValues())
|
||||
->filter(fn (mixed $action): bool => is_string($action) && trim($action) !== '')
|
||||
->map(fn (string $action): string => trim($action))
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(fn (string $action): array => [$action => AuditActionId::labelFor($action)])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function auditOutcomes(): array
|
||||
{
|
||||
return self::badgeOptions(BadgeDomain::AuditOutcome, array_map(
|
||||
static fn (AuditOutcome $outcome): string => $outcome->value,
|
||||
AuditOutcome::cases(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed>|null $types
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function auditTargetTypes(?iterable $types = null): array
|
||||
{
|
||||
return collect($types ?? [])
|
||||
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||
->map(fn (string $type): string => trim($type))
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(fn (string $type): array => [$type => str_replace('_', ' ', ucfirst($type))])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@ -4,11 +4,15 @@
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -110,6 +114,105 @@ public function headerEntries(string $sourceType, Model $record): array
|
||||
return $this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER, $record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function auditTargetLink(AuditLog $record): ?array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resourceType = is_string($record->resource_type) ? $record->resource_type : null;
|
||||
$resourceId = is_numeric($record->resource_id) ? (int) $record->resource_id : null;
|
||||
|
||||
if ($resourceType === null || $resourceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
$workspace = $record->workspace;
|
||||
|
||||
return match ($resourceType) {
|
||||
'operation_run' => $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
&& OperationRun::query()
|
||||
->whereKey($resourceId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->exists()
|
||||
? ['label' => 'Open operation run', 'url' => route('admin.operations.view', ['run' => $resourceId])]
|
||||
: null,
|
||||
'baseline_profile' => $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
&& $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)
|
||||
&& ($baselineProfile = BaselineProfile::query()
|
||||
->whereKey($resourceId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->first()) instanceof BaselineProfile
|
||||
&& BaselineProfileResource::canView($baselineProfile)
|
||||
? ['label' => 'Open baseline profile', 'url' => BaselineProfileResource::getUrl('view', ['record' => $resourceId], panel: 'admin')]
|
||||
: null,
|
||||
'baseline_snapshot' => $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
&& $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)
|
||||
&& ($baselineSnapshot = BaselineSnapshot::query()
|
||||
->whereKey($resourceId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->first()) instanceof BaselineSnapshot
|
||||
&& BaselineSnapshotResource::canView($baselineSnapshot)
|
||||
? ['label' => 'Open baseline snapshot', 'url' => BaselineSnapshotResource::getUrl('view', ['record' => $resourceId], panel: 'admin')]
|
||||
: null,
|
||||
'alert_rule' => $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
&& ($alertRule = AlertRule::query()
|
||||
->whereKey($resourceId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->first()) instanceof AlertRule
|
||||
&& AlertRuleResource::canView($alertRule)
|
||||
? ['label' => 'Open alert rule', 'url' => AlertRuleResource::getUrl('view', ['record' => $resourceId], panel: 'admin')]
|
||||
: null,
|
||||
'alert_destination' => $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
&& ($alertDestination = AlertDestination::query()
|
||||
->whereKey($resourceId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->first()) instanceof AlertDestination
|
||||
&& AlertDestinationResource::canView($alertDestination)
|
||||
? ['label' => 'Open alert destination', 'url' => AlertDestinationResource::getUrl('view', ['record' => $resourceId], panel: 'admin')]
|
||||
: null,
|
||||
'backup_set' => $tenant instanceof Tenant
|
||||
&& $this->capabilityResolver->isMember($user, $tenant)
|
||||
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||
&& BackupSet::query()
|
||||
->whereKey($resourceId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->exists()
|
||||
? ['label' => 'Open backup set', 'url' => BackupSetResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
|
||||
: null,
|
||||
'restore_run' => $tenant instanceof Tenant
|
||||
&& $this->capabilityResolver->isMember($user, $tenant)
|
||||
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||
&& RestoreRun::query()
|
||||
->whereKey($resourceId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->exists()
|
||||
? ['label' => 'Open restore run', 'url' => RestoreRunResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
|
||||
: null,
|
||||
'finding' => $tenant instanceof Tenant
|
||||
&& $this->capabilityResolver->isMember($user, $tenant)
|
||||
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||
&& Finding::query()
|
||||
->whereKey($resourceId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->exists()
|
||||
? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
|
||||
: null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
|
||||
@ -23,7 +23,6 @@ public static function baseline(): self
|
||||
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
|
||||
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage intentionally omits inspect affordances because rows are runtime-derived metadata; spec 124 requires search, sort, filters, and a resettable empty state instead.',
|
||||
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
|
||||
'App\\Filament\\Pages\\Monitoring\\AuditLog' => 'Monitoring audit-log page retrofit deferred; no action-surface declaration yet.',
|
||||
'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.',
|
||||
'App\\Filament\\Pages\\NoAccess' => 'No-access page has no actionable surface by design.',
|
||||
'App\\Filament\\Pages\\Operations\\TenantlessOperationRunViewer' => 'Tenantless run viewer retrofit deferred; run-link semantics are covered by monitoring tests.',
|
||||
|
||||
@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('audit_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('audit_logs', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('audit_logs', 'summary')) {
|
||||
$table->text('summary')->nullable()->after('action');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('audit_logs', 'outcome')) {
|
||||
$table->string('outcome')->nullable()->after('status');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('audit_logs', 'actor_type')) {
|
||||
$table->string('actor_type')->nullable()->after('actor_name');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('audit_logs', 'actor_label')) {
|
||||
$table->string('actor_label')->nullable()->after('actor_type');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('audit_logs', 'target_label')) {
|
||||
$table->string('target_label')->nullable()->after('resource_id');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('audit_logs', 'operation_run_id')) {
|
||||
$table->foreignId('operation_run_id')->nullable()->after('workspace_id')->constrained()->nullOnDelete();
|
||||
}
|
||||
});
|
||||
|
||||
$this->backfillDerivedColumns();
|
||||
|
||||
Schema::table('audit_logs', function (Blueprint $table): void {
|
||||
$table->index(['workspace_id', 'recorded_at'], 'audit_logs_workspace_recorded_at_index');
|
||||
$table->index(['tenant_id', 'recorded_at'], 'audit_logs_tenant_recorded_at_index');
|
||||
$table->index(['action', 'recorded_at'], 'audit_logs_action_recorded_at_index');
|
||||
$table->index(['outcome', 'recorded_at'], 'audit_logs_outcome_recorded_at_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('audit_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('audit_logs', function (Blueprint $table): void {
|
||||
foreach ([
|
||||
'audit_logs_workspace_recorded_at_index',
|
||||
'audit_logs_tenant_recorded_at_index',
|
||||
'audit_logs_action_recorded_at_index',
|
||||
'audit_logs_outcome_recorded_at_index',
|
||||
] as $indexName) {
|
||||
$table->dropIndex($indexName);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('audit_logs', 'operation_run_id')) {
|
||||
$table->dropConstrainedForeignId('operation_run_id');
|
||||
}
|
||||
|
||||
foreach (['target_label', 'actor_label', 'actor_type', 'outcome', 'summary'] as $column) {
|
||||
if (Schema::hasColumn('audit_logs', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillDerivedColumns(): void
|
||||
{
|
||||
DB::table('audit_logs')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$metadata = $this->decodeJson($row->metadata ?? null);
|
||||
|
||||
$resourceType = is_string($row->resource_type ?? null) ? $row->resource_type : null;
|
||||
$resourceId = $row->resource_id ?? null;
|
||||
$action = is_string($row->action ?? null) ? $row->action : 'audit.event';
|
||||
$status = is_string($row->status ?? null) ? strtolower(trim($row->status)) : 'info';
|
||||
|
||||
$outcome = match ($status) {
|
||||
'success', 'succeeded', 'completed', 'complete' => 'success',
|
||||
'partial', 'partially_succeeded', 'partial_success', 'partial_failure' => 'partial',
|
||||
'blocked', 'skipped', 'deferred', 'cancelled', 'canceled' => 'blocked',
|
||||
'failure', 'failed', 'error', 'errored' => 'failed',
|
||||
default => 'info',
|
||||
};
|
||||
|
||||
$actorType = match (true) {
|
||||
filled($row->actor_id ?? null), filled($row->actor_email ?? null), filled($row->actor_name ?? null) => 'human',
|
||||
str_starts_with(strtolower($action), 'platform.') => 'platform',
|
||||
str_contains(strtolower($action), 'schedule') || array_key_exists('backup_schedule_id', $metadata) => 'scheduled',
|
||||
default => 'system',
|
||||
};
|
||||
|
||||
$actorLabel = is_string($row->actor_name ?? null) && trim($row->actor_name) !== ''
|
||||
? trim($row->actor_name)
|
||||
: ((is_string($row->actor_email ?? null) && trim($row->actor_email) !== '')
|
||||
? trim($row->actor_email)
|
||||
: ucfirst($actorType));
|
||||
|
||||
$targetLabel = $this->fallbackTargetLabel($resourceType, $resourceId);
|
||||
|
||||
$operationRunCandidate = is_numeric($metadata['operation_run_id'] ?? null)
|
||||
? (int) $metadata['operation_run_id']
|
||||
: (($resourceType === 'operation_run' && is_numeric($resourceId)) ? (int) $resourceId : null);
|
||||
|
||||
$operationRunId = $this->resolveExistingOperationRunId($operationRunCandidate);
|
||||
|
||||
$summary = $this->fallbackSummary($action, $targetLabel);
|
||||
|
||||
DB::table('audit_logs')
|
||||
->where('id', (int) $row->id)
|
||||
->update([
|
||||
'summary' => $summary,
|
||||
'outcome' => $outcome,
|
||||
'actor_type' => $actorType,
|
||||
'actor_label' => $actorLabel,
|
||||
'target_label' => $targetLabel,
|
||||
'operation_run_id' => $operationRunId,
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJson(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function fallbackTargetLabel(?string $type, mixed $id): ?string
|
||||
{
|
||||
if (! filled($type) && ! filled($id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$subject = is_string($type) && trim($type) !== ''
|
||||
? ucfirst(str_replace(['_', '.'], ' ', trim($type)))
|
||||
: 'Record';
|
||||
|
||||
if (filled($id)) {
|
||||
return sprintf('%s #%s', $subject, (string) $id);
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
private function fallbackSummary(string $action, ?string $targetLabel): string
|
||||
{
|
||||
$segments = preg_split('/[._]+/', strtolower(trim($action))) ?: ['audit', 'event'];
|
||||
$segments = array_values(array_filter($segments, static fn (string $segment): bool => $segment !== ''));
|
||||
|
||||
$verb = array_pop($segments) ?? 'recorded';
|
||||
$subject = $segments === [] ? 'Audit event' : ucfirst(implode(' ', $segments));
|
||||
$verb = match ($verb) {
|
||||
'add' => 'added',
|
||||
'remove' => 'removed',
|
||||
'start' => 'started',
|
||||
'started' => 'started',
|
||||
'finish', 'finished' => 'finished',
|
||||
'complete', 'completed' => 'completed',
|
||||
'fail', 'failed' => 'failed',
|
||||
'update', 'updated' => 'updated',
|
||||
'create', 'created' => 'created',
|
||||
'delete', 'deleted' => 'deleted',
|
||||
'enable', 'enabled' => 'enabled',
|
||||
'disable', 'disabled' => 'disabled',
|
||||
default => str_replace('-', ' ', $verb),
|
||||
};
|
||||
|
||||
$summary = trim(sprintf('%s %s', $subject, $verb));
|
||||
|
||||
if ($targetLabel !== null && $targetLabel !== '' && ! str_contains($summary, $targetLabel)) {
|
||||
$summary .= ' for '.$targetLabel;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function resolveExistingOperationRunId(?int $operationRunId): ?int
|
||||
{
|
||||
if ($operationRunId === null || $operationRunId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::table('operation_runs')
|
||||
->where('id', $operationRunId)
|
||||
->exists()
|
||||
? $operationRunId
|
||||
: null;
|
||||
}
|
||||
};
|
||||
@ -1,7 +1,148 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Audit Log is reserved for future work.
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Summary-first audit history
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review governance, operational, and workspace-admin events in reverse chronological order without leaving the canonical Monitoring route.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Actor, outcome, target, and readable context stay visible even when the original record changes or disappears later.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
|
||||
@php
|
||||
$selectedAudit = $this->selectedAuditLog();
|
||||
$selectedAuditLink = $this->selectedAuditLink();
|
||||
@endphp
|
||||
|
||||
@if ($selectedAudit)
|
||||
<x-filament::section
|
||||
:heading="$selectedAudit->summaryText()"
|
||||
:description="$selectedAudit->recorded_at?->toDayDateTimeString()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditOutcome)($selectedAudit->normalizedOutcome()->value) }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditActorType)($selectedAudit->actorSnapshot()->type->value) }}
|
||||
</span>
|
||||
|
||||
@if (is_array($selectedAuditLink))
|
||||
<a
|
||||
class="inline-flex items-center rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
href="{{ $selectedAuditLink['url'] }}"
|
||||
>
|
||||
{{ $selectedAuditLink['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
type="button"
|
||||
wire:click="clearSelectedAuditLog"
|
||||
>
|
||||
Close details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Actor
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->actorDisplayLabel() }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $selectedAudit->actorSnapshot()->type->label() }}
|
||||
</div>
|
||||
@if ($selectedAudit->actorSnapshot()->email)
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $selectedAudit->actorSnapshot()->email }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Target
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->targetDisplayLabel() ?? 'No target snapshot' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $selectedAudit->resource_type ? ucfirst(str_replace('_', ' ', $selectedAudit->resource_type)) : 'Workspace event' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Scope
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->tenant?->name ?? 'Workspace-wide event' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Workspace #{{ $selectedAudit->workspace_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Readable context
|
||||
</div>
|
||||
|
||||
@if ($selectedAudit->contextItems() === [])
|
||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
No additional context was recorded for this event.
|
||||
</div>
|
||||
@else
|
||||
<dl class="mt-3 space-y-3">
|
||||
@foreach ($selectedAudit->contextItems() as $item)
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $item['label'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ is_bool($item['value']) ? ($item['value'] ? 'true' : 'false') : $item['value'] }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Technical metadata
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 space-y-3">
|
||||
@foreach ($selectedAudit->technicalMetadata() as $label => $value)
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $label }}
|
||||
</dt>
|
||||
<dd class="mt-1 break-all text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $value }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
|
||||
34
specs/134-audit-log-foundation/checklists/requirements.md
Normal file
34
specs/134-audit-log-foundation/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Audit Log Foundation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-11
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/134-audit-log-foundation/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass 1: passed. The spec stays product-facing while still naming the existing canonical audit route and capability required by current repo conventions.
|
||||
@ -0,0 +1,285 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Audit Log Review Contract
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Internal route and response contract for the canonical workspace Monitoring
|
||||
audit log surface at /admin/audit-log. This documents the filter and
|
||||
inspection semantics required by Spec 134; it is not a public API promise.
|
||||
paths:
|
||||
/admin/audit-log:
|
||||
get:
|
||||
summary: View canonical audit log
|
||||
description: >-
|
||||
Returns the workspace-scoped audit review surface with optional tenant,
|
||||
event, outcome, actor, target, search, and date-range filters. When the
|
||||
request is made while tenant context is active and entitled, the active
|
||||
tenant may be preselected as the default filter, but the route remains
|
||||
canonical and workspace-scoped.
|
||||
parameters:
|
||||
- in: query
|
||||
name: tenant_id
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Authorized tenant filter within the active workspace.
|
||||
- in: query
|
||||
name: event_type
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Canonical audit event taxonomy key.
|
||||
- in: query
|
||||
name: outcome
|
||||
schema:
|
||||
type: string
|
||||
enum: [success, failed, partial, info, blocked]
|
||||
nullable: true
|
||||
- in: query
|
||||
name: actor
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Actor label or actor-kind filter.
|
||||
- in: query
|
||||
name: target_type
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- in: query
|
||||
name: search
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
description: High-signal summary search.
|
||||
- in: query
|
||||
name: date_from
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
- in: query
|
||||
name: date_until
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: Authorized workspace-scoped audit review surface.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AuditLogListRow'
|
||||
meta:
|
||||
type: object
|
||||
required:
|
||||
- scope
|
||||
- filters
|
||||
properties:
|
||||
scope:
|
||||
type: object
|
||||
required: [workspace_id]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
filters:
|
||||
$ref: '#/components/schemas/AuditLogFilterState'
|
||||
empty_state:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
cta_label:
|
||||
type: string
|
||||
'403':
|
||||
description: Workspace member lacks audit.view capability.
|
||||
'404':
|
||||
description: Viewer is not entitled to the active workspace or requested tenant scope.
|
||||
/admin/audit-log/{auditLogId}:
|
||||
get:
|
||||
summary: Inspect one audit event
|
||||
description: >-
|
||||
Returns a detail-inspection representation of one audit event. Related
|
||||
target navigation is included only when the target still exists and the
|
||||
current viewer is authorized to inspect it.
|
||||
parameters:
|
||||
- in: path
|
||||
name: auditLogId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Authorized audit event detail.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuditLogDetail'
|
||||
'403':
|
||||
description: Workspace member lacks audit.view capability.
|
||||
'404':
|
||||
description: Viewer is not entitled to the workspace or tenant scope of the event.
|
||||
components:
|
||||
schemas:
|
||||
AuditLogListRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- occurred_at
|
||||
- summary
|
||||
- event_type
|
||||
- outcome
|
||||
- actor
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
occurred_at:
|
||||
type: string
|
||||
format: date-time
|
||||
summary:
|
||||
type: string
|
||||
event_type:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
enum: [success, failed, partial, info, blocked]
|
||||
actor:
|
||||
$ref: '#/components/schemas/AuditActor'
|
||||
target:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AuditTarget'
|
||||
nullable: true
|
||||
tenant_label:
|
||||
type: string
|
||||
nullable: true
|
||||
has_related_link:
|
||||
type: boolean
|
||||
AuditLogDetail:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- occurred_at
|
||||
- summary
|
||||
- event_type
|
||||
- outcome
|
||||
- actor
|
||||
- context_items
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
occurred_at:
|
||||
type: string
|
||||
format: date-time
|
||||
summary:
|
||||
type: string
|
||||
event_type:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
enum: [success, failed, partial, info, blocked]
|
||||
actor:
|
||||
$ref: '#/components/schemas/AuditActor'
|
||||
target:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AuditTarget'
|
||||
nullable: true
|
||||
context_items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [label, value]
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
value:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: integer
|
||||
- type: boolean
|
||||
technical_metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
related_link:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
AuditActor:
|
||||
type: object
|
||||
required:
|
||||
- actor_type
|
||||
properties:
|
||||
actor_type:
|
||||
type: string
|
||||
enum: [human, system, scheduled, integration, platform]
|
||||
actor_id:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
nullable: true
|
||||
actor_label:
|
||||
type: string
|
||||
nullable: true
|
||||
actor_email:
|
||||
type: string
|
||||
nullable: true
|
||||
AuditTarget:
|
||||
type: object
|
||||
properties:
|
||||
target_type:
|
||||
type: string
|
||||
nullable: true
|
||||
target_id:
|
||||
type: string
|
||||
nullable: true
|
||||
target_label:
|
||||
type: string
|
||||
nullable: true
|
||||
AuditLogFilterState:
|
||||
type: object
|
||||
properties:
|
||||
tenant_id:
|
||||
type: string
|
||||
nullable: true
|
||||
event_type:
|
||||
type: string
|
||||
nullable: true
|
||||
outcome:
|
||||
type: string
|
||||
nullable: true
|
||||
actor:
|
||||
type: string
|
||||
nullable: true
|
||||
target_type:
|
||||
type: string
|
||||
nullable: true
|
||||
search:
|
||||
type: string
|
||||
nullable: true
|
||||
date_from:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
date_until:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
280
specs/134-audit-log-foundation/data-model.md
Normal file
280
specs/134-audit-log-foundation/data-model.md
Normal file
@ -0,0 +1,280 @@
|
||||
# Data Model: Audit Log Foundation
|
||||
|
||||
**Feature**: 134-audit-log-foundation | **Date**: 2026-03-11
|
||||
|
||||
## Overview
|
||||
|
||||
This feature extends an existing persisted entity rather than introducing a second audit-history store. The design adds a first-class event model, normalized actor and target snapshots, and a canonical read model for the Monitoring audit page.
|
||||
|
||||
The main model layers are:
|
||||
|
||||
1. `AuditLog` as the durable event record,
|
||||
2. normalized event taxonomy and outcome semantics,
|
||||
3. actor and target snapshots captured at write time,
|
||||
4. structured context payloads with redaction,
|
||||
5. a workspace-scoped audit list and detail read model.
|
||||
|
||||
## Existing Persistent Entity to Evolve
|
||||
|
||||
### AuditLog
|
||||
|
||||
Current persisted shape already includes:
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Existing primary key |
|
||||
| `tenant_id` | int nullable | Existing tenant scope field |
|
||||
| `workspace_id` | int nullable | Added later for workspace-scoped events |
|
||||
| `actor_id` | int nullable | Existing actor identifier |
|
||||
| `actor_email` | string nullable | Existing actor display fallback |
|
||||
| `actor_name` | string nullable | Existing actor display fallback |
|
||||
| `action` | string | Existing event key |
|
||||
| `resource_type` | string nullable | Existing target-type precursor |
|
||||
| `resource_id` | string nullable | Existing target-id precursor |
|
||||
| `status` | string | Existing outcome precursor |
|
||||
| `metadata` | json nullable | Existing structured payload precursor |
|
||||
| `recorded_at` | timestamp | Existing event time |
|
||||
|
||||
Planned first-class audit semantics to add or formalize:
|
||||
|
||||
| Target Semantic | Likely Storage Shape | Purpose |
|
||||
|-----------------|----------------------|---------|
|
||||
| `event_type` or normalized action | existing `action` reused or renamed semantically | Stable taxonomy key |
|
||||
| `summary` | new string/text column | Human-readable list/detail summary |
|
||||
| `outcome` | new column or normalized use of `status` | Explicit success/failure/partial/info/blocked state |
|
||||
| `actor_type` | new string column | Human/system/scheduled/integration distinction |
|
||||
| `actor_label` | derived from existing name/email or new snapshot field | Stable readable actor label |
|
||||
| `target_type` | existing `resource_type` reused or normalized | Stable target taxonomy key |
|
||||
| `target_id` | existing `resource_id` reused or normalized | Structured target identity |
|
||||
| `target_label` | new string column | Stable readable target label |
|
||||
| `operation_run_id` | optional relation shortcut | Operational cross-linking |
|
||||
| `context` | existing `metadata` reused or renamed semantically | Structured supporting metadata |
|
||||
| `occurred_at` | existing `recorded_at` reused or normalized | Canonical event timestamp |
|
||||
|
||||
**Core rules**:
|
||||
- Every row is append-only in normal product flows.
|
||||
- Every row must remain understandable even when the source object is deleted or renamed later.
|
||||
- Every row must be scoped at least to a workspace; tenant scope remains nullable for workspace-only events.
|
||||
|
||||
## Supporting Domain Concepts
|
||||
|
||||
### AuditEventType
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `key` | string | Stable registry key such as `baseline_capture.completed` |
|
||||
| `family` | string | Domain grouping such as `baseline`, `finding`, `backup`, `restore`, `workspace_membership` |
|
||||
| `verb` | string | Normalized operator-facing action verb |
|
||||
| `supports_target_link` | bool | Whether target drill-down is meaningful |
|
||||
|
||||
**Rules**:
|
||||
- Must come from a shared canonical registry.
|
||||
- Must cover both legacy migrated keys and new first-wave keys.
|
||||
|
||||
### AuditOutcome
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `success` | Action completed successfully |
|
||||
| `failed` | Action failed |
|
||||
| `partial` | Action completed partially |
|
||||
| `info` | Informational/non-mutating event |
|
||||
| `blocked` | Action was intentionally prevented |
|
||||
|
||||
**Rules**:
|
||||
- Badge rendering is centralized.
|
||||
- Existing historical values like `success` and `failure` need compatibility mapping where required.
|
||||
|
||||
### AuditActorSnapshot
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `actor_type` | string enum | `human`, `system`, `scheduled`, `integration`, `platform` if compatibility requires |
|
||||
| `actor_id` | string or int nullable | Stable identifier when available |
|
||||
| `actor_label` | string nullable | Display label captured at event time |
|
||||
| `actor_email` | string nullable | Secondary identity where useful |
|
||||
|
||||
**Rules**:
|
||||
- Human and non-human actors must be distinguishable in storage and UI.
|
||||
- Historical readability must not depend on the actor record continuing to exist.
|
||||
|
||||
### AuditTargetSnapshot
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `target_type` | string nullable | Canonical target kind such as `finding`, `baseline_profile`, `backup_set` |
|
||||
| `target_id` | string nullable | Stable identity |
|
||||
| `target_label` | string nullable | Display label captured at write time |
|
||||
| `workspace_id` | int | Scope guard |
|
||||
| `tenant_id` | int nullable | Tenant guard for tenant-owned targets |
|
||||
|
||||
**Rules**:
|
||||
- Target label should be snapped at write time whenever practical.
|
||||
- Related links remain optional and permission-aware.
|
||||
|
||||
### AuditContext
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `context` | JSON-like object | Structured metadata for interpretation |
|
||||
|
||||
Expected content patterns:
|
||||
|
||||
| Pattern | Example |
|
||||
|---------|---------|
|
||||
| before/after lifecycle change | `before_status`, `after_status` |
|
||||
| related run identity | `operation_run_id` |
|
||||
| workflow rationale | `reason`, `reason_code` |
|
||||
| ownership reassignment | `from_assignee_user_id`, `to_assignee_user_id` |
|
||||
| summary counts | `items_succeeded`, `items_failed` |
|
||||
|
||||
**Rules**:
|
||||
- Context must remain intentionally shaped.
|
||||
- `AuditContextSanitizer` or equivalent must redact secrets, tokens, and sensitive fields.
|
||||
- Raw policy payloads and oversized blobs are not first-class audit context.
|
||||
|
||||
## Read Models for the Monitoring Page
|
||||
|
||||
### AuditLogListRow
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | int | Event identity |
|
||||
| `occurred_at` | datetime | Default sort field |
|
||||
| `summary` | string | Primary row text |
|
||||
| `event_type` | string | Secondary technical key |
|
||||
| `outcome` | string | Badge-rendered state |
|
||||
| `actor_label` | string | Who or what caused the event |
|
||||
| `actor_type` | string | Actor-kind indicator |
|
||||
| `target_label` | string nullable | Affected object |
|
||||
| `target_type` | string nullable | Secondary target hint |
|
||||
| `tenant_label` | string nullable | Only when safe to show |
|
||||
| `workspace_id` | int | Scope |
|
||||
| `has_related_link` | bool | Whether drill-down is available to this viewer |
|
||||
|
||||
**Rules**:
|
||||
- Summary-first ordering is mandatory.
|
||||
- Rows remain understandable without opening the detail view.
|
||||
|
||||
### AuditLogFilterState
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `search` | string nullable | High-signal summary search |
|
||||
| `tenant_id` | string nullable | Defaulted from active tenant when entitled |
|
||||
| `event_type` | string nullable | Taxonomy filter |
|
||||
| `outcome` | string nullable | Outcome filter |
|
||||
| `actor` | string nullable | Actor-label or actor-kind filter |
|
||||
| `target_type` | string nullable | Target kind filter |
|
||||
| `date_from` | date nullable | Range lower bound |
|
||||
| `date_until` | date nullable | Range upper bound |
|
||||
|
||||
**Rules**:
|
||||
- Filter options must come only from the active workspace and authorized tenant subset.
|
||||
- Empty results are valid and intentional.
|
||||
|
||||
### AuditLogDetailViewModel
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `summary` | string | Primary heading |
|
||||
| `occurred_at` | datetime | Event timestamp |
|
||||
| `actor` | AuditActorSnapshot | Who/what caused the event |
|
||||
| `target` | AuditTargetSnapshot nullable | Affected object |
|
||||
| `outcome` | string | Result state |
|
||||
| `context_items` | list | Readable context facts |
|
||||
| `technical_metadata` | list | Secondary raw identifiers or event key |
|
||||
| `related_link` | object nullable | Canonical target destination when permitted |
|
||||
|
||||
**Rules**:
|
||||
- Readable context facts come before raw JSON.
|
||||
- The event must stay intelligible if `related_link` is absent.
|
||||
|
||||
## Existing Domain Relations Important for Coverage
|
||||
|
||||
### Finding
|
||||
|
||||
Relevant persisted fields already include status, severity, assignee and owner users, due dates, and closed or resolved timestamps.
|
||||
|
||||
**Audit significance**:
|
||||
- status changes
|
||||
- assignee or owner changes
|
||||
- risk acceptance via `STATUS_RISK_ACCEPTED`
|
||||
- reopen, resolve, close transitions
|
||||
|
||||
### BaselineProfile
|
||||
|
||||
Relevant persisted fields already include workspace ownership, status, capture mode, active snapshot, and creator.
|
||||
|
||||
**Audit significance**:
|
||||
- create or update or archive actions
|
||||
- capture and compare lifecycle events through related jobs and operation context
|
||||
|
||||
### BackupSet
|
||||
|
||||
Relevant persisted fields already include tenant ownership, metadata, completion time, and restore-run relation.
|
||||
|
||||
**Audit significance**:
|
||||
- create or update or archive events
|
||||
- restore initiation linkages and backup-scope summaries
|
||||
|
||||
### RestoreRun
|
||||
|
||||
Relevant persisted fields already include status, preview, results, group mapping, completion timestamps, and operation-run relation.
|
||||
|
||||
**Audit significance**:
|
||||
- restore started, completed, failed, partial outcomes
|
||||
- assignment outcome summaries and preview/execute distinctions when meaningful
|
||||
|
||||
### OperationRun
|
||||
|
||||
Remains the canonical operational record for long-running workflows.
|
||||
|
||||
**Audit significance**:
|
||||
- high-value completion/failure/retry evidence
|
||||
- optional `operation_run_id` shortcut from `AuditLog`
|
||||
|
||||
## State and Transition Notes
|
||||
|
||||
### AuditLog lifecycle
|
||||
|
||||
```text
|
||||
Created
|
||||
-> persisted as historical fact
|
||||
-> never user-edited or user-deleted in normal flows
|
||||
-> may be superseded or corrected only by later audit events
|
||||
```
|
||||
|
||||
### Workflow-to-audit relationship
|
||||
|
||||
```text
|
||||
User/system action occurs
|
||||
-> domain service or job performs authorization and mutation
|
||||
-> shared audit recorder writes one meaningful event with actor/target/context
|
||||
-> canonical Monitoring page surfaces the event in reverse chronological order
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Rule | Result |
|
||||
|------|--------|
|
||||
| Every audit row has workspace scope | Required |
|
||||
| Tenant-scoped events must belong to the same workspace | Required |
|
||||
| Actor kind is explicit for new rows | Required |
|
||||
| Summary is human-readable and non-empty for new rows | Required |
|
||||
| Context is sanitized and shaped | Required |
|
||||
| No user-facing edit/delete path exists | Required |
|
||||
| Related links are permission-aware and optional | Required |
|
||||
|
||||
## Schema Impact
|
||||
|
||||
Schema changes are expected. The preferred migration strategy is additive evolution of `audit_logs` with compatibility-safe backfills and indexes for:
|
||||
|
||||
- occurred-at or recorded-at descending review,
|
||||
- workspace + occurred-at,
|
||||
- tenant + occurred-at,
|
||||
- event type,
|
||||
- outcome,
|
||||
- actor lookup,
|
||||
- target lookup,
|
||||
- optional operation-run shortcuts.
|
||||
280
specs/134-audit-log-foundation/plan.md
Normal file
280
specs/134-audit-log-foundation/plan.md
Normal file
@ -0,0 +1,280 @@
|
||||
# Implementation Plan: Audit Log Foundation
|
||||
|
||||
**Branch**: `134-audit-log-foundation` | **Date**: 2026-03-11 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/134-audit-log-foundation/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Turn the existing placeholder Audit Log page into a real workspace-scoped Monitoring surface backed by a normalized, immutable audit event foundation. Extend the existing `audit_logs` persistence model rather than creating a second history table, consolidate the current tenant and workspace audit writers behind one reusable recorder with actor and target snapshot support, centralize event taxonomy and outcome semantics, instrument the highest-value governance and operational workflows at service or job boundaries, and expose a filterable audit list with detail inspection and permission-aware related links at `/admin/audit-log`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 / Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads
|
||||
**Testing**: Pest v4 feature and unit tests on PHPUnit 12
|
||||
**Target Platform**: Laravel Sail web application with canonical workspace Monitoring routes under `/admin` and tenant-context navigation under `/admin/t/{tenant}`
|
||||
**Project Type**: Laravel monolith / Filament web application
|
||||
**Performance Goals**: Audit page remains DB-only at render time, default result sets are indexed and reverse-chronological, filter option loading is bounded to current workspace scope, and entry inspection never requires remote calls
|
||||
**Constraints**: Preserve `/admin/audit-log` as the canonical route; keep workspace and tenant isolation semantics intact; no new Microsoft Graph calls; no user-facing edit or delete path for audit events; retain compatibility with existing `audit_logs` readers while migrating to richer actor, target, and outcome semantics
|
||||
**Scale/Scope**: One expanded audit event store, one reusable recorder foundation, one canonical Monitoring page, first-wave instrumentation across baselines, findings, backup/restore, operation outcomes, and selected workspace-admin changes, plus focused regression coverage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS — no inventory-vs-snapshot semantics change; the feature introduces a cross-domain evidence layer over existing records.
|
||||
- Read/write separation: PASS — existing write workflows keep their current preview and confirmation behavior where applicable, and this feature adds stronger audit coverage rather than replacing those safeguards.
|
||||
- Graph contract path: PASS — no new Microsoft Graph calls or contracts are introduced.
|
||||
- Deterministic capabilities: PASS — the feature reuses the canonical capability registry, specifically `Capabilities::AUDIT_VIEW`, plus existing target-resource capabilities for related links.
|
||||
- RBAC-UX planes and isolation: PASS — the canonical audit surface remains in the `/admin` workspace plane; no `/system` route is introduced; non-members remain 404; workspace members lacking `audit.view` are 403; target drill-downs preserve their own 404/403 behavior.
|
||||
- Workspace isolation: PASS — audit queries are explicitly bounded by active workspace context before any filter or result shaping.
|
||||
- RBAC-UX destructive confirmation: PASS / N/A — the audit surface itself is read-only and introduces no destructive action.
|
||||
- RBAC-UX global search: PASS — no new globally searchable resource is introduced.
|
||||
- Tenant isolation: PASS — tenant-owned events appear only through workspace-bounded queries plus entitled tenant filtering; tenant context is additive filter state, not a second canonical audit plane.
|
||||
- Run observability: PASS — existing long-running workflows continue to use `OperationRun`; this plan adds high-value audit entries alongside those runs rather than bypassing them.
|
||||
- Ops-UX 3-surface feedback: PASS — operational audit coverage must not add new progress surfaces or custom completion notifications.
|
||||
- Ops-UX lifecycle: PASS — no direct `OperationRun` lifecycle updates are added; operation-related audit writes occur beside existing service-owned transitions.
|
||||
- Ops-UX summary counts: PASS — existing run producers remain the only source of `summary_counts`; audit rows summarize outcomes secondarily.
|
||||
- Ops-UX guards: PASS — existing regression guards remain intact; new tests focus on audit coverage and visibility.
|
||||
- Ops-UX system runs: PASS — initiator-null workflows remain auditable through the audit stream without introducing terminal DB notifications.
|
||||
- Automation: PASS — no new queued orchestration rules are added beyond emitting audit entries at existing service/job boundaries.
|
||||
- Data minimization: PASS — existing `AuditContextSanitizer` is retained and expanded as needed to protect secrets, tokens, and oversized payloads.
|
||||
- Badge semantics (BADGE-001): PASS — audit outcome and actor-kind badges must be added through `BadgeCatalog` / `BadgeRenderer`, not page-local mappings.
|
||||
- UI naming (UI-NAMING-001): PASS — event summaries, list labels, and detail copy use domain-first `Verb + Object` vocabulary and keep raw event keys secondary.
|
||||
- Filament UI Action Surface Contract: PASS — the custom Monitoring page gains list filtering and inspection only; no bulk or destructive mutations are introduced.
|
||||
- Filament UI UX-001: PASS — the audit page remains a structured Monitoring work surface with search, sort, filters, explicit empty state, and readable detail inspection rather than raw JSON-first presentation.
|
||||
- Filament v5 / Livewire v4 compliance: PASS — the design stays inside the existing Filament v5 / Livewire v4 application.
|
||||
- Provider registration (`bootstrap/providers.php`): PASS — no new panel provider is introduced; the existing admin panel provider remains registered in `bootstrap/providers.php`.
|
||||
- Global search resource rule: PASS — no new Resource is made globally searchable.
|
||||
- Asset strategy: PASS — no heavy new assets are required; existing deploy-time `php artisan filament:assets` behavior remains sufficient.
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/134-audit-log-foundation/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── audit-log-review.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ └── Pages/
|
||||
│ └── Monitoring/
|
||||
│ └── AuditLog.php # MODIFY — replace placeholder with real table/inspection page
|
||||
├── Models/
|
||||
│ ├── AuditLog.php # MODIFY — richer casts/relations/immutability semantics
|
||||
│ ├── BaselineProfile.php # reference — covered target domain
|
||||
│ ├── BackupSet.php # reference — covered target domain
|
||||
│ ├── Finding.php # reference — covered target domain
|
||||
│ ├── OperationRun.php # reference — related operational context
|
||||
│ └── RestoreRun.php # reference — covered target domain
|
||||
├── Services/
|
||||
│ ├── Audit/
|
||||
│ │ ├── WorkspaceAuditLogger.php # MODIFY or adapt into shared recorder facade
|
||||
│ │ ├── ... # NEW shared actor/target/context recorder helpers
|
||||
│ ├── Intune/
|
||||
│ │ └── AuditLogger.php # MODIFY or deprecate behind shared recorder
|
||||
│ ├── Findings/
|
||||
│ │ └── FindingWorkflowService.php # MODIFY — normalize event writes
|
||||
│ ├── Settings/
|
||||
│ │ └── SettingsWriter.php # MODIFY — adopt richer target/context semantics
|
||||
│ ├── Auth/
|
||||
│ │ └── WorkspaceMembershipManager.php # MODIFY — preserve admin-change coverage in shared taxonomy
|
||||
│ └── SystemConsole/
|
||||
│ └── SystemConsoleAuditLogger.php # MODIFY — compatibility with shared recorder and actor kinds
|
||||
├── Support/
|
||||
│ ├── Audit/
|
||||
│ │ ├── AuditActionId.php # MODIFY — expand or supersede taxonomy registry
|
||||
│ │ ├── AuditContextSanitizer.php # MODIFY — retain safe redaction boundary
|
||||
│ │ └── ... # NEW audit actor/target/outcome enums or value objects
|
||||
│ ├── Badges/
|
||||
│ │ ├── BadgeCatalog.php # MODIFY — add audit outcome/actor-kind badge domains if needed
|
||||
│ │ └── BadgeDomain.php # MODIFY — add audit badge domains if needed
|
||||
│ ├── Filament/
|
||||
│ │ ├── FilterOptionCatalog.php # MODIFY — audit filter options if centralized here
|
||||
│ │ └── FilterPresets.php # reference — date-range filter pattern
|
||||
│ └── Navigation/
|
||||
│ └── RelatedNavigationResolver.php # reference — permission-aware drill-down links
|
||||
database/
|
||||
├── migrations/
|
||||
│ └── ... # NEW migration(s) evolving `audit_logs` shape and indexes
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
└── pages/
|
||||
└── monitoring/
|
||||
└── audit-log.blade.php # MODIFY if custom page shell remains
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/
|
||||
│ │ └── AuditLog*Test.php # NEW canonical audit page access/filter/detail tests
|
||||
│ └── Monitoring/
|
||||
│ └── AuditCoverage*Test.php # NEW workflow-to-audit coverage tests where feature-level fit is better
|
||||
└── Unit/
|
||||
└── Audit/
|
||||
└── *Test.php # NEW recorder, taxonomy, redaction, actor/target snapshot tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the feature inside the existing Laravel/Filament monolith and evolve the existing `audit_logs` subsystem in place. The implementation centers on one shared audit recorder foundation, one canonical Monitoring page at `/admin/audit-log`, targeted migrations for richer event semantics and indexing, and focused Pest coverage rather than introducing a separate reporting service or external audit pipeline.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No Constitution Check violations. No justifications needed.
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
| — | — | — |
|
||||
|
||||
## Phase 0 — Research (DONE)
|
||||
|
||||
Output:
|
||||
- `specs/134-audit-log-foundation/research.md`
|
||||
|
||||
Key findings captured:
|
||||
- The repo already has an `AuditLog` model, a legacy tenant-scoped `App\Services\Intune\AuditLogger`, and a newer `App\Services\Audit\WorkspaceAuditLogger`, so the feature should consolidate and expand an existing foundation rather than create a parallel one.
|
||||
- Current `audit_logs` persistence is too narrow for the new spec: it stores `action`, `status`, `resource_type`, `resource_id`, actor email or name, `metadata`, and `recorded_at`, but lacks first-class actor type, target label snapshots, normalized outcome semantics, and strong canonical indexing for the planned filters.
|
||||
- The codebase already has early taxonomy centralization in `App\Support\Audit\AuditActionId`, but many covered workflows still use free-form action strings such as `finding.triaged`, so event naming needs one authoritative registry.
|
||||
- The existing placeholder Monitoring page at `/admin/audit-log` can stay in place as the canonical route, while existing table patterns from `OperationRunResource` and `AlertDeliveryResource` provide the best reusable filter, date-range, and drill-down conventions.
|
||||
- `AuditContextSanitizer` already provides a redaction boundary via `SecretClassificationService`; the new design should reuse and tighten that boundary rather than invent a second redaction path.
|
||||
- Current commands such as `tenantpilot:purge-nonpersistent` still delete `audit_logs`, which conflicts with the new explicit retention posture and must be corrected in implementation.
|
||||
|
||||
## Phase 1 — Design & Contracts (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/134-audit-log-foundation/data-model.md`
|
||||
- `specs/134-audit-log-foundation/contracts/audit-log-review.openapi.yaml`
|
||||
- `specs/134-audit-log-foundation/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- Extend `audit_logs` in place with richer actor, target, summary, outcome, context, and relation semantics while preserving compatibility for existing readers during migration.
|
||||
- Consolidate `AuditLogger`, `WorkspaceAuditLogger`, and system-console wrappers behind one shared audit recorder foundation with actor and target snapshot helpers and a single event taxonomy registry.
|
||||
- Keep `/admin/audit-log` as the canonical workspace Monitoring surface, with tenant context expressed only as a default filter and never as a second route or second audit model.
|
||||
- Instrument high-value domain boundaries at service and job edges, especially findings workflow, baseline capture or compare, backup and restore flows, operation outcomes, and workspace-admin changes.
|
||||
- Make the audit UI summary-first with bounded filters, permission-aware related links, and an inspection surface that keeps raw structured payload secondary.
|
||||
|
||||
## Phase 1 — Agent Context Update (DONE)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Step 1 — Normalize the audit event contract and retention posture
|
||||
|
||||
Goal: implement FR-134-01 through FR-134-07, FR-134-19 through FR-134-22, and the initial rollout constraints.
|
||||
|
||||
Changes:
|
||||
- Define the canonical audit taxonomy and outcome semantics in a single shared registry, expanding or superseding `AuditActionId` so covered workflows stop inventing ad hoc event names.
|
||||
- Define actor-kind and target-kind semantics, including human, system, scheduler, and optional integration actor classes.
|
||||
- Make the retention stance explicit in implementation by stopping short-lived purge flows from casually deleting audit history and documenting the v1 “keep until policy is defined” posture.
|
||||
|
||||
Tests:
|
||||
- Add unit tests for event-type normalization, outcome mapping, and redaction boundaries.
|
||||
- Add regression coverage proving non-persistent purge flows no longer remove durable audit entries.
|
||||
|
||||
### Step 2 — Evolve `audit_logs` into the first-class event store
|
||||
|
||||
Goal: implement FR-134-02 through FR-134-06, FR-134-17, and DR1 through DR6.
|
||||
|
||||
Changes:
|
||||
- Add the missing audit semantics to `audit_logs` through additive migrations and compatibility-safe backfills: richer summary field, outcome, actor type, actor label snapshot, target label snapshot, optional operation or domain relation shortcuts, and stronger workspace or tenant or event indexes.
|
||||
- Update `App\Models\AuditLog` with explicit casts, relations, and helper accessors for the new semantics.
|
||||
- Preserve intelligibility for existing rows with fallback mapping or backfill rules rather than treating old rows as unreadable.
|
||||
- Enforce immutable-by-default application behavior by keeping the model append-oriented and by ensuring no user-facing edit or delete flows exist.
|
||||
|
||||
Tests:
|
||||
- Add migration tests or schema assertions for new indexes and nullable-scope rules.
|
||||
- Add unit tests for label snapshot fallback and old-row compatibility.
|
||||
|
||||
### Step 3 — Consolidate audit writing into one reusable recorder foundation
|
||||
|
||||
Goal: implement FR-134-18 and support all event-writing coverage tasks.
|
||||
|
||||
Changes:
|
||||
- Introduce one shared recorder with actor, target, summary, and context composition helpers.
|
||||
- Adapt `Intune\AuditLogger`, `WorkspaceAuditLogger`, and `SystemConsoleAuditLogger` into wrappers or compatibility layers over the shared recorder so current call sites can migrate incrementally.
|
||||
- Centralize redaction and payload shaping through `AuditContextSanitizer` and explicit context schemas.
|
||||
|
||||
Tests:
|
||||
- Add unit tests for actor resolution, target resolution, target label snapshots, context shaping, and immutable write behavior.
|
||||
- Add regression tests ensuring wrappers still produce valid first-class audit rows.
|
||||
|
||||
### Step 4 — Instrument first-wave governance and admin event sources
|
||||
|
||||
Goal: implement FR-134-08 through FR-134-10 plus the governance and administration acceptance criteria.
|
||||
|
||||
Changes:
|
||||
- Normalize findings workflow events in `FindingWorkflowService` so assignment, status changes, reopen, resolve, close, and risk-acceptance actions use the shared taxonomy and richer actor or target context.
|
||||
- Expand baseline-related writes so baseline profile creation, update, status change, archive, capture start or completion or failure, and compare start or completion or failure use the shared recorder and target snapshots.
|
||||
- Keep existing workspace-admin writes from `WorkspaceMembershipManager`, `SettingsWriter`, onboarding flows, verification acknowledgements, and alert configuration changes aligned to the same taxonomy and summary format.
|
||||
|
||||
Tests:
|
||||
- Add focused feature and unit tests for findings and baseline audit coverage.
|
||||
- Add positive and negative authorization coverage for workspace-admin changes that surface in the audit log.
|
||||
|
||||
### Step 5 — Instrument backup, restore, and operations outcomes
|
||||
|
||||
Goal: implement FR-134-11 and FR-134-12 while respecting existing Ops-UX rules.
|
||||
|
||||
Changes:
|
||||
- Normalize backup set creation, update, archive, and retention-related audit coverage explicitly rather than treating them as generic backup workflow side effects.
|
||||
- Normalize restore initiation, completion, failure, and partial outcomes from existing restore services and jobs.
|
||||
- Add high-value operation completion, failure, and retry or rerun audit coverage without changing `OperationRun` ownership or feedback surfaces.
|
||||
- Preserve the existing Ops-UX contract explicitly: queued feedback stays presenter-owned via `OperationUxPresenter`, terminal notifications remain initiator-only `OperationRunCompleted`, and `OperationRun.status` / `OperationRun.outcome` transitions continue to flow only through `OperationRunService`.
|
||||
|
||||
Tests:
|
||||
- Add focused workflow tests for backup and restore audit coverage, especially partial or failed outcomes.
|
||||
- Add regression tests proving operation-run notifications and lifecycle ownership remain unchanged while audit entries are added, including guards for queued-toast usage, terminal notification exactness, and no direct job-level DB notifications or status/outcome transitions outside `OperationRunService`.
|
||||
|
||||
### Step 6 — Replace the placeholder Monitoring page with a real audit work surface
|
||||
|
||||
Goal: implement FR-134-13 through FR-134-16, FR-134-21, FR-134-24, UX1 through UX7, and IA1 through IA4.
|
||||
|
||||
Changes:
|
||||
- Keep `App\Filament\Pages\Monitoring\AuditLog` as the canonical route owner and implement it as a workspace-scoped table surface with reverse-chronological sorting, search, core filters, and a clear empty state.
|
||||
- Reuse `FilterPresets`, `FilterOptionCatalog`, `OperateHubShell`, and related-navigation helpers for tenant default filtering, date-range filters, and permission-aware drill-down links.
|
||||
- Add a detail inspection surface that emphasizes summary, actor, target, outcome, context, and only then raw identifiers or raw context.
|
||||
- Add centralized badge semantics for audit outcomes and actor kinds.
|
||||
- Enforce the custom-page Action Surface Contract and UX-001 expectations explicitly, including a stable inspection affordance, no unsupported bulk actions, and an empty state with exactly one clear CTA.
|
||||
- Keep the audit page DB-only at render time by ensuring filter loading and detail inspection rely only on application data already stored in PostgreSQL and never trigger remote calls.
|
||||
|
||||
Tests:
|
||||
- Add feature tests for authorized access, 404 non-member denial, 403 missing-capability denial, filter behavior, empty states, and permission-aware related links.
|
||||
- Add regression tests proving the old placeholder content is gone, raw JSON is not the primary UI, the Action Surface Contract stays satisfied for the custom Monitoring page, and render-time inspection remains DB-only with no external calls.
|
||||
|
||||
### Step 7 — Verify compatibility and complete rollout hardening
|
||||
|
||||
Goal: finish AC1 through AC10 and protect future expansion.
|
||||
|
||||
Changes:
|
||||
- Audit existing consumers such as system-console access logs and preserve compatibility or explicitly scope them out of the workspace audit page while still reading from the same event store.
|
||||
- Review all existing direct `AuditLog::query()` readers and adapt them to the richer schema where needed.
|
||||
- Confirm deployment assumptions: no new panel provider, no heavy assets, and no deviation from current `php artisan filament:assets` deployment step.
|
||||
|
||||
Tests:
|
||||
- Run focused Pest suites for audit model, recorder, Filament page access and filters, and first-wave workflow instrumentation.
|
||||
- Run Pint on dirty files during implementation.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance: preserved because the design remains inside the existing Filament v5 and Livewire v4 application.
|
||||
- Provider registration location: unchanged; no new panel provider is introduced, and the current panel remains registered in `bootstrap/providers.php`.
|
||||
- Globally searchable resources: unchanged; no new globally searchable Resource is added.
|
||||
- Destructive actions: unchanged on the audit page; it remains read-only with inspection and related-link affordances only.
|
||||
- Asset strategy: unchanged; no heavy or shared asset registration is required, and current deployment behavior including `php artisan filament:assets` remains sufficient.
|
||||
- Testing plan: add unit coverage for recorder, taxonomy, actor or target snapshots, and redaction; add feature coverage for audit page access, filters, details, empty states, and permission-aware links; add workflow coverage for findings, baselines, backup or restore, operation outcomes, and workspace-admin event emission.
|
||||
|
||||
67
specs/134-audit-log-foundation/quickstart.md
Normal file
67
specs/134-audit-log-foundation/quickstart.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Quickstart: Audit Log Foundation
|
||||
|
||||
**Feature**: 134-audit-log-foundation | **Date**: 2026-03-11
|
||||
|
||||
## Scope
|
||||
|
||||
This feature turns the existing Audit Log placeholder into a real enterprise Monitoring surface by:
|
||||
|
||||
- evolving the current `audit_logs` store into a first-class audit event model,
|
||||
- consolidating the existing audit writers behind one shared recorder foundation,
|
||||
- standardizing event taxonomy, actor kinds, target snapshots, summaries, and outcomes,
|
||||
- instrumenting first-wave governance, backup or restore, operations, and admin-change event sources,
|
||||
- replacing the placeholder `/admin/audit-log` page with a filterable, summary-first audit list and detail inspection surface,
|
||||
- enforcing workspace and tenant-safe visibility through `audit.view` and existing target entitlements,
|
||||
- making retention explicit so audit history is no longer treated as short-lived operational noise.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Expand the audit taxonomy and outcome model, using the current `AuditActionId` registry as the starting point.
|
||||
2. Design and implement the additive `audit_logs` migration path, including stronger indexes and compatibility-safe backfills.
|
||||
3. Introduce the shared audit recorder and adapt the existing tenant, workspace, and system loggers to use it.
|
||||
4. Tighten `AuditContextSanitizer` usage and codify context-shaping rules for summaries, before/after snapshots, and safe metadata.
|
||||
5. Normalize first-wave workspace and governance audit writes from `WorkspaceMembershipManager`, `SettingsWriter`, findings workflow, and baseline workflows.
|
||||
6. Normalize first-wave backup, restore, and operation outcome audit writes without changing `OperationRun` lifecycle ownership.
|
||||
7. Replace the placeholder `App\Filament\Pages\Monitoring\AuditLog` surface with a real workspace-scoped table, filters, badges, and detail inspection affordance.
|
||||
8. Ensure related target links are canonical and permission-aware through existing navigation helpers.
|
||||
9. Update retention-related commands or purge flows so durable audit history is no longer deleted as regeneratable noise.
|
||||
10. Run focused Sail-based tests and Pint.
|
||||
|
||||
## Reference files
|
||||
|
||||
- [app/Models/AuditLog.php](../../../app/Models/AuditLog.php)
|
||||
- [database/migrations/2025_12_10_000160_create_audit_logs_table.php](../../../database/migrations/2025_12_10_000160_create_audit_logs_table.php)
|
||||
- [database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php](../../../database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php)
|
||||
- [app/Services/Intune/AuditLogger.php](../../../app/Services/Intune/AuditLogger.php)
|
||||
- [app/Services/Audit/WorkspaceAuditLogger.php](../../../app/Services/Audit/WorkspaceAuditLogger.php)
|
||||
- [app/Support/Audit/AuditActionId.php](../../../app/Support/Audit/AuditActionId.php)
|
||||
- [app/Support/Audit/AuditContextSanitizer.php](../../../app/Support/Audit/AuditContextSanitizer.php)
|
||||
- [app/Filament/Pages/Monitoring/AuditLog.php](../../../app/Filament/Pages/Monitoring/AuditLog.php)
|
||||
- [resources/views/filament/pages/monitoring/audit-log.blade.php](../../../resources/views/filament/pages/monitoring/audit-log.blade.php)
|
||||
- [app/Filament/Resources/OperationRunResource.php](../../../app/Filament/Resources/OperationRunResource.php)
|
||||
- [app/Filament/Resources/AlertDeliveryResource.php](../../../app/Filament/Resources/AlertDeliveryResource.php)
|
||||
- [app/Services/Findings/FindingWorkflowService.php](../../../app/Services/Findings/FindingWorkflowService.php)
|
||||
- [app/Services/Auth/WorkspaceMembershipManager.php](../../../app/Services/Auth/WorkspaceMembershipManager.php)
|
||||
- [app/Services/Settings/SettingsWriter.php](../../../app/Services/Settings/SettingsWriter.php)
|
||||
- [app/Console/Commands/TenantpilotPurgeNonPersistentData.php](../../../app/Console/Commands/TenantpilotPurgeNonPersistentData.php)
|
||||
|
||||
## Suggested validation commands
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Audit
|
||||
vendor/bin/sail artisan test --compact --filter=AuditLog
|
||||
vendor/bin/sail artisan test --compact --filter=FindingWorkflow
|
||||
vendor/bin/sail artisan test --compact --filter=WorkspaceMembership
|
||||
vendor/bin/sail artisan test --compact --filter=SettingsWriter
|
||||
vendor/bin/sail artisan test --compact --filter=RestoreRun
|
||||
vendor/bin/sail artisan test --compact --filter=Baseline
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Expected outcome
|
||||
|
||||
- `/admin/audit-log` becomes a real canonical Monitoring page with meaningful filtering and event inspection.
|
||||
- Audit events consistently capture actor, action, target, scope, timestamp, outcome, and safe structured context.
|
||||
- Existing tenant and workspace audit writes converge on one shared recorder and one stable taxonomy.
|
||||
- Covered governance and operational workflows emit readable audit entries without requiring raw JSON or source-record survival to understand what happened.
|
||||
- Audit history is treated as durable evidence rather than short-lived tenant noise.
|
||||
103
specs/134-audit-log-foundation/research.md
Normal file
103
specs/134-audit-log-foundation/research.md
Normal file
@ -0,0 +1,103 @@
|
||||
# Research: Audit Log Foundation
|
||||
|
||||
**Feature**: 134-audit-log-foundation | **Date**: 2026-03-11
|
||||
|
||||
## R1: Whether to create a new audit table or evolve the existing one
|
||||
|
||||
**Decision**: Evolve the existing `audit_logs` table in place and treat it as the canonical first-class audit store, adding the richer semantics required by the spec through compatibility-safe migrations and backfills.
|
||||
|
||||
**Rationale**: The repo already has a durable `audit_logs` table, a model, and multiple active readers and writers. Creating a second audit table would split history, duplicate migration effort, and force existing consumers to choose between two incompatible sources of truth. The current shape is too narrow for the new product requirement, but it is close enough to extend safely.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a brand-new `audit_events` table and leave `audit_logs` as legacy: rejected because it would fragment the evidence trail and multiply compatibility work.
|
||||
- Leave the current table shape unchanged and store everything in `metadata`: rejected because that would bury primary audit semantics in opaque JSON and fail the summary-first requirement.
|
||||
|
||||
## R2: How to handle the current split between tenant and workspace audit writers
|
||||
|
||||
**Decision**: Consolidate audit creation behind one shared recorder foundation, with the existing `App\Services\Intune\AuditLogger`, `App\Services\Audit\WorkspaceAuditLogger`, and `App\Services\SystemConsole\SystemConsoleAuditLogger` becoming wrappers or compatibility layers over the shared recorder.
|
||||
|
||||
**Rationale**: The codebase already writes audit entries from both tenant-scoped and workspace-scoped services, but the writer API is inconsistent and does not expose the richer actor or target semantics the new feature needs. A shared recorder avoids a third parallel logger and enables gradual migration of existing call sites.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep both current loggers and add conventions only in documentation: rejected because the current drift is already visible in action naming and payload shape.
|
||||
- Replace every call site directly with model inserts: rejected because it would increase inconsistency and reduce redaction safety.
|
||||
|
||||
## R3: How to define event taxonomy without starting from zero
|
||||
|
||||
**Decision**: Use `App\Support\Audit\AuditActionId` as the seed of the v1 audit taxonomy, expand it to cover the missing event families, and migrate existing free-form strings toward the shared registry.
|
||||
|
||||
**Rationale**: The repo already centralizes several workspace and baseline-related audit action IDs, which proves the direction is accepted by the codebase. At the same time, important workflows such as `FindingWorkflowService` still emit strings like `finding.triaged` directly. Expanding the registry creates one authoritative naming contract without throwing away existing intent.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep free-form action strings and document naming guidelines only: rejected because the spec explicitly forbids event-name drift.
|
||||
- Introduce a second registry separate from `AuditActionId`: rejected because the codebase already has a recognizable naming anchor.
|
||||
|
||||
## R4: Where first-wave event writes should occur
|
||||
|
||||
**Decision**: Instrument meaningful service and job boundaries rather than generic model observers or blanket `saved` hooks.
|
||||
|
||||
**Rationale**: The spec prioritizes high-signal actions and state transitions, not every low-level field write. Existing domain services already define the right mutation boundaries: `FindingWorkflowService` for findings and risk acceptance, baseline capture and compare jobs for baseline outcomes, backup and restore services and jobs for operational events, `SettingsWriter` and `WorkspaceMembershipManager` for workspace-admin changes, and existing operation jobs for high-value failures and completions.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Audit every model save generically: rejected because it would create noisy, low-signal history and make human-readable summaries harder.
|
||||
- Rely only on UI-layer actions: rejected because queued jobs and background outcomes would be missed.
|
||||
|
||||
## R5: How to implement the canonical audit UI without changing route semantics
|
||||
|
||||
**Decision**: Keep `App\Filament\Pages\Monitoring\AuditLog` at `/admin/audit-log` as the canonical workspace Monitoring surface and implement its list, filters, and detail inspection directly on that page.
|
||||
|
||||
**Rationale**: The route and navigation already exist, but the page is only a placeholder. Keeping the custom page preserves canonical routing and Monitoring placement while avoiding the overhead of introducing a new Resource solely for immutable historical records.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Replace the page with a new Resource: rejected because the current route is already canonical and the feature is a Monitoring work surface, not CRUD.
|
||||
- Create a tenant-specific audit page under `/admin/t/{tenant}`: rejected because the spec requires a workspace-first canonical model with tenant-aware filtering, not split route ownership.
|
||||
|
||||
## R6: Which existing Filament patterns best fit the audit page
|
||||
|
||||
**Decision**: Reuse the table and filter conventions from `OperationRunResource` and `AlertDeliveryResource`, plus existing `FilterPresets` date-range helpers and `RelatedNavigationResolver` for permission-aware drill-down links.
|
||||
|
||||
**Rationale**: These are already workspace-safe Monitoring surfaces with reverse-chronological ordering, date-range filters, scoped tenant filters, and linked inspection patterns. The audit page has very similar operator needs.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Build all filters ad hoc inside the page: rejected because the repo already has reusable filter and navigation patterns.
|
||||
- Use raw JSON-first detail rendering: rejected because the spec explicitly requires readable structured context before raw payload views.
|
||||
|
||||
## R7: How to handle outcome and actor presentation consistently
|
||||
|
||||
**Decision**: Add audit-specific badge domains to the existing `BadgeCatalog` / `BadgeRenderer` system rather than mapping audit outcome colors inside the page.
|
||||
|
||||
**Rationale**: The constitution’s BADGE-001 rule requires centralized status-like semantics. The repo already centralizes operation, backup, restore, finding, and alert badge states through `BadgeCatalog`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Render audit outcome chips with inline color closures: rejected because it would violate BADGE-001 and create drift.
|
||||
- Reuse operation outcome badges directly without an audit domain: rejected because audit outcomes include informational and blocked semantics that are not identical to operation-run outcomes.
|
||||
|
||||
## R8: What to do about existing retention behavior that conflicts with the spec
|
||||
|
||||
**Decision**: Treat the current deletion of `audit_logs` in `tenantpilot:purge-nonpersistent` as a behavior that must change as part of this foundation, so durable audit history is no longer handled as ephemeral tenant noise.
|
||||
|
||||
**Rationale**: The new spec explicitly requires a durable retention posture. The purge command currently deletes audit logs alongside regeneratable tenant data, which directly conflicts with that posture.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Leave purge behavior unchanged and document retention as “best effort”: rejected because it would make the audit feature untrustworthy.
|
||||
- Remove all purge access to audit logs without documenting why: rejected because operators still need a clear explicit retention stance.
|
||||
|
||||
## R9: How to represent risk acceptance in the first release
|
||||
|
||||
**Decision**: Treat risk acceptance as part of the existing findings workflow in v1, because the current domain model represents it primarily through `Finding::STATUS_RISK_ACCEPTED` and related workflow actions rather than a separate persistent `RiskAcceptance` model.
|
||||
|
||||
**Rationale**: The spec wants risk acceptance lifecycle visibility, but the codebase today appears to model at least the core acceptance action through finding status transitions. The first plan should cover the current domain truth instead of inventing a richer separate entity before the implementation proves it exists.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introduce a separate risk-acceptance data model during this foundation: rejected because it broadens scope beyond the audit foundation and is not required to begin auditable coverage.
|
||||
- Omit risk-acceptance coverage entirely: rejected because it is explicitly called out as a high-value governance action.
|
||||
|
||||
## R10: How to preserve compatibility for existing audit readers
|
||||
|
||||
**Decision**: Preserve existing readers such as the system-console access logs page while migrating the richer audit schema, using backward-compatible field fallbacks where needed.
|
||||
|
||||
**Rationale**: `app/Filament/System/Pages/Security/AccessLogs.php` already queries `AuditLog` for platform login and break-glass events. The audit foundation should not silently break that consumer while focusing on the new workspace Monitoring page.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Rewrite all readers immediately to the new final schema: rejected because it increases rollout risk unnecessarily.
|
||||
- Ignore existing readers during migration: rejected because it would create regressions outside the feature’s main surface.
|
||||
183
specs/134-audit-log-foundation/spec.md
Normal file
183
specs/134-audit-log-foundation/spec.md
Normal file
@ -0,0 +1,183 @@
|
||||
# Feature Specification: Audit Log Foundation
|
||||
|
||||
**Feature Branch**: `134-audit-log-foundation`
|
||||
**Created**: 2026-03-11
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 134 — Audit Log Foundation"
|
||||
|
||||
## Spec Scope Fields
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/audit-log` as the canonical audit review surface in Monitoring
|
||||
- Existing tenant-context Monitoring entry points continue to route to the same canonical audit surface rather than creating a second audit model
|
||||
- Existing canonical related-resource destinations remain the drill-down targets from audit entries when the viewer is entitled to open them
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned: the audit event stream itself, canonical audit review surface, workspace-scoped event querying, and actor or target snapshots stored for long-lived review
|
||||
- Tenant-owned but workspace-filtered: tenant-specific target context, tenant-scoped related links, and tenant-bound audit events surfaced only through the canonical audit stream inside the active workspace
|
||||
- Existing domain entities such as baselines, findings, risk acceptances, backup sets, restore runs, and operation runs remain the systems of record for their business state while audit events become the canonical historical evidence layer
|
||||
- **RBAC**:
|
||||
- Workspace membership remains the prerequisite for opening the audit surface
|
||||
- `audit.view` becomes the explicit capability for viewing audit events within an authorized workspace
|
||||
- Tenant-aware audit rows, filters, and related links remain constrained to the viewer's authorized tenant subset and existing target-resource capabilities
|
||||
- Non-members or users outside the active workspace receive deny-as-not-found behavior, while in-scope members lacking `audit.view` receive a capability-based denial
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: The audit log stays canonical at `/admin/audit-log`. If the operator is already in tenant context, the page opens with the active tenant preselected as a default filter, but the operator remains on the shared audit surface and may clear or change that filter only within their authorized tenant set.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Audit queries must always be bounded by active workspace and authorized tenant scope before filters, counts, summaries, or row labels are assembled. Related target links appear only when the target still exists and the viewer is entitled to open it. Unauthorized tenants, targets, and target labels must not leak through filter options, counts, or row hints.
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Review what happened in the environment (Priority: P1)
|
||||
|
||||
As an operator responsible for governance and operations, I want a real audit log that shows meaningful actions across the workspace so I can answer who did what, when, where, and with what outcome without reconstructing events manually.
|
||||
|
||||
**Why this priority**: The core value of this feature is replacing a placeholder with a trustworthy historical record for compliance-critical and operationally significant actions.
|
||||
|
||||
**Independent Test**: Can be fully tested by visiting the audit log page after covered actions occur and verifying that the list shows readable entries with actor, action, target, scope, timestamp, and outcome.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** covered governance or operational actions have occurred in the active workspace, **When** an authorized operator opens the audit log, **Then** the page shows a reverse-chronological event history with enough summary detail to understand each event at a glance.
|
||||
2. **Given** the operator needs to answer who changed a target resource and whether it succeeded, **When** they scan the list, **Then** each event row clearly communicates actor, summary, target, timestamp, and outcome.
|
||||
3. **Given** the original source workflow lives in another product area, **When** the operator reviews the audit stream, **Then** the event still appears in the unified audit surface rather than requiring the operator to open the source workflow first.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Inspect an event in context (Priority: P2)
|
||||
|
||||
As an operator investigating an audit entry, I want to inspect additional event context and open the affected resource when permitted so I can understand the event without losing my place.
|
||||
|
||||
**Why this priority**: The audit list becomes much more useful when operators can interpret a specific event and move to the affected record only when they need deeper detail.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening an audit entry from the list and confirming that its detail surface exposes the event summary, actor, target, scope, outcome, and structured context with related links gated by authorization.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an audit row contains structured context, **When** the operator opens that row, **Then** the detail surface presents readable contextual metadata rather than raw payload as the primary experience.
|
||||
2. **Given** the target resource still exists and the operator is entitled to inspect it, **When** they open the audit entry detail, **Then** a canonical related link is available.
|
||||
3. **Given** the target resource has been deleted or is no longer accessible, **When** the operator inspects the entry, **Then** the event remains understandable without a working drill-down link.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Trust scope and access boundaries (Priority: P3)
|
||||
|
||||
As a security-conscious workspace user, I want audit visibility to follow the same workspace and tenant boundaries as the rest of the product so the audit surface does not leak events I should not see.
|
||||
|
||||
**Why this priority**: Audit history is highly sensitive. The feature fails if it improves visibility for the wrong audience.
|
||||
|
||||
**Independent Test**: Can be fully tested by loading the audit log with authorized, low-permission, and unauthorized users and verifying the expected 404 or 403 behavior, filtered row visibility, and permission-aware related links.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is not a member of the active workspace, **When** they try to open the audit log, **Then** the request is denied as not found.
|
||||
2. **Given** a workspace member lacks the audit viewing capability, **When** they try to open the audit log, **Then** access is denied as forbidden.
|
||||
3. **Given** a user can view the audit log but not a linked target resource, **When** they inspect an event, **Then** the event summary remains visible but the target drill-down is withheld.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- An event may reference a target record that has been deleted, archived, renamed, or hidden by later authorization changes; the event must remain readable from stored summary and target snapshot data.
|
||||
- An event may be initiated by a background job or system actor with no human user identifier; the UI must still make the actor class and label clear.
|
||||
- A high-value workflow may fail before a durable domain record is created; the audit entry must still capture action intent, scope, and failure outcome.
|
||||
- Filters can reduce the result set to zero rows; the page must show an intentional empty state instead of looking broken.
|
||||
- Structured context can contain useful metadata but must exclude secrets, tokens, passwords, and oversized raw payloads even for authorized viewers.
|
||||
- An authorized operator may view the canonical audit stream while a tenant is active; the default tenant filter must narrow safely without implying a different route or bypassing workspace controls.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls. It does introduce a first-class historical evidence layer, explicit audit writes for covered domain actions, and reuse of existing long-running operational workflows. Contract registry changes are not required because no new outbound Graph contracts are added. Safety behavior remains explicit: user-triggered mutations continue to use their existing confirmations and previews where applicable, and the audit foundation records meaningful intents and outcomes rather than replacing those safeguards. Tenant isolation remains mandatory for every audit query and every event write. Existing long-running workflows that already use `OperationRun` remain observable through their existing operations surfaces while also writing high-value audit entries for start, completion, failure, and significant retries. Tests must cover event creation, tenant isolation, immutable behavior, and canonical Monitoring visibility.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` records for covered operational workflows but does not change the Ops-UX contract itself. Any baseline capture, compare, restore, backup, or similar high-value run that already has an `OperationRun` must continue to satisfy the 3-surface feedback contract: toast for intent only, progress in Monitoring or Operations surfaces, and terminal database notification when the initiator is a human operator. `OperationRun.status` and `OperationRun.outcome` remain service-owned and must continue to transition only through existing operation services. Existing `summary_counts` rules remain unchanged and numeric-only. Scheduled or system-initiated runs with no human initiator continue to omit terminal database notifications while still writing audit entries into Monitoring. Regression coverage must protect the coexistence of operations visibility and audit visibility.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature operates in the admin workspace plane at `/admin/audit-log` and respects tenant-context filtering within that canonical view. No platform `/system` surface is introduced. Cross-plane access remains deny-as-not-found. Non-members or users outside the active workspace receive 404. In-scope workspace members lacking `audit.view` receive 403. Target-resource drill-downs continue to enforce their own server-side Gates, Policies, and capability checks, and the audit page must suppress unauthorized links and target hints rather than leaking them. No raw capability strings or role-string checks may be introduced in feature code. No new globally searchable resource is introduced by this feature, so global search rules remain unchanged. The audit log page itself exposes no destructive action.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not alter authentication handshake behavior.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Audit outcome, actor class, and any surfaced status-like values must use centralized badge semantics so success, failure, partial, and informational states remain consistent with the rest of Monitoring. Tests must cover any newly introduced outcome or actor-kind badge states.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing audit summaries and related UI copy must use domain-first vocabulary such as “Baseline capture completed,” “Risk acceptance renewed,” or “Restore failed.” Event taxonomy, row summaries, detail headings, notification text where reused, and related links must preserve the same object noun and operator verb across the experience. Implementation-first terms, raw internal event keys, and opaque system labels must remain secondary.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies the existing custom Filament Monitoring Audit Log page and adds list-level inspection behavior plus permission-aware related links. The UI Action Matrix below applies. The Action Surface Contract is satisfied because the surface is read-oriented, exposes no destructive action, and uses explicit inspection affordances instead of hidden mutation paths.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature modifies a Filament screen but does not introduce a create or edit workflow. The canonical audit page must still comply with UX-001 through sectioned page composition, searchable and filterable list behavior, centralized badge semantics, and an explicit empty state with one clear CTA. The create or edit main-and-aside rule and view-page Infolist rule are not applicable because this surface is a monitoring list with detail inspection rather than a CRUD record page.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-134-01 First-class audit foundation**: The system must provide a first-class audit event store that persists meaningful historical events independently of any single page, run detail, or notification surface.
|
||||
- **FR-134-02 Immutable records**: Audit entries must behave as append-only historical facts in normal product flows. Operators must not be able to edit or delete them through the user interface.
|
||||
- **FR-134-03 Core event semantics**: Every audit entry must capture event time, normalized action type, outcome, actor identity or class, workspace context, tenant context when relevant, target identity when relevant, human-readable summary, and structured supporting context.
|
||||
- **FR-134-04 Human and system actors**: The audit foundation must distinguish authenticated human operators from platform or system actors, scheduled automation, and other non-human initiators.
|
||||
- **FR-134-05 Target-aware history**: Audit entries must record the affected resource in a structured way that preserves target type, identifier when available, and a display label snapshot when known.
|
||||
- **FR-134-06 Structured context discipline**: Audit context must use intentionally shaped metadata that helps explain the event and must not become a dump of arbitrary model state or raw payloads.
|
||||
- **FR-134-07 Stable event taxonomy**: The product must define and reuse a normalized event naming convention for creation, update, deletion, lifecycle transitions, operational events, governance actions, and administrative changes.
|
||||
- **FR-134-08 Initial event coverage**: The first release of the foundation must cover high-value governance, backup, restore, operational, and administration events rather than attempting to log every low-signal mutation.
|
||||
- **FR-134-09 Baseline coverage**: Initial coverage must include baseline profile creation, update, status change, capture started or completed or failed, and compare started or completed or failed.
|
||||
- **FR-134-10 Findings and risk workflow coverage**: Initial coverage must include finding status changes, finding assignment changes, and risk acceptance lifecycle events that are currently meaningful in the product.
|
||||
- **FR-134-11 Backup and restore coverage**: Initial coverage must include backup set creation, update, archive, and restore initiation, completion, and failure where those workflows already exist.
|
||||
- **FR-134-12 Operations coverage**: Initial coverage must include high-value operation-run completion, failure, and manual retry or rerun events where those actions are meaningful.
|
||||
- **FR-134-13 Canonical audit UI**: The placeholder Audit Log page must become a usable canonical Monitoring surface with a reverse-chronological list of audit entries.
|
||||
- **FR-134-14 Filterable review**: The audit UI must allow filtering by time range, event type, outcome, actor, target type, and tenant where applicable within the current workspace, with search over high-signal summary fields.
|
||||
- **FR-134-15 Detail inspection**: Operators must be able to inspect one audit entry in a secondary detail surface that emphasizes summary, actor, target, scope, outcome, and readable context before technical identifiers.
|
||||
- **FR-134-16 Authorization-aware visibility**: Only authorized workspace members with `audit.view` may access the audit surface, and they may see only the events, filter options, and related links allowed by workspace and tenant scope.
|
||||
- **FR-134-17 No dependency on source survival**: Audit entries must remain intelligible even when the source object is later deleted, renamed, archived, or no longer accessible.
|
||||
- **FR-134-18 Reusable write path**: Audit writing must use a shared recording pattern so future features can add coverage without inventing one-off insertion logic or inconsistent event semantics.
|
||||
- **FR-134-19 Failure and partial outcomes**: Audit entries must support success, failure, partial-success, and informational outcomes so sensitive operational and governance flows can be reviewed accurately.
|
||||
- **FR-134-20 Safe payload boundaries**: Audit records must never store secrets, passwords, tokens, or oversized raw payloads when concise summary metadata is sufficient.
|
||||
- **FR-134-21 Permission-aware related links**: Audit entries may link to the affected resource only when that resource still exists and the current viewer is authorized to inspect it.
|
||||
- **FR-134-22 Explicit retention posture**: The feature must establish an explicit retention stance for audit records. For v1, audit history remains durable and is not treated as short-lived operational noise.
|
||||
- **FR-134-23 Summary-first list design**: The audit list must prioritize readable summaries, actor or target context, timestamp, and outcome over raw event keys or opaque identifiers.
|
||||
- **FR-134-24 Empty-state integrity**: When no audit rows match the active scope or filters, the page must clearly communicate that no audit events were found and offer one clear next action.
|
||||
- **FR-134-25 Regression protection**: Automated tests must protect against placeholder regressions, missing actor or target context, inconsistent event naming, unsafe payload inclusion, unauthorized visibility, and any user-facing edit or delete path for audit entries.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Replacing a full SIEM or external evidence platform
|
||||
- Ingesting Microsoft audit logs or other third-party audit feeds
|
||||
- Building advanced audit dashboards, trend analytics, or evidence-pack exports
|
||||
- Retrofitting full historical reconstruction for activity that predates this foundation
|
||||
- Creating a cryptographically signed or externally ledgered tamper-proof audit system in v1
|
||||
- Logging every low-signal field mutation or background progress update in the first release
|
||||
- Delivering per-event rich diff viewers for every covered domain object
|
||||
|
||||
### Assumptions
|
||||
|
||||
- The existing `audit.view` capability is the canonical starting permission for audit visibility and can be expanded later if narrower audit scopes become necessary.
|
||||
- `/admin/audit-log` remains the single canonical audit review route, even when entered from tenant-context Monitoring navigation.
|
||||
- Existing high-value workflows such as baselines, findings, backup, restore, and operations already have enough domain context to emit meaningful audit summaries without inventing new business objects.
|
||||
- The initial retention posture is to keep audit events indefinitely until an explicit policy supersedes that default.
|
||||
- Initial event coverage prioritizes high-value state transitions and outcomes over exhaustive low-level mutation logging.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing workspace context and tenant-context resolution used by canonical Monitoring surfaces
|
||||
- Existing capability registry and workspace or tenant authorization helpers, including `audit.view`
|
||||
- Existing baseline, findings, risk acceptance, backup, restore, and operation workflows that will emit the first covered events
|
||||
- Existing canonical related-resource routes used for drill-down from audit entries
|
||||
- Existing centralized badge semantics for outcome-like values in Monitoring
|
||||
|
||||
## UI Action Matrix
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Audit Log Page | Existing custom Monitoring page at `/admin/audit-log` | Existing scope or return actions only | Clicking a row or explicit `View details` opens event inspection | `View details`, `Open target` when the target still exists and the viewer is authorized | None | `Clear filters` | None beyond the detail inspection affordance | N/A | Source-domain mutations write audit events; the page itself does not mutate audit data | Read-oriented Monitoring surface. No destructive action, no edit action, no inline mutation. |
|
||||
| Audit Entry Detail Inspection | Secondary inspection surface launched from the audit list | None | Opened from the audit list only | `Open target` when authorized, `Close` | None | N/A | None | N/A | No additional audit write for viewing an event in v1 | This is an inspection surface, not a CRUD view page. Technical identifiers remain secondary. |
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Audit Event**: A durable historical record describing a meaningful action or state transition that should remain reviewable later.
|
||||
- **Actor Snapshot**: The stored representation of who or what caused the event, including actor class and human-readable label when known.
|
||||
- **Target Snapshot**: The stored representation of the primary resource affected by the event, including type, identifier when available, and display label snapshot when known.
|
||||
- **Audit Context**: Structured supporting metadata that explains the event without requiring raw source payload inspection.
|
||||
- **Audit Stream**: The canonical workspace-scoped sequence of audit events surfaced in Monitoring and filterable by time, scope, actor, target, and outcome.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-134-01 Canonical history usability**: In acceptance review, an authorized operator can answer who, what, when, where, and with what outcome for any covered audit event within 2 minutes using the audit surface alone.
|
||||
- **SC-134-02 Covered-event durability**: 100% of automated tests for the initial covered workflows verify that a qualifying action produces an audit entry with actor, summary, scope, and outcome.
|
||||
- **SC-134-03 Visibility safety**: 100% of negative authorization tests confirm that non-members see 404 behavior, members lacking `audit.view` see 403 behavior, and unauthorized target links are not exposed.
|
||||
- **SC-134-04 Historical intelligibility**: In deletion or rename regression tests, covered audit entries remain understandable without relying on the continued existence of the source object.
|
||||
- **SC-134-05 Summary-first experience**: In acceptance testing, the primary audit list communicates the event summary, actor or target context, timestamp, and outcome without requiring operators to open row details for basic interpretation.
|
||||
- **SC-134-06 Placeholder removal**: The Monitoring Audit Log surface no longer presents “reserved for future work” or any equivalent placeholder state once this feature ships.
|
||||
209
specs/134-audit-log-foundation/tasks.md
Normal file
209
specs/134-audit-log-foundation/tasks.md
Normal file
@ -0,0 +1,209 @@
|
||||
# Tasks: Audit Log Foundation
|
||||
|
||||
**Input**: Design documents from `/specs/134-audit-log-foundation/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/audit-log-review.openapi.yaml, quickstart.md
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior across existing audit persistence, workflow instrumentation, and Filament Monitoring surfaces.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Create the shared implementation and test entry points used by all story work.
|
||||
|
||||
- [X] T001 Create the audit unit-test entry points in tests/Unit/Audit/AuditActionIdTest.php, tests/Unit/Audit/AuditRecorderTest.php, tests/Unit/Audit/AuditLogCompatibilityTest.php, and tests/Unit/Audit/AuditContextSanitizerTest.php
|
||||
- [X] T002 Create the audit feature-test entry points in tests/Feature/Filament/AuditLogPageTest.php, tests/Feature/Filament/AuditLogDetailInspectionTest.php, tests/Feature/Filament/AuditLogAuthorizationTest.php, tests/Feature/Monitoring/AuditCoverageGovernanceTest.php, tests/Feature/Monitoring/AuditCoverageOperationsTest.php, and tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php
|
||||
- [X] T003 Create the shared audit support entry points in app/Support/Audit/AuditActorType.php, app/Support/Audit/AuditOutcome.php, app/Support/Audit/AuditActorSnapshot.php, app/Support/Audit/AuditTargetSnapshot.php, app/Services/Audit/AuditRecorder.php, and app/Services/Audit/AuditEventBuilder.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the first-class audit schema, taxonomy, recorder, and retention rules before any user story work begins.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T004 Add additive audit-log evolution migrations in database/migrations/ to introduce summary, outcome, actor-type, actor-label, target-label, operation-run linkage, compatibility backfills, and the minimum review indexes for workspace plus recorded-at, tenant plus recorded-at, normalized action, and normalized outcome queries
|
||||
- [X] T005 [P] Update app/Models/AuditLog.php to expose the new casts, scope helpers, relation shortcuts, summary fallbacks, and immutable read semantics for legacy and new audit rows
|
||||
- [X] T006 [P] Expand the audit taxonomy and value objects in app/Support/Audit/AuditActionId.php, app/Support/Audit/AuditActorType.php, app/Support/Audit/AuditOutcome.php, app/Support/Audit/AuditActorSnapshot.php, and app/Support/Audit/AuditTargetSnapshot.php
|
||||
- [X] T007 [P] Implement the shared recorder foundation in app/Services/Audit/AuditRecorder.php and app/Services/Audit/AuditEventBuilder.php so actor, target, summary, outcome, and context shaping flow through one reusable write path
|
||||
- [X] T008 [P] Tighten redaction and context-shaping rules in app/Support/Audit/AuditContextSanitizer.php and any supporting helpers it depends on so secrets, tokens, and oversized payloads never persist to audit rows
|
||||
- [X] T009 Update app/Services/Audit/WorkspaceAuditLogger.php, app/Services/Intune/AuditLogger.php, and app/Services/SystemConsole/SystemConsoleAuditLogger.php to wrap the shared recorder without breaking existing callers
|
||||
- [X] T010 Update app/Console/Commands/TenantpilotPurgeNonPersistentData.php so v1 audit history remains durable and is no longer purged as non-persistent workspace noise
|
||||
- [X] T011 [P] Add foundational schema, taxonomy, recorder, compatibility, and retention coverage in tests/Unit/Audit/AuditActionIdTest.php, tests/Unit/Audit/AuditRecorderTest.php, tests/Unit/Audit/AuditLogCompatibilityTest.php, tests/Unit/Audit/AuditContextSanitizerTest.php, and tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php
|
||||
|
||||
**Checkpoint**: The audit event store, shared recorder, and retention posture are ready; user-story work can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Review What Happened In The Environment (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Replace the placeholder audit page with a summary-first, reverse-chronological Monitoring surface backed by meaningful governance and administration audit coverage.
|
||||
|
||||
**Independent Test**: Trigger covered governance and admin actions, open `/admin/audit-log`, and verify the page shows readable rows with actor, summary, target, timestamp, and outcome without opening row details. This story is functionally testable on its own, but it is not release-safe until the authorization and tenant-boundary tasks in T027-T029 are also complete.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T012 [P] [US1] Add canonical list rendering, reverse-chronological ordering, summary-field search, tenant-context default-filter preselection, empty-state, and placeholder-removal coverage in tests/Feature/Filament/AuditLogPageTest.php
|
||||
- [X] T013 [P] [US1] Add governance and admin audit-emission coverage for finding status and assignment changes, risk-acceptance lifecycle events, baseline profile create or update or status-change events, baseline capture and compare started or completed or failed events, membership changes, settings changes, onboarding acknowledgements, verification acknowledgements, and alert configuration changes in tests/Feature/Monitoring/AuditCoverageGovernanceTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T014 [P] [US1] Instrument finding lifecycle and risk-acceptance audit writes in app/Services/Findings/FindingWorkflowService.php using the shared recorder and normalized audit taxonomy
|
||||
- [ ] T015 [P] [US1] Instrument baseline profile create or update or status-change audit writes plus baseline capture and compare started or completed or failed audit writes in app/Services/Baselines/BaselineCaptureService.php, app/Services/Baselines/BaselineCompareService.php, app/Services/Baselines/BaselineEvidenceCaptureResumeService.php, app/Jobs/CaptureBaselineSnapshotJob.php, and app/Jobs/CompareBaselineToTenantJob.php
|
||||
- [ ] T016 [P] [US1] Normalize workspace-admin audit writes in app/Services/Auth/WorkspaceMembershipManager.php, app/Services/Settings/SettingsWriter.php, app/Http/Controllers/TenantOnboardingController.php, app/Services/Intune/RbacOnboardingService.php, app/Services/Verification/VerificationCheckAcknowledgementService.php, app/Filament/Resources/AlertRuleResource.php, and app/Filament/Resources/AlertDestinationResource.php so actor, target, summary, and outcome semantics match the new audit contract
|
||||
- [X] T017 [P] [US1] Add audit outcome and actor-kind badge support in app/Support/Badges/BadgeDomain.php, app/Support/Badges/BadgeCatalog.php, and any badge-mapping tests that cover the new audit values
|
||||
- [X] T018 [US1] Replace the placeholder Monitoring surface in app/Filament/Pages/Monitoring/AuditLog.php and resources/views/filament/pages/monitoring/audit-log.blade.php with a summary-first workspace-scoped audit table that uses the evolved AuditLog model and exposes search over high-signal summary fields
|
||||
- [X] T019 [US1] Add audit list filter, active-tenant default preselection, and date-range option sources in app/Support/Filament/FilterOptionCatalog.php and app/Support/Filament/FilterPresets.php so the page can filter by outcome, event type, actor, target type, tenant, and time range within the current workspace only
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when `/admin/audit-log` is a working summary-first history surface for covered governance and admin events.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Inspect An Event In Context (Priority: P2)
|
||||
|
||||
**Goal**: Let operators inspect one audit event in a readable detail surface and open the affected resource when the target still exists and they are entitled to view it.
|
||||
|
||||
**Independent Test**: Open a covered audit row, verify the detail surface emphasizes summary, actor, target, scope, outcome, and readable context, and confirm the related link appears only when the resource exists and the viewer is allowed to open it.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T020 [P] [US2] Add audit detail rendering and related-link behavior coverage in tests/Feature/Filament/AuditLogDetailInspectionTest.php
|
||||
- [ ] T021 [P] [US2] Add backup create or update or archive coverage, restore initiation or completion or failure coverage, operation retry or rerun coverage, and Ops-UX regression coverage in tests/Feature/Monitoring/AuditCoverageOperationsTest.php, tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php, tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php, tests/Feature/OpsUx/QueuedToastCopyTest.php, and tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T022 [P] [US2] Instrument backup set creation, update, archive, and retention workflow audit writes in app/Services/Intune/BackupService.php, app/Services/AssignmentBackupService.php, app/Jobs/RunBackupScheduleJob.php, and app/Jobs/ApplyBackupScheduleRetentionJob.php
|
||||
- [ ] T023 [P] [US2] Instrument restore initiation, completion, failure, and partial-outcome audit writes in app/Services/Intune/RestoreService.php, app/Services/AssignmentRestoreService.php, app/Jobs/ExecuteRestoreRunJob.php, and app/Jobs/RestoreAssignmentsJob.php
|
||||
- [ ] T024 [P] [US2] Instrument high-value operation completion, failure, retry, and rerun audit writes alongside existing run ownership in app/Services/OperationRunService.php, app/Support/OpsUx/OperationUxPresenter.php, app/Notifications/OperationRunCompleted.php, app/Jobs/BulkBackupSetRestoreJob.php, app/Jobs/BulkRestoreRunRestoreJob.php, and app/Jobs/BulkPolicyVersionRestoreJob.php without introducing ad-hoc toasts, direct status/outcome transitions, or custom terminal DB notifications
|
||||
- [X] T025 [P] [US2] Add permission-aware audit target destinations in app/Support/Navigation/RelatedNavigationResolver.php so audit entries can resolve canonical links without leaking inaccessible targets
|
||||
- [X] T026 [US2] Extend app/Filament/Pages/Monitoring/AuditLog.php and resources/views/filament/pages/monitoring/audit-log.blade.php with event inspection, readable context sections, technical metadata fallback, target drill-down affordances, and DB-only render behavior that never triggers remote calls during list or detail rendering
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when operators can inspect an event in context and open a permitted target without leaving the canonical audit workflow blindly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Trust Scope And Access Boundaries (Priority: P3)
|
||||
|
||||
**Goal**: Enforce workspace membership, `audit.view`, tenant scoping, and permission-aware target visibility so the audit surface never leaks cross-workspace or cross-tenant evidence.
|
||||
|
||||
**Independent Test**: Exercise `/admin/audit-log` and event-detail inspection as a non-member, a workspace member without `audit.view`, and a scoped operator with partial tenant entitlements, then confirm 404 vs 403 semantics, row visibility, filter options, and related-link behavior all match the spec.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T027 [P] [US3] Add positive and negative authorization coverage for canonical audit list and detail access in tests/Feature/Filament/AuditLogAuthorizationTest.php
|
||||
- [X] T028 [P] [US3] Add legacy-reader and hidden-target compatibility coverage in tests/Unit/Audit/AuditLogCompatibilityTest.php and tests/Feature/Filament/AuditLogDetailInspectionTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T029 [US3] Enforce workspace membership, `audit.view`, deny-as-not-found tenant scoping, and tenant-safe filter option queries in app/Filament/Pages/Monitoring/AuditLog.php using app/Support/Auth/Capabilities.php and existing workspace or tenant authorization helpers
|
||||
- [X] T030 [US3] Harden target-link suppression and target-label fallback behavior in app/Support/Navigation/RelatedNavigationResolver.php and app/Models/AuditLog.php so inaccessible or deleted resources remain intelligible without leaking links or hints
|
||||
- [ ] T031 [US3] Preserve compatibility for existing audit readers in app/Filament/System/Pages/Security/AccessLogs.php, app/Filament/System/Pages/RepairWorkspaceOwners.php, app/Console/Commands/TenantpilotPurgeNonPersistentData.php, and app/Services/SystemConsole/SystemConsoleAuditLogger.php while the richer schema rolls out
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when audit visibility obeys the same workspace and tenant boundaries as the rest of the product and older readers still work safely.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Lock in naming consistency, cross-story regression coverage, and final verification.
|
||||
|
||||
- [X] T032 [P] Update operator-facing summaries, empty-state copy, detail headings, and related-link labels in app/Filament/Pages/Monitoring/AuditLog.php, resources/views/filament/pages/monitoring/audit-log.blade.php, and app/Support/Audit/AuditActionId.php to align with UI-NAMING-001 domain-first wording
|
||||
- [ ] T033 [P] Add cross-story regression coverage for badge mappings, placeholder removal, immutable UI behavior, permission-aware target navigation, and custom-page Action Surface or UX-001 compliance in tests/Feature/Filament/AuditLogPageTest.php, tests/Feature/Filament/AuditLogDetailInspectionTest.php, tests/Feature/Filament/AuditLogAuthorizationTest.php, and tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- [ ] T034 Run the focused validation commands documented in specs/134-audit-log-foundation/quickstart.md
|
||||
- [X] T035 Run formatting on touched files with vendor/bin/sail bin pint --dirty --format agent from /Users/ahmeddarrazi/Documents/projects/TenantAtlas
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user-story implementation.
|
||||
- **User Stories (Phases 3-5)**: Depend on Foundational completion.
|
||||
- **Polish (Phase 6)**: Depends on the desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Starts after Foundational and delivers the functional MVP surface by making `/admin/audit-log` a real summary-first Monitoring page with first-wave governance and admin coverage.
|
||||
- **User Story 2 (P2)**: Starts after Foundational and builds on US1’s list surface to add inspection, backup or restore coverage, and permission-aware related links.
|
||||
- **User Story 3 (P3)**: Starts after Foundational and must be completed before release because authorization, tenant scoping, and compatibility are baseline safety requirements for the canonical audit surface, not optional post-MVP hardening.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or update the story tests first and confirm they fail against the pre-change behavior.
|
||||
- Land domain instrumentation before finalizing the page behavior that surfaces the resulting events.
|
||||
- Keep badge, filter, and related-navigation helpers shared rather than page-local.
|
||||
- Finish story-level validation before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T005 through T008 can run in parallel after T004, then T009 through T011 complete the foundational layer.
|
||||
- In US1, T012 and T013 can run in parallel, then T014 through T017 can split by domain before T018 and T019 finalize the page surface.
|
||||
- In US2, T020 and T021 can run in parallel, then T022 through T025 can split by workflow and navigation before T026 integrates the detail experience.
|
||||
- In US3, T027 and T028 can run in parallel, then T029 through T031 harden authorization, fallback, and reader compatibility.
|
||||
- T032 and T033 can run in parallel once the user-story phases are complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the US1 test updates together:
|
||||
Task: "Add canonical list rendering, reverse-chronological ordering, empty-state, and placeholder-removal coverage in tests/Feature/Filament/AuditLogPageTest.php"
|
||||
Task: "Add governance and admin audit-emission coverage for findings, baselines, membership, and settings changes in tests/Feature/Monitoring/AuditCoverageGovernanceTest.php"
|
||||
|
||||
# Split the domain instrumentation work:
|
||||
Task: "Instrument finding lifecycle audit writes in app/Services/Findings/FindingWorkflowService.php using the shared recorder and normalized audit taxonomy"
|
||||
Task: "Instrument baseline capture and compare audit writes in app/Services/Baselines/BaselineCaptureService.php, app/Services/Baselines/BaselineCompareService.php, app/Services/Baselines/BaselineEvidenceCaptureResumeService.php, app/Jobs/CaptureBaselineSnapshotJob.php, and app/Jobs/CompareBaselineToTenantJob.php"
|
||||
Task: "Normalize workspace-admin audit writes in app/Services/Auth/WorkspaceMembershipManager.php and app/Services/Settings/SettingsWriter.php so actor, target, summary, and outcome semantics match the new audit contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch the US2 test updates together:
|
||||
Task: "Add audit detail rendering and related-link behavior coverage in tests/Feature/Filament/AuditLogDetailInspectionTest.php"
|
||||
Task: "Add backup, restore, and operation-outcome audit-emission coverage in tests/Feature/Monitoring/AuditCoverageOperationsTest.php"
|
||||
|
||||
# Split the workflow instrumentation work:
|
||||
Task: "Instrument backup workflow audit writes in app/Services/Intune/BackupService.php, app/Services/AssignmentBackupService.php, app/Jobs/RunBackupScheduleJob.php, and app/Jobs/ApplyBackupScheduleRetentionJob.php"
|
||||
Task: "Instrument restore workflow audit writes in app/Services/Intune/RestoreService.php, app/Services/AssignmentRestoreService.php, app/Jobs/ExecuteRestoreRunJob.php, and app/Jobs/RestoreAssignmentsJob.php"
|
||||
Task: "Instrument high-value operation completion, failure, and retry audit writes alongside existing run ownership in app/Services/OperationRunService.php, app/Jobs/BulkBackupSetRestoreJob.php, app/Jobs/BulkRestoreRunRestoreJob.php, and app/Jobs/BulkPolicyVersionRestoreJob.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Complete T027-T029 from User Story 3 to enforce mandatory authorization and tenant-boundary rules before any release candidate exists.
|
||||
5. Validate `/admin/audit-log` against the US1 independent test plus the US3 authorization checks before moving on.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish Setup and Foundational shared audit infrastructure.
|
||||
2. Deliver User Story 1 to establish the canonical audit history surface.
|
||||
3. Deliver User Story 2 to add inspection and deeper operational context.
|
||||
4. Deliver User Story 3 before release so authorization, tenant boundaries, and compatibility are in place for all exposed audit flows.
|
||||
5. Finish with naming cleanup, regression coverage, validation commands, and formatting.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor handles the migration/model/recorder foundation while another prepares the unit and feature tests.
|
||||
2. After Foundation is ready, split US1 and US2 domain instrumentation across findings, baselines, backup, restore, and operations workflows.
|
||||
3. Reserve one contributor for the Filament page and authorization hardening once the event-producing workflows are emitting the expected audit records.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch different files and can be executed in parallel.
|
||||
- User-story labels map directly to the prioritized stories in spec.md.
|
||||
- Tests are mandatory in this repo for every runtime change in the resulting implementation.
|
||||
- The suggested MVP scope is Phase 3 only after Setup and Foundational are complete.
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
@ -16,7 +18,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('purges non-persistent tenant data without touching persistent catalog data', function () {
|
||||
it('purges non-persistent tenant data while preserving durable audit history', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Tenant A']);
|
||||
$tenantB = Tenant::factory()->create(['name' => 'Tenant B']);
|
||||
|
||||
@ -115,13 +117,31 @@
|
||||
expect(BackupItem::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(2);
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', $tenantA->id)
|
||||
->orderBy('action')
|
||||
->pluck('action')
|
||||
->all())->toBe([
|
||||
'operation.completed',
|
||||
'test.action',
|
||||
]);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenantA->id)
|
||||
->where('type', 'backup_schedule_purge')
|
||||
->exists())->toBeTrue();
|
||||
|
||||
$purgeRun = OperationRun::query()
|
||||
->where('tenant_id', $tenantA->id)
|
||||
->where('type', 'backup_schedule_purge')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($purgeRun)->not->toBeNull();
|
||||
expect(data_get($purgeRun?->context, 'audit_logs_retained'))->toBe(2)
|
||||
->and(data_get($purgeRun?->context, 'deleted_rows.audit_logs_retained'))->toBeNull();
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
|
||||
89
tests/Feature/Filament/AuditLogAuthorizationTest.php
Normal file
89
tests/Feature/Filament/AuditLogAuthorizationTest.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function auditLogAuthorizationTestRecord(Tenant $tenant, array $attributes = []): AuditLogModel
|
||||
{
|
||||
return AuditLogModel::query()->create(array_merge([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'auditor@example.com',
|
||||
'action' => 'verification.completed',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'tenant',
|
||||
'resource_id' => (string) $tenant->getKey(),
|
||||
'summary' => 'Verification completed',
|
||||
'metadata' => [],
|
||||
'recorded_at' => now(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('returns 404 when the user is not a member of the active workspace', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 when the user is a workspace member without audit capability', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||
$resolver->shouldReceive('can')->andReturnFalse();
|
||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('limits audit rows and event inspection to the user tenant scope', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$visible = auditLogAuthorizationTestRecord($tenantA, [
|
||||
'summary' => 'Tenant A audit event',
|
||||
]);
|
||||
|
||||
$hidden = auditLogAuthorizationTestRecord($tenantB, [
|
||||
'summary' => 'Tenant B audit event',
|
||||
]);
|
||||
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log').'?event='.(int) $hidden->getKey())
|
||||
->assertNotFound();
|
||||
});
|
||||
87
tests/Feature/Filament/AuditLogDetailInspectionTest.php
Normal file
87
tests/Feature/Filament/AuditLogDetailInspectionTest.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function auditLogDetailTestComponent(User $user, ?Tenant $tenant = null): Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
return Livewire::actingAs($user)->test(AuditLogPage::class);
|
||||
}
|
||||
|
||||
function auditLogDetailTestRecord(Tenant $tenant, array $attributes = []): AuditLogModel
|
||||
{
|
||||
return AuditLogModel::query()->create(array_merge([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'auditor@example.com',
|
||||
'actor_name' => 'Audit Operator',
|
||||
'action' => 'backup.created',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'backup_set',
|
||||
'resource_id' => '1',
|
||||
'summary' => 'Backup set created',
|
||||
'metadata' => [
|
||||
'item_count' => 12,
|
||||
],
|
||||
'recorded_at' => now(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('shows readable detail sections and a related link for an accessible target', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Nightly iOS backup',
|
||||
]);
|
||||
|
||||
$audit = auditLogDetailTestRecord($tenant, [
|
||||
'resource_id' => (string) $backupSet->getKey(),
|
||||
'target_label' => $backupSet->name,
|
||||
'summary' => 'Backup set created for Nightly iOS backup',
|
||||
]);
|
||||
|
||||
auditLogDetailTestComponent($user)
|
||||
->callTableAction('inspect', $audit)
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Readable context')
|
||||
->assertSee('Technical metadata')
|
||||
->assertSee('Nightly iOS backup')
|
||||
->assertSee('Open backup set');
|
||||
});
|
||||
|
||||
it('keeps deleted targets readable while suppressing their drill-down link', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Archived backup',
|
||||
]);
|
||||
|
||||
$backupSet->delete();
|
||||
|
||||
$audit = auditLogDetailTestRecord($tenant, [
|
||||
'action' => 'backup.archived',
|
||||
'resource_id' => (string) $backupSet->getKey(),
|
||||
'target_label' => 'Archived backup',
|
||||
'summary' => 'Backup set archived for Archived backup',
|
||||
]);
|
||||
|
||||
auditLogDetailTestComponent($user)
|
||||
->callTableAction('inspect', $audit)
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Archived backup')
|
||||
->assertSee('Technical metadata')
|
||||
->assertDontSee('Open backup set');
|
||||
});
|
||||
209
tests/Feature/Filament/AuditLogPageTest.php
Normal file
209
tests/Feature/Filament/AuditLogPageTest.php
Normal file
@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Models\AuditLog as AuditLogModel;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function auditLogPageTestComponent(User $user, ?Tenant $tenant = null): Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
return Livewire::actingAs($user)->test(AuditLogPage::class);
|
||||
}
|
||||
|
||||
function auditLogPageTestRecord(?Tenant $tenant, array $attributes = []): AuditLogModel
|
||||
{
|
||||
$workspaceId = array_key_exists('workspace_id', $attributes)
|
||||
? (int) $attributes['workspace_id']
|
||||
: (int) ($tenant?->workspace_id ?? Workspace::factory()->create()->getKey());
|
||||
|
||||
return AuditLogModel::query()->create(array_merge([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant?->getKey(),
|
||||
'actor_email' => 'auditor@example.com',
|
||||
'actor_name' => 'Audit Operator',
|
||||
'action' => 'operation.completed',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'operation_run',
|
||||
'resource_id' => '1',
|
||||
'summary' => 'Operation completed',
|
||||
'metadata' => [],
|
||||
'recorded_at' => now(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('renders the canonical audit route as a summary-first monitoring surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertOk()
|
||||
->assertSee('Summary-first audit history')
|
||||
->assertSee('Review governance, operational, and workspace-admin events in reverse chronological order');
|
||||
});
|
||||
|
||||
it('loads the audit page with populated filter options', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
auditLogPageTestRecord($tenant, [
|
||||
'action' => 'verification.completed',
|
||||
'actor_name' => 'Ahmed Darrazi',
|
||||
'resource_type' => 'tenant',
|
||||
'resource_id' => (string) $tenant->getKey(),
|
||||
'summary' => 'Verification completed',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertOk()
|
||||
->assertSee('Event type')
|
||||
->assertSee('Actor')
|
||||
->assertSee('Target type');
|
||||
});
|
||||
|
||||
it('renders derived labels for audit rows that are missing evolved snapshot columns', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
DB::table('audit_logs')->insert([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_id' => null,
|
||||
'actor_email' => 'spo_admin@yptw2.onmicrosoft.com',
|
||||
'actor_name' => 'Ahmed Darrazi',
|
||||
'action' => 'baseline.capture.started',
|
||||
'resource_type' => 'baseline_profile',
|
||||
'resource_id' => '2',
|
||||
'status' => 'success',
|
||||
'metadata' => json_encode([
|
||||
'baseline_profile_id' => 2,
|
||||
'baseline_profile_name' => 'Device Compliance',
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'recorded_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertOk()
|
||||
->assertSee('Baseline capture started for Baseline profile #2')
|
||||
->assertSee('Ahmed Darrazi')
|
||||
->assertSee('Baseline profile #2')
|
||||
->assertSee('Success');
|
||||
});
|
||||
|
||||
it('renders workspace setting targets with readable labels', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
auditLogPageTestRecord($tenant, [
|
||||
'action' => 'workspace_setting.updated',
|
||||
'summary' => 'Workspace setting updated for backup.retention_keep_last_default',
|
||||
'resource_type' => 'workspace_setting',
|
||||
'resource_id' => 'backup.retention_keep_last_default',
|
||||
'target_label' => 'backup.retention_keep_last_default',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertOk()
|
||||
->assertSee('Workspace setting updated for Backup Retention Keep Last Default')
|
||||
->assertSee('Backup Retention Keep Last Default');
|
||||
});
|
||||
|
||||
it('shows reverse-chronological audit rows and supports summary search', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant($tenantB, $user, role: 'owner');
|
||||
|
||||
$oldest = auditLogPageTestRecord($tenantA, [
|
||||
'resource_id' => '101',
|
||||
'summary' => 'Older baseline capture completed',
|
||||
'action' => 'baseline.capture.completed',
|
||||
'recorded_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$middle = auditLogPageTestRecord($tenantB, [
|
||||
'resource_id' => '102',
|
||||
'summary' => 'Rotate backup schedule credentials',
|
||||
'action' => 'backup.updated',
|
||||
'recorded_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$newest = auditLogPageTestRecord($tenantA, [
|
||||
'resource_id' => '103',
|
||||
'summary' => 'Newest restore failed',
|
||||
'action' => 'restore.failed',
|
||||
'status' => 'failed',
|
||||
'recorded_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
auditLogPageTestComponent($user)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords([$newest, $middle, $oldest], inOrder: true)
|
||||
->searchTable('Rotate')
|
||||
->assertCanSeeTableRecords([$middle])
|
||||
->assertCanNotSeeTableRecords([$newest, $oldest]);
|
||||
});
|
||||
|
||||
it('preselects the active tenant as the default audit filter', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant($tenantB, $user, role: 'owner');
|
||||
|
||||
$visible = auditLogPageTestRecord($tenantA, [
|
||||
'resource_id' => '201',
|
||||
'summary' => 'Tenant A verification completed',
|
||||
'action' => 'verification.completed',
|
||||
]);
|
||||
|
||||
$hidden = auditLogPageTestRecord($tenantB, [
|
||||
'resource_id' => '202',
|
||||
'summary' => 'Tenant B verification completed',
|
||||
'action' => 'verification.completed',
|
||||
]);
|
||||
|
||||
auditLogPageTestComponent($user, $tenantA)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
});
|
||||
|
||||
it('shows a clear-filters empty state when no audit rows match the current view', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$record = auditLogPageTestRecord($tenant, [
|
||||
'summary' => 'Baseline compare completed',
|
||||
'action' => 'baseline.compare.completed',
|
||||
]);
|
||||
|
||||
auditLogPageTestComponent($user)
|
||||
->assertTableEmptyStateActionsExistInOrder(['clear_filters'])
|
||||
->searchTable('no-such-audit-event')
|
||||
->assertCountTableRecords(0)
|
||||
->assertSee('No audit events match this view')
|
||||
->assertSee('Clear filters')
|
||||
->searchTable(null)
|
||||
->assertCanSeeTableRecords([$record]);
|
||||
});
|
||||
81
tests/Feature/Monitoring/AuditCoverageGovernanceTest.php
Normal file
81
tests/Feature/Monitoring/AuditCoverageGovernanceTest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
|
||||
it('derives summary-first audit semantics for baseline capture workflow events', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'audit-policy-a',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Audit policy A',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'],
|
||||
]);
|
||||
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$run = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_capture',
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRunService,
|
||||
);
|
||||
|
||||
$started = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.capture.started')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$completed = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.capture.completed')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($started)->not->toBeNull();
|
||||
expect($completed)->not->toBeNull();
|
||||
|
||||
expect($started?->summaryText())->toContain('Baseline capture started')
|
||||
->and($started?->normalizedOutcome())->toBe(AuditOutcome::Success)
|
||||
->and($started?->actorSnapshot()->type)->toBe(AuditActorType::Human)
|
||||
->and($started?->targetDisplayLabel())->toBe($profile->name)
|
||||
->and((int) $started?->operation_run_id)->toBe((int) $run->getKey());
|
||||
|
||||
expect($completed?->summaryText())->toContain('Baseline capture completed')
|
||||
->and($completed?->normalizedOutcome())->toBe(AuditOutcome::Success)
|
||||
->and($completed?->actorSnapshot()->type)->toBe(AuditActorType::Human)
|
||||
->and($completed?->targetDisplayLabel())->not->toBeNull()
|
||||
->and((int) $completed?->operation_run_id)->toBe((int) $run->getKey());
|
||||
});
|
||||
82
tests/Feature/Monitoring/AuditCoverageOperationsTest.php
Normal file
82
tests/Feature/Monitoring/AuditCoverageOperationsTest.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
it('writes a terminal audit row when an operation run completes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
$run = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'inventory_sync',
|
||||
identityInputs: ['selection_hash' => 'abc123'],
|
||||
context: ['selection_hash' => 'abc123'],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$service->updateRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'total' => 3,
|
||||
'processed' => 3,
|
||||
'succeeded' => 3,
|
||||
'failed' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
|
||||
expect($audit?->action)->toBe('operation.completed')
|
||||
->and($audit?->summaryText())->toBe('Inventory sync completed')
|
||||
->and($audit?->resource_type)->toBe('operation_run')
|
||||
->and((string) $audit?->resource_id)->toBe((string) $run->getKey())
|
||||
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Success)
|
||||
->and($audit?->actorDisplayLabel())->toBe($user->name)
|
||||
->and(data_get($audit?->metadata, 'operation_type'))->toBe('inventory_sync');
|
||||
});
|
||||
|
||||
it('writes blocked terminal audit semantics for blocked runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
$run = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'restore.execute',
|
||||
identityInputs: ['restore_run_id' => 44],
|
||||
context: ['restore_run_id' => 44],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$service->finalizeBlockedRun(
|
||||
$run,
|
||||
reasonCode: 'intune_rbac.not_configured',
|
||||
message: 'Restore is blocked until RBAC is configured.',
|
||||
);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
|
||||
expect($audit?->action)->toBe('operation.blocked')
|
||||
->and($audit?->summaryText())->toBe('Restore execution blocked')
|
||||
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Blocked)
|
||||
->and(data_get($audit?->metadata, 'failure_summary.0.reason_code'))->toBe('intune_rbac.not_configured');
|
||||
});
|
||||
29
tests/Unit/Audit/AuditActionIdTest.php
Normal file
29
tests/Unit/Audit/AuditActionIdTest.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Audit\AuditActionId;
|
||||
|
||||
it('labels and summarizes canonical audit taxonomy values', function (): void {
|
||||
expect(AuditActionId::labelFor(AuditActionId::BaselineProfileUpdated))
|
||||
->toBe('Baseline profile updated');
|
||||
|
||||
expect(AuditActionId::summaryFor(
|
||||
action: AuditActionId::BaselineProfileUpdated,
|
||||
targetLabel: 'Windows baseline',
|
||||
))->toBe('Baseline profile updated for Windows baseline');
|
||||
});
|
||||
|
||||
it('keeps legacy event ids readable and appends transition context when available', function (): void {
|
||||
expect(AuditActionId::labelFor('finding.risk_accepted'))
|
||||
->toBe('Finding risk accepted');
|
||||
|
||||
expect(AuditActionId::summaryFor(
|
||||
action: 'finding.triaged',
|
||||
targetLabel: 'Drift finding #42',
|
||||
context: [
|
||||
'before_status' => 'new',
|
||||
'after_status' => 'triaged',
|
||||
],
|
||||
))->toBe('Finding triaged for Drift finding #42 (new -> triaged)');
|
||||
});
|
||||
35
tests/Unit/Audit/AuditContextSanitizerTest.php
Normal file
35
tests/Unit/Audit/AuditContextSanitizerTest.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
|
||||
it('redacts protected audit fields recursively', function (): void {
|
||||
$sanitized = AuditContextSanitizer::sanitize([
|
||||
'token' => 'super-secret',
|
||||
'nested' => [
|
||||
'client_secret' => 'another-secret',
|
||||
],
|
||||
'notes' => 'Authorization: Bearer abc.def.ghi',
|
||||
]);
|
||||
|
||||
expect(data_get($sanitized, 'token'))->toBe('[REDACTED]')
|
||||
->and(data_get($sanitized, 'nested.client_secret'))->toBe('[REDACTED]')
|
||||
->and(data_get($sanitized, 'notes'))->toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('truncates oversized strings and arrays before they reach the audit log', function (): void {
|
||||
$items = collect(range(1, 55))
|
||||
->mapWithKeys(static fn (int $index): array => ["key_{$index}" => "value_{$index}"])
|
||||
->all();
|
||||
|
||||
$sanitized = AuditContextSanitizer::sanitize([
|
||||
'message' => str_repeat('x', 600),
|
||||
'items' => $items,
|
||||
]);
|
||||
|
||||
expect((string) data_get($sanitized, 'message'))->toEndWith(' [truncated]')
|
||||
->and(data_get($sanitized, 'items.truncated'))->toBeTrue()
|
||||
->and(array_key_exists('key_50', $sanitized['items']))->toBeTrue()
|
||||
->and(array_key_exists('key_51', $sanitized['items']))->toBeFalse();
|
||||
});
|
||||
64
tests/Unit/Audit/AuditLogCompatibilityTest.php
Normal file
64
tests/Unit/Audit/AuditLogCompatibilityTest.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps the evolved audit columns available for legacy readers', function (): void {
|
||||
expect(Schema::hasColumns('audit_logs', [
|
||||
'summary',
|
||||
'outcome',
|
||||
'actor_type',
|
||||
'actor_label',
|
||||
'target_label',
|
||||
'operation_run_id',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('backfills summary, actor, and outcome semantics for legacy audit rows', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'ops@example.com',
|
||||
'action' => 'restore.failed',
|
||||
'resource_type' => 'restore_run',
|
||||
'resource_id' => '55',
|
||||
'status' => 'error',
|
||||
'metadata' => [
|
||||
'reason_code' => 'graph.unavailable',
|
||||
],
|
||||
'recorded_at' => now(),
|
||||
])->refresh();
|
||||
|
||||
expect($audit->summaryText())->toBe('Restore failed for Restore run #55')
|
||||
->and($audit->normalizedOutcome())->toBe(AuditOutcome::Failed)
|
||||
->and($audit->actorSnapshot()->type)->toBe(AuditActorType::Human)
|
||||
->and($audit->actorDisplayLabel())->toBe('ops@example.com')
|
||||
->and($audit->targetDisplayLabel())->toBe('Restore run #55');
|
||||
});
|
||||
|
||||
it('keeps target labels intelligible when the source record no longer exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'action' => 'backup.archived',
|
||||
'resource_type' => 'backup_set',
|
||||
'resource_id' => '999999',
|
||||
'status' => 'success',
|
||||
'metadata' => [],
|
||||
'recorded_at' => now(),
|
||||
])->refresh();
|
||||
|
||||
expect($audit->targetDisplayLabel())->toBe('Backup set #999999')
|
||||
->and($audit->summaryText())->toBe('Backup set archived for Backup set #999999');
|
||||
});
|
||||
79
tests/Unit/Audit/AuditRecorderTest.php
Normal file
79
tests/Unit/Audit/AuditRecorderTest.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Audit\AuditRecorder;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('records workspace and tenant audit rows through the shared recorder', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'backup_create',
|
||||
]);
|
||||
|
||||
$audit = app(AuditRecorder::class)->record(
|
||||
action: 'backup.created',
|
||||
context: [
|
||||
'item_count' => 3,
|
||||
'client_secret' => 'super-secret',
|
||||
],
|
||||
workspace: $tenant->workspace,
|
||||
tenant: $tenant,
|
||||
actor: AuditActorSnapshot::human($user),
|
||||
target: new AuditTargetSnapshot(
|
||||
type: 'backup_set',
|
||||
id: '42',
|
||||
label: 'Nightly backup',
|
||||
),
|
||||
outcome: 'completed',
|
||||
summary: null,
|
||||
operationRunId: (int) $run->getKey(),
|
||||
)->refresh();
|
||||
|
||||
expect((int) $audit->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||
->and((int) $audit->tenant_id)->toBe((int) $tenant->getKey())
|
||||
->and($audit->actorSnapshot()->type)->toBe(AuditActorType::Human)
|
||||
->and($audit->actorDisplayLabel())->toBe($user->name)
|
||||
->and($audit->resource_type)->toBe('backup_set')
|
||||
->and((string) $audit->resource_id)->toBe('42')
|
||||
->and($audit->targetDisplayLabel())->toBe('Nightly backup')
|
||||
->and($audit->summaryText())->toBe('Backup set created for Nightly backup')
|
||||
->and($audit->normalizedOutcome())->toBe(AuditOutcome::Success)
|
||||
->and((int) $audit->operation_run_id)->toBe((int) $run->getKey())
|
||||
->and(data_get($audit->metadata, 'client_secret'))->toBe('[REDACTED]')
|
||||
->and(data_get($audit->metadata, 'item_count'))->toBe(3);
|
||||
});
|
||||
|
||||
it('infers actor kind, target identity, and normalized outcome when snapshots are omitted', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = app(AuditRecorder::class)->record(
|
||||
action: 'backup_schedule.run_failed',
|
||||
context: [
|
||||
'_actor_type' => 'scheduled',
|
||||
'backup_schedule_id' => 123,
|
||||
'status' => 'failed',
|
||||
'token' => 'top-secret',
|
||||
],
|
||||
workspace: $tenant->workspace,
|
||||
tenant: $tenant,
|
||||
)->refresh();
|
||||
|
||||
expect($audit->actorSnapshot()->type)->toBe(AuditActorType::Scheduled)
|
||||
->and($audit->resource_type)->toBe('backup_schedule')
|
||||
->and((string) $audit->resource_id)->toBe('123')
|
||||
->and($audit->targetDisplayLabel())->toBe('Backup schedule #123')
|
||||
->and($audit->normalizedOutcome())->toBe(AuditOutcome::Failed)
|
||||
->and(data_get($audit->metadata, 'token'))->toBe('[REDACTED]');
|
||||
});
|
||||
34
tests/Unit/Badges/AuditBadgesTest.php
Normal file
34
tests/Unit/Badges/AuditBadgesTest.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps audit outcomes to canonical badge semantics', function (): void {
|
||||
$success = BadgeCatalog::spec(BadgeDomain::AuditOutcome, 'success');
|
||||
expect($success->label)->toBe('Success');
|
||||
expect($success->color)->toBe('success');
|
||||
|
||||
$failed = BadgeCatalog::spec(BadgeDomain::AuditOutcome, 'failed');
|
||||
expect($failed->label)->toBe('Failed');
|
||||
expect($failed->color)->toBe('danger');
|
||||
|
||||
$blocked = BadgeCatalog::spec(BadgeDomain::AuditOutcome, 'blocked');
|
||||
expect($blocked->label)->toBe('Blocked');
|
||||
expect($blocked->color)->toBe('warning');
|
||||
});
|
||||
|
||||
it('maps audit actor types to canonical badge semantics', function (): void {
|
||||
$human = BadgeCatalog::spec(BadgeDomain::AuditActorType, 'human');
|
||||
expect($human->label)->toBe('Human');
|
||||
expect($human->color)->toBe('info');
|
||||
|
||||
$scheduled = BadgeCatalog::spec(BadgeDomain::AuditActorType, 'scheduled');
|
||||
expect($scheduled->label)->toBe('Scheduled job');
|
||||
expect($scheduled->color)->toBe('warning');
|
||||
|
||||
$platform = BadgeCatalog::spec(BadgeDomain::AuditActorType, 'platform');
|
||||
expect($platform->label)->toBe('Platform');
|
||||
expect($platform->color)->toBe('gray');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user