mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-06 14:07:37 -04:00
Add audit levels and record user, OAuth, DNS, notification, and settings changes while removing the obsolete scheduled job monitoring UI and services.
233 lines
8.0 KiB
PHP
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);
|
|
}
|
|
}
|