## 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
394 lines
14 KiB
PHP
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();
|
|
}
|
|
}
|