mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-29 02:24:29 -05:00
212 lines
7.3 KiB
PHP
212 lines
7.3 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',
|
|
'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 = []): void
|
|
{
|
|
try {
|
|
$attributes = self::attributesFor($event, $context);
|
|
|
|
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): 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,
|
|
'actor_type' => $actorType,
|
|
'actor_id' => $user?->id,
|
|
'actor_name' => $user?->name,
|
|
'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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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 && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $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();
|
|
}
|
|
}
|