Files
coolify/app/Models/AuditEvent.php
T
Andras Bacsai 1c408fb408 feat(audit): expand event tracking and remove scheduled job monitoring
Add audit levels and record user, OAuth, DNS, notification, and settings changes while removing the obsolete scheduled job monitoring UI and services.
2026-09-21 12:23:42 +02:00

233 lines
8.0 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
class AuditEvent extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'team_id',
'event',
'source',
'action',
'level',
'actor_type',
'actor_id',
'actor_name',
'actor_email',
'actor_token_id',
'actor_token_name',
'resource_type',
'resource_uuid',
'resource_name',
'description',
'metadata',
'ip_address',
'user_agent',
'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function scopeVisibleToTeam(Builder $query, int $teamId, bool $includeInstanceEvents = false): Builder
{
return $query->where(function (Builder $query) use ($includeInstanceEvents, $teamId): void {
$query->where('team_id', $teamId)
->when($includeInstanceEvents, fn (Builder $query) => $query->orWhereNull('team_id'));
});
}
public function scopeFiltered(
Builder $query,
string $search = '',
string $action = 'all',
string $source = 'all',
bool $searchSensitiveFields = true,
): Builder {
return $query
->when($action !== 'all', fn (Builder $query) => $query->where('action', $action))
->when($source !== 'all', fn (Builder $query) => $query->where('source', $source))
->when($search !== '', function (Builder $query) use ($search, $searchSensitiveFields): void {
$query->where(function (Builder $query) use ($search, $searchSensitiveFields): void {
$query->where('event', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('resource_name', 'like', "%{$search}%")
->orWhere('actor_name', 'like', "%{$search}%")
->when($searchSensitiveFields, fn (Builder $query) => $query->orWhere('actor_email', 'like', "%{$search}%"));
});
});
}
public function scopeLatestFirst(Builder $query): Builder
{
return $query->latest('created_at')->latest('id');
}
/**
* @param array<string, mixed> $context
*/
public static function record(string $event, array $context = [], string $level = 'info'): void
{
try {
$attributes = self::attributesFor($event, $context, $level);
DB::afterCommit(function () use ($attributes): void {
defer(function () use ($attributes): void {
try {
self::query()->create($attributes);
} catch (Throwable $exception) {
Log::warning('Audit event persistence failed', [
'event' => $attributes['event'],
'exception' => $exception::class,
]);
}
})->always();
});
} catch (Throwable $exception) {
Log::warning('Audit event preparation failed', [
'event' => $event,
'exception' => $exception::class,
]);
}
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private static function attributesFor(string $event, array $context, string $level): array
{
$teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id')
?? data_get($context, 'team_id')
?? currentTeam()?->id
?? self::teamIdFromContext($context);
$parts = explode('.', $event);
$source = $parts[0] ?? 'system';
$resourceType = data_get($context, 'resource') ?? ($parts[1] ?? null);
$action = data_get($context, 'action') ?? (end($parts) ?: 'event');
$resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid');
$resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name');
$user = auth()->user();
$token = $user?->currentAccessToken();
$actorType = match (true) {
in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source,
$token !== null => 'api_token',
$user !== null => 'user',
default => 'system',
};
return [
'team_id' => $teamId,
'event' => $event,
'source' => $source,
'action' => $action,
'level' => self::normalizeLevel($level),
'actor_type' => $actorType,
'actor_id' => data_get($context, 'actor_id', $user?->id),
'actor_name' => data_get($context, 'actor_name', $user?->name),
'actor_email' => data_get($context, 'actor_email', $user?->email),
'actor_token_id' => $token?->id,
'actor_token_name' => $token?->name,
'resource_type' => $resourceType,
'resource_uuid' => $resourceUuid,
'resource_name' => $resourceName,
'description' => data_get($context, 'audit_description')
?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)),
'metadata' => self::redact($context),
'ip_address' => app()->bound('request') ? request()->ip() : null,
'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null,
];
}
public static function normalizeLevel(string $level): string
{
return in_array($level, ['info', 'warning', 'error'], true) ? $level : 'info';
}
public static function redactContext(array $context): array
{
return self::redact($context);
}
/**
* @param array<string, mixed> $context
*/
private static function teamIdFromContext(array $context): ?int
{
$applicationUuid = data_get($context, 'application_uuid');
if (! is_string($applicationUuid) || $applicationUuid === '') {
return null;
}
return Application::query()
->where('uuid', $applicationUuid)
->first()?->team()?->id;
}
public static function pruneExpired(): int
{
return self::query()
->where('created_at', '<', now()->subDays(90))
->delete();
}
/**
* @param array<string, mixed> $context
*/
private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed
{
if ($preferredKey !== null && filled(data_get($context, $preferredKey))) {
return data_get($context, $preferredKey);
}
$key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix));
return $key ? data_get($context, $key) : null;
}
private static function redact(mixed $value, ?string $key = null): mixed
{
if ($key !== null && self::isSensitiveKey($key)) {
return '[REDACTED]';
}
if (! is_array($value)) {
return $value;
}
return collect($value)
->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [
$itemKey => self::redact($item, (string) $itemKey),
])
->all();
}
private static function isSensitiveKey(string $key): bool
{
if (preg_match('/_(id|uuid|name)$/i', $key)) {
return false;
}
return (bool) preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie|license_key/i', $key);
}
}