mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(audit): store encrypted field-level changes on mutations
Record old/new values for auditable create, update, and delete actions, persist them encrypted on audit events, expose them in the API, and let the team audit log expand a field-level before/after view. Sensitive, hidden, and identifier fields stay out of the change payload.
This commit is contained in:
@@ -63,6 +63,7 @@ class AuditEventsController extends Controller
|
||||
'actor_token_id',
|
||||
'actor_token_name',
|
||||
'metadata',
|
||||
'changes',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
]))
|
||||
|
||||
@@ -34,6 +34,7 @@ class AuditEvent extends Model
|
||||
'resource_name',
|
||||
'description',
|
||||
'metadata',
|
||||
'changes',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'created_at',
|
||||
@@ -43,6 +44,7 @@ class AuditEvent extends Model
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'changes' => 'encrypted:array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -85,9 +87,27 @@ class AuditEvent extends Model
|
||||
* @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, $level);
|
||||
$attributes = self::attributesFor($event, $context, $changes, $level);
|
||||
|
||||
DB::afterCommit(function () use ($attributes): void {
|
||||
defer(function () use ($attributes): void {
|
||||
@@ -113,7 +133,7 @@ class AuditEvent extends Model
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function attributesFor(string $event, array $context, string $level): array
|
||||
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)
|
||||
@@ -152,7 +172,8 @@ class AuditEvent extends Model
|
||||
'resource_name' => $resourceName,
|
||||
'description' => data_get($context, 'audit_description')
|
||||
?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)),
|
||||
'metadata' => self::redact($context),
|
||||
'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,
|
||||
];
|
||||
@@ -204,7 +225,7 @@ class AuditEvent extends Model
|
||||
return $key ? data_get($context, $key) : null;
|
||||
}
|
||||
|
||||
private static function redact(mixed $value, ?string $key = null): mixed
|
||||
public static function redact(mixed $value, ?string $key = null): mixed
|
||||
{
|
||||
if ($key !== null && self::isSensitiveKey($key)) {
|
||||
return '[REDACTED]';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\Team;
|
||||
use Closure;
|
||||
@@ -82,12 +83,53 @@ trait Auditable
|
||||
$resourceType = Str::snake(class_basename($this));
|
||||
$source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui';
|
||||
|
||||
auditLog("{$source}.{$resourceType}.{$action}", [
|
||||
AuditEvent::recordModelMutation("{$source}.{$resourceType}.{$action}", [
|
||||
'team_id' => $teamId,
|
||||
"{$resourceType}_uuid" => $this->getAttribute('uuid'),
|
||||
"{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'),
|
||||
'changed_fields' => $changedFields,
|
||||
]);
|
||||
], $this->auditChanges(
|
||||
$action,
|
||||
$action === 'updated' ? $changedFields : array_keys($this->getAttributes()),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $fields
|
||||
* @return array<string, array{old: mixed, new: mixed}>
|
||||
*/
|
||||
private function auditChanges(string $action, array $fields): array
|
||||
{
|
||||
return collect($fields)
|
||||
->reject(fn (string $field): bool => $this->isExcludedAuditField($field))
|
||||
->mapWithKeys(fn (string $field): array => [
|
||||
$field => [
|
||||
'old' => $action === 'created' ? null : AuditEvent::redact($this->getOriginal($field)),
|
||||
'new' => $action === 'deleted' ? null : AuditEvent::redact($this->getAttribute($field)),
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function isExcludedAuditField(string $field): bool
|
||||
{
|
||||
$cast = $this->getCasts()[$field] ?? null;
|
||||
|
||||
return in_array($field, [
|
||||
$this->getKeyName(),
|
||||
'uuid',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'order',
|
||||
'status',
|
||||
...$this->getHidden(),
|
||||
...($this->auditExclude ?? []),
|
||||
], true)
|
||||
|| str_ends_with($field, '_id')
|
||||
|| str_ends_with($field, '_at')
|
||||
|| preg_match('/password|secret|token|private_key|signature|credential|api_key|access_key|authorization|cookie/i', $field)
|
||||
|| (is_string($cast) && ($cast === 'encrypted' || str_starts_with($cast, 'encrypted:')));
|
||||
}
|
||||
|
||||
public function withoutAuditLogging(Closure $callback): mixed
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('audit_events', function (Blueprint $table) {
|
||||
$table->text('changes')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('audit_events', function (Blueprint $table) {
|
||||
$table->dropColumn('changes');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -44,8 +44,9 @@
|
||||
<span class="text-right">Time</span>
|
||||
</div>
|
||||
@foreach ($events as $event)
|
||||
<div wire:key="audit-event-{{ $event->id }}"
|
||||
class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-3 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div wire:key="audit-event-{{ $event->id }}" x-data="{ expanded: false }"
|
||||
class="min-w-[760px] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="grid grid-cols-[14rem_minmax(0,1fr)_12rem_9rem] gap-4 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
|
||||
{{ $event->actor_name ?: Str::headline($event->actor_type) }}
|
||||
@@ -69,6 +70,13 @@
|
||||
<div class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $event->event }}
|
||||
</div>
|
||||
@if (filled($event->changes))
|
||||
<button type="button" @click="expanded = ! expanded"
|
||||
class="mt-1 text-[11px] font-medium text-accent hover:underline"
|
||||
:aria-expanded="expanded">
|
||||
<span x-text="expanded ? 'Hide changes' : 'View changes'">View changes</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
<span class="rounded-md bg-neutral-100 px-2 py-1 dark:bg-white/[0.06]">
|
||||
@@ -82,6 +90,24 @@
|
||||
{{ $event->created_at->diffForHumans() }}
|
||||
</time>
|
||||
</div>
|
||||
@if (filled($event->changes))
|
||||
<div x-cloak x-show="expanded" x-collapse
|
||||
class="border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/[0.07] dark:bg-white/[0.02]">
|
||||
<div class="grid grid-cols-[12rem_minmax(0,1fr)_minmax(0,1fr)] gap-4 pb-2 text-[10px] font-medium uppercase tracking-wide text-neutral-500 dark:text-fg-faint">
|
||||
<span>Field</span>
|
||||
<span>Previous value</span>
|
||||
<span>New value</span>
|
||||
</div>
|
||||
@foreach ($event->changes as $field => $change)
|
||||
<div class="grid grid-cols-[12rem_minmax(0,1fr)_minmax(0,1fr)] gap-4 border-t border-neutral-200 py-2 text-[12px] dark:border-white/[0.07]">
|
||||
<span class="font-medium text-black dark:text-fg">{{ Str::headline($field) }}</span>
|
||||
<span class="break-words whitespace-pre-wrap text-neutral-600 dark:text-fg-dim">{{ is_array($change['old']) ? json_encode($change['old'], JSON_UNESCAPED_SLASHES) : (is_bool($change['old']) ? ($change['old'] ? 'True' : 'False') : ($change['old'] ?? 'None')) }}</span>
|
||||
<span class="break-words whitespace-pre-wrap text-neutral-600 dark:text-fg-dim">{{ is_array($change['new']) ? json_encode($change['new'], JSON_UNESCAPED_SLASHES) : (is_bool($change['new']) ? ($change['new'] ? 'True' : 'False') : ($change['new'] ?? 'None')) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Auth\Events\Logout;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Once;
|
||||
@@ -171,6 +172,70 @@ test('auditable models record authenticated create update and delete actions', f
|
||||
])->and($events[1]->metadata['changed_fields'])->toBe(['name']);
|
||||
});
|
||||
|
||||
test('auditable models store encrypted changes for create update and delete actions', function () {
|
||||
$project = Project::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Website project',
|
||||
'description' => 'Original description',
|
||||
]);
|
||||
$project->update(['description' => 'Updated description']);
|
||||
$project->delete();
|
||||
|
||||
$events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get();
|
||||
|
||||
expect($events[0]->changes)->toMatchArray([
|
||||
'name' => ['old' => null, 'new' => 'Website project'],
|
||||
'description' => ['old' => null, 'new' => 'Original description'],
|
||||
])->and($events[0]->changes)->not->toHaveKeys(['id', 'uuid', 'team_id', 'created_at', 'updated_at'])
|
||||
->and($events[1]->changes)->toBe([
|
||||
'description' => ['old' => 'Original description', 'new' => 'Updated description'],
|
||||
])->and($events[2]->changes)->toMatchArray([
|
||||
'name' => ['old' => 'Website project', 'new' => null],
|
||||
'description' => ['old' => 'Updated description', 'new' => null],
|
||||
]);
|
||||
|
||||
$storedChanges = DB::table('audit_events')->where('id', $events[1]->id)->value('changes');
|
||||
|
||||
expect($storedChanges)->not->toContain('Original description')
|
||||
->and($storedChanges)->not->toContain('Updated description');
|
||||
});
|
||||
|
||||
test('auditable models exclude hidden and encrypted attributes from changes', function () {
|
||||
SharedEnvironmentVariable::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'super-secret',
|
||||
'comment' => 'Used by the API',
|
||||
]);
|
||||
|
||||
$event = AuditEvent::query()->where('resource_type', 'shared_environment_variable')->sole();
|
||||
|
||||
expect($event->changes)->toMatchArray([
|
||||
'key' => ['old' => null, 'new' => 'API_TOKEN'],
|
||||
'comment' => ['old' => null, 'new' => 'Used by the API'],
|
||||
])->and($event->changes)->not->toHaveKey('value')
|
||||
->and(DB::table('audit_events')->where('id', $event->id)->value('changes'))
|
||||
->not->toContain('super-secret');
|
||||
});
|
||||
|
||||
test('auditable models exclude timestamp attributes from changes', function () {
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
StandalonePostgresql::query()->create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'Timestamp test database',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_type' => Server::class,
|
||||
'destination_id' => 0,
|
||||
'postgres_password' => 'password',
|
||||
'last_restart_at' => now(),
|
||||
]);
|
||||
|
||||
expect(AuditEvent::query()->sole()->changes)->not->toHaveKey('last_restart_at');
|
||||
});
|
||||
|
||||
test('deleting a project dispatches deleted events for its environments', function () {
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = $project->environments()->sole();
|
||||
@@ -634,6 +699,37 @@ test('audit log page only shows events for the current team', function () {
|
||||
->assertDontSee('Private app deleted');
|
||||
});
|
||||
|
||||
test('audit log expands encrypted model changes for team admins and owners', function () {
|
||||
AuditEvent::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'description' => 'Website updated',
|
||||
'changes' => [
|
||||
'name' => ['old' => 'Website', 'new' => 'Store'],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(AuditLog::class)
|
||||
->assertSee('View changes')
|
||||
->assertSee('Name')
|
||||
->assertSee('Website')
|
||||
->assertSee('Store');
|
||||
});
|
||||
|
||||
test('manual audit events do not store model changes', function () {
|
||||
auditLog('ui.application.restarted', [
|
||||
'team_id' => $this->team->id,
|
||||
'application_uuid' => 'app-123',
|
||||
'audit_changes' => [
|
||||
'name' => ['old' => 'Injected', 'new' => 'Value'],
|
||||
],
|
||||
]);
|
||||
|
||||
$event = AuditEvent::query()->sole();
|
||||
|
||||
expect($event->changes)->toBeNull()
|
||||
->and($event->metadata)->not->toHaveKey('audit_changes');
|
||||
});
|
||||
|
||||
test('audit log is available under team settings', function () {
|
||||
$this->get('/team/audit-log')
|
||||
->assertSuccessful()
|
||||
@@ -669,6 +765,7 @@ test('team admins can query only their team audit events through the api', funct
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_token_name' => 'production token',
|
||||
'metadata' => ['changed_fields' => ['name']],
|
||||
'changes' => ['name' => ['old' => 'Old project', 'new' => 'New project']],
|
||||
'ip_address' => '192.0.2.1',
|
||||
'user_agent' => 'Sensitive user agent',
|
||||
]);
|
||||
@@ -694,6 +791,7 @@ test('team admins can query only their team audit events through the api', funct
|
||||
->assertJsonMissingPath('data.0.actor_token_id')
|
||||
->assertJsonMissingPath('data.0.actor_token_name')
|
||||
->assertJsonMissingPath('data.0.metadata')
|
||||
->assertJsonMissingPath('data.0.changes')
|
||||
->assertJsonMissingPath('data.0.ip_address')
|
||||
->assertJsonMissingPath('data.0.user_agent');
|
||||
});
|
||||
@@ -710,6 +808,7 @@ test('team admins with sensitive read access can query full audit event details'
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_token_name' => 'production token',
|
||||
'metadata' => ['changed_fields' => ['name']],
|
||||
'changes' => ['name' => ['old' => 'Old project', 'new' => 'New project']],
|
||||
'ip_address' => '192.0.2.1',
|
||||
'user_agent' => 'Sensitive user agent',
|
||||
]);
|
||||
@@ -720,6 +819,8 @@ test('team admins with sensitive read access can query full audit event details'
|
||||
->assertJsonPath('data.0.actor_email', 'owner@example.com')
|
||||
->assertJsonPath('data.0.actor_token_name', 'production token')
|
||||
->assertJsonPath('data.0.metadata.changed_fields.0', 'name')
|
||||
->assertJsonPath('data.0.changes.name.old', 'Old project')
|
||||
->assertJsonPath('data.0.changes.name.new', 'New project')
|
||||
->assertJsonPath('data.0.ip_address', '192.0.2.1')
|
||||
->assertJsonPath('data.0.user_agent', 'Sensitive user agent');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user