TenantAtlas/app/Services/Audit/AuditEventBuilder.php
ahmido 28cfe38ba4 feat: lay audit log foundation (#163)
## Summary
- turn the Monitoring audit log placeholder into a real workspace-scoped audit review surface
- introduce a shared audit recorder, richer audit value objects, and additive audit log schema evolution
- add audit outcome and actor badges, permission-aware related navigation, and durable audit retention coverage

## Included
- canonical `/admin/audit-log` list and detail inspection UI
- audit model helpers, taxonomy expansion, actor/target snapshots, and recorder/builder services
- operation terminal audit writes and purge command retention changes
- spec 134 design artifacts and focused Pest coverage for audit foundation behavior

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Audit tests/Unit/Badges/AuditBadgesTest.php 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 tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php`

## Notes
- Livewire v4.0+ compliance is preserved within the existing Filament v5 application.
- No provider registration changes were needed; panel provider registration remains in `bootstrap/providers.php`.
- No new globally searchable resource was introduced.
- The audit page remains read-only; no destructive actions were added.
- No new asset pipeline changes were introduced; existing deploy-time `php artisan filament:assets` behavior remains unchanged.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #163
2026-03-11 09:39:37 +00:00

394 lines
14 KiB
PHP

<?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();
}
}