$context * @return array */ 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 $attributes * @return array */ 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 $context * @return array */ private function extractMetadata(array $context): array { $metadata = $context['metadata'] ?? []; unset($context['metadata']); if (! is_array($metadata)) { $metadata = []; } return $metadata + $context; } /** * @param array $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 $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 $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 */ 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(); } }