mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(audit): show encrypted field-level mutation changes (#11788)
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',
|
||||
]))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -9,6 +10,8 @@ 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
|
||||
@@ -17,6 +20,27 @@ class AuditEvent extends Model
|
||||
|
||||
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',
|
||||
@@ -34,6 +58,7 @@ class AuditEvent extends Model
|
||||
'resource_name',
|
||||
'description',
|
||||
'metadata',
|
||||
'changes',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'created_at',
|
||||
@@ -43,6 +68,7 @@ class AuditEvent extends Model
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'changes' => 'encrypted:array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -85,9 +111,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 +157,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 +196,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,12 +249,26 @@ class AuditEvent extends Model
|
||||
return $key ? data_get($context, $key) : null;
|
||||
}
|
||||
|
||||
private static function redact(mixed $value, ?string $key = null): mixed
|
||||
public static function isSensitiveField(string $field): bool
|
||||
{
|
||||
if ($key !== null && self::isSensitiveKey($key)) {
|
||||
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_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\Team;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
trait Auditable
|
||||
{
|
||||
@@ -82,12 +85,64 @@ 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
|
||||
{
|
||||
try {
|
||||
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();
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Audit change preparation failed', [
|
||||
'resource_type' => Str::snake(class_basename($this)),
|
||||
'action' => $action,
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
...(get_class_vars(static::class)['hidden'] ?? []),
|
||||
...$this->getHidden(),
|
||||
...($this->auditExclude ?? []),
|
||||
], true)
|
||||
|| str_ends_with($field, '_id')
|
||||
|| str_ends_with($field, '_at')
|
||||
|| AuditEvent::isSensitiveField($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>
|
||||
|
||||
@@ -31,13 +31,29 @@ use App\Models\User;
|
||||
use App\Traits\Auditable;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Auth\Events\Logout;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
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;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class FailingAuditAttributeCast implements CastsAttributes
|
||||
{
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
throw new RuntimeException('sensitive attribute value');
|
||||
}
|
||||
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -171,6 +187,216 @@ 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 redact nested secrets in object casts', function () {
|
||||
Server::flushIdentityMap();
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->proxy = [
|
||||
'type' => 'TRAEFIK',
|
||||
'credentials' => ['password' => 'old-proxy-password'],
|
||||
];
|
||||
$server->save();
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
$server->proxy = [
|
||||
'type' => 'TRAEFIK',
|
||||
'credentials' => ['password' => 'new-proxy-password'],
|
||||
];
|
||||
$server->save();
|
||||
|
||||
$changes = AuditEvent::query()->sole()->changes;
|
||||
|
||||
expect($changes['proxy'])->toBe([
|
||||
'old' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'],
|
||||
'new' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('audit changes exclude raw fields that can contain embedded credentials', function () {
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create(['environment_id' => $environment->id]);
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
$application->update([
|
||||
'name' => 'Safe name',
|
||||
'git_full_url' => 'https://user:embedded-password@example.com/repo.git',
|
||||
'build_command' => 'API_KEY=embedded-key npm run build',
|
||||
]);
|
||||
|
||||
expect(AuditEvent::query()->sole()->changes)->toHaveKey('name')
|
||||
->not->toHaveKeys(['git_full_url', 'build_command']);
|
||||
});
|
||||
|
||||
test('audit changes exclude database configuration credentials on create and delete', function () {
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
$database = StandaloneRedis::query()->create([
|
||||
'name' => 'Redis',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_type' => Server::class,
|
||||
'destination_id' => 0,
|
||||
'redis_conf' => 'requirepass embedded-redis-password',
|
||||
]);
|
||||
$database->delete();
|
||||
|
||||
$events = AuditEvent::query()->orderBy('id')->get();
|
||||
|
||||
expect($events)->toHaveCount(2);
|
||||
foreach ($events as $event) {
|
||||
expect($event->changes)->toHaveKey('name')->not->toHaveKey('redis_conf');
|
||||
}
|
||||
});
|
||||
|
||||
test('audit metadata redacts nested raw configuration credentials', function () {
|
||||
auditLog('ui.server.updated', [
|
||||
'team_id' => $this->team->id,
|
||||
'configuration' => ['last_saved_proxy_configuration' => 'PASSWORD=must-not-be-recorded'],
|
||||
]);
|
||||
|
||||
expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([
|
||||
'last_saved_proxy_configuration' => '[REDACTED]',
|
||||
]);
|
||||
});
|
||||
|
||||
test('audit changes keep model default hidden fields excluded after makeVisible', function () {
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create(['environment_id' => $environment->id]);
|
||||
AuditEvent::query()->delete();
|
||||
$application->makeVisible(['domain_dns_statuses']);
|
||||
|
||||
$application->update([
|
||||
'name' => 'Updated application',
|
||||
'domain_dns_statuses' => ['example.com' => 'private DNS data'],
|
||||
]);
|
||||
|
||||
expect(AuditEvent::query()->sole()->changes)->not->toHaveKey('domain_dns_statuses');
|
||||
});
|
||||
|
||||
test('audit changes redact credentials embedded in nested proxy configuration', function () {
|
||||
Server::flushIdentityMap();
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=old-secret'];
|
||||
$server->save();
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
$server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=new-secret'];
|
||||
$server->save();
|
||||
|
||||
expect(AuditEvent::query()->sole()->changes['proxy'])->toBe([
|
||||
'old' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'],
|
||||
'new' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('audit metadata redacts secrets in serializable objects', function (Closure $configuration) {
|
||||
auditLog('ui.server.updated', [
|
||||
'team_id' => $this->team->id,
|
||||
'configuration' => $configuration(),
|
||||
]);
|
||||
|
||||
expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([
|
||||
'enabled' => true,
|
||||
'nested' => ['password' => '[REDACTED]'],
|
||||
]);
|
||||
})->with([
|
||||
'plain object' => fn () => (object) [
|
||||
'enabled' => true,
|
||||
'nested' => (object) ['password' => 'must-not-be-recorded'],
|
||||
],
|
||||
'JSON serializable object' => fn () => new class implements JsonSerializable
|
||||
{
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return ['enabled' => true, 'nested' => ['password' => 'must-not-be-recorded']];
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
test('audit change preparation failures do not fail model writes', function () {
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
AuditEvent::query()->delete();
|
||||
$project->mergeCasts(['description' => FailingAuditAttributeCast::class]);
|
||||
|
||||
$project->update(['description' => 'Persist this description']);
|
||||
|
||||
expect(Project::query()->findOrFail($project->id)->description)->toBe('Persist this description')
|
||||
->and(AuditEvent::query()->sole()->changes)->toBe([]);
|
||||
|
||||
Log::shouldHaveReceived('warning')->once()->with(
|
||||
'Audit change preparation failed',
|
||||
Mockery::on(fn (array $context): bool => $context['action'] === 'updated'
|
||||
&& $context['exception'] === RuntimeException::class
|
||||
&& array_keys($context) === ['resource_type', 'action', 'exception']),
|
||||
);
|
||||
});
|
||||
|
||||
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 +860,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 +926,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 +952,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 +969,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 +980,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