299 lines
9.7 KiB
PHP
299 lines
9.7 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;
|
|
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
private const INTERNAL_METADATA_KEYS = [
|
|
'_actor_type',
|
|
'_dedupe_key',
|
|
];
|
|
|
|
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)
|
|
|| str_starts_with((string) $key, '_')
|
|
|| in_array((string) $key, self::INTERNAL_METADATA_KEYS, 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();
|
|
}
|
|
}
|