mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
304 lines
11 KiB
PHP
304 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Contracts\Support\Arrayable;
|
|
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 JsonSerializable;
|
|
use stdClass;
|
|
use Throwable;
|
|
|
|
class AuditEvent extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
public const UPDATED_AT = null;
|
|
|
|
/**
|
|
* Raw commands and configurations can contain credentials without sensitive field names.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
private const SENSITIVE_VALUE_FIELDS = [
|
|
'git_full_url',
|
|
'install_command', 'build_command', 'start_command',
|
|
'health_check_command', 'health_check_response_text',
|
|
'custom_docker_run_options', 'pre_deployment_command', 'post_deployment_command',
|
|
'docker_compose_custom_start_command', 'docker_compose_custom_build_command',
|
|
'custom_nginx_configuration',
|
|
'postgres_conf', 'mysql_conf', 'mariadb_conf', 'mongo_conf', 'redis_conf', 'keydb_conf',
|
|
'internal_db_url', 'external_db_url', 'init_scripts',
|
|
'dockerfile', 'docker_compose', 'docker_compose_raw', 'custom_labels',
|
|
'last_saved_proxy_configuration',
|
|
'environment_variables', 'environment_variables_preview',
|
|
'validation_logs', 'server_metadata', 'logs',
|
|
'configuration_snapshot', 'configuration_diff', 'content', 'file_storage_content',
|
|
];
|
|
|
|
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',
|
|
'changes',
|
|
'ip_address',
|
|
'user_agent',
|
|
'created_at',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'metadata' => 'array',
|
|
'changes' => 'encrypted: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
|
|
{
|
|
self::recordWithChanges($event, $context, level: $level);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, array{old: mixed, new: mixed}> $changes
|
|
*/
|
|
public static function recordModelMutation(string $event, array $context, array $changes): void
|
|
{
|
|
self::recordWithChanges($event, $context, $changes);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, array{old: mixed, new: mixed}>|null $changes
|
|
*/
|
|
private static function recordWithChanges(string $event, array $context, ?array $changes = null, string $level = 'info'): void
|
|
{
|
|
try {
|
|
$attributes = self::attributesFor($event, $context, $changes, $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, ?array $changes, string $level): array
|
|
{
|
|
$teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id')
|
|
?? (array_key_exists('team_id', $context)
|
|
? $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(Arr::except($context, ['audit_changes'])),
|
|
'changes' => $changes,
|
|
'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;
|
|
}
|
|
|
|
public static function isSensitiveField(string $field): bool
|
|
{
|
|
return in_array($field, self::SENSITIVE_VALUE_FIELDS, true)
|
|
|| preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie|license_key/i', $field) === 1;
|
|
}
|
|
|
|
public static function redact(mixed $value, ?string $key = null): mixed
|
|
{
|
|
if ($key !== null && (in_array($key, self::SENSITIVE_VALUE_FIELDS, true) || self::isSensitiveKey($key))) {
|
|
return '[REDACTED]';
|
|
}
|
|
|
|
if ($value instanceof Arrayable) {
|
|
$value = $value->toArray();
|
|
} elseif ($value instanceof JsonSerializable) {
|
|
$value = $value->jsonSerialize();
|
|
} elseif ($value instanceof stdClass) {
|
|
$value = (array) $value;
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
return self::stripUrlCredentials($value);
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
return collect($value)
|
|
->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [
|
|
$itemKey => self::redact($item, (string) $itemKey),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Removes the user info of URLs (https://user:token@host), so the audit keeps the URL without credentials.
|
|
*/
|
|
private static function stripUrlCredentials(string $value): string
|
|
{
|
|
return preg_replace('#\b([a-z][a-z0-9+.-]*://)[^/?\#\s]+@#i', '$1', $value) ?? $value;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|