TenantAtlas/app/Models/AuditLog.php
2026-03-11 10:35:32 +01:00

286 lines
9.4 KiB
PHP

<?php
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
{
use HasFactory;
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();
}
}