fix(audit): reduce audit log noise and keep instance events out of team logs

- Ignore status, heartbeat, and check fields that background checks write
  during a user request.
- Record repeated unauthenticated failures (failed logins, API auth,
  webhook signatures) once per source in a short window.
- An explicit null team_id in the context is kept, so instance events are
  not stored on the admin's current team.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-02 16:01:10 +02:00
co-authored by Claude Opus 5.5
parent f75ffd329f
commit 9862f75dc2
6 changed files with 349 additions and 6 deletions
+3 -3
View File
@@ -116,9 +116,9 @@ class AuditEvent extends Model
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);
?? (array_key_exists('team_id', $context)
? $context['team_id']
: currentTeam()?->id ?? self::teamIdFromContext($context));
$parts = explode('.', $event);
$source = $parts[0] ?? 'system';
+36 -3
View File
@@ -10,6 +10,41 @@ use Illuminate\Support\Str;
trait Auditable
{
/**
* Fields that status checks, Sentinel pushes and server checks write as a side effect.
* They can change inside a user's request (for example GetContainersStatus::run()),
* so they must not appear as changes made by that user.
*/
private const AUDIT_IGNORED_FIELDS = [
'updated_at',
'order',
// Container status and restart tracking (GetContainersStatus, PushServerUpdateJob).
'status',
'last_online_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'restart_limit_reached',
'container_present',
'started_at',
'config_hash',
'custom_healthcheck_found',
'domain_dns_statuses',
// Server heartbeat and check state (Sentinel push, ServerConnectionCheckJob, validation, proxy checks).
'sentinel_updated_at',
'sentinel_waiting_since',
'unreachable_count',
'high_disk_usage_notification_sent',
'log_drain_notification_sent',
'validation_logs',
'is_validating',
'detected_traefik_version',
'traefik_outdated_info',
'hetzner_server_status',
'vultr_instance_status',
'digitalocean_droplet_status',
];
private bool $auditLoggingEnabled = true;
public static function bootAuditable(): void
@@ -33,9 +68,7 @@ trait Auditable
$changedFields = $action === 'updated'
? collect(array_keys($this->getChanges()))
->reject(fn (string $field): bool => in_array($field, [
'updated_at',
'order',
'status',
...self::AUDIT_IGNORED_FIELDS,
...($this->auditExclude ?? []),
], true))
->values()
+55
View File
@@ -1,7 +1,9 @@
<?php
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
if (! function_exists('auditLog')) {
/**
@@ -13,6 +15,10 @@ if (! function_exists('auditLog')) {
*/
function auditLog(string $event, array $context = [], string $level = 'info'): void
{
if (! shouldRecordAuditEvent($event, $context)) {
return;
}
$level = AuditEvent::normalizeLevel($level);
try {
@@ -41,6 +47,55 @@ if (! function_exists('auditLog')) {
}
}
if (! function_exists('auditFailureDeduplicationWindow')) {
/**
* Seconds during which repeated unauthenticated failures from one source are recorded once.
*
* Failed logins use a short window so an ongoing brute force attempt stays visible
* (one row per email and source IP each minute, on top of the login rate limit).
*/
function auditFailureDeduplicationWindow(string $event): ?int
{
return match (true) {
$event === 'auth.user.login_failed' => 60,
$event === 'api.auth.unauthenticated',
preg_match('/^webhook\.[a-z0-9_-]+\.signature_failed$/i', $event) === 1 => 300,
default => null,
};
}
}
if (! function_exists('shouldRecordAuditEvent')) {
/**
* Keep only the first unauthenticated failure per event, source IP, reason, server and
* attempted email in each deduplication window. All other events are always recorded.
*
* @param array<string, mixed> $context
*/
function shouldRecordAuditEvent(string $event, array $context): bool
{
$window = auditFailureDeduplicationWindow($event);
if ($window === null) {
return true;
}
try {
$request = app()->bound('request') ? request() : null;
$fingerprint = hash('sha256', json_encode([
$event,
$request?->ip(),
data_get($context, 'reason'),
data_get($context, 'server_uuid'),
Str::lower(trim((string) data_get($context, 'attempted_email'))),
]));
return Cache::add("audit-failure-dedup:{$fingerprint}", true, $window);
} catch (Throwable) {
return true;
}
}
}
if (! function_exists('auditLogWebhookFailure')) {
/**
* Record a webhook signature/auth verification failure.
@@ -0,0 +1,95 @@
<?php
use App\Models\AuditEvent;
use App\Models\InstanceSettings;
use Illuminate\Auth\Events\Failed;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutDefer();
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
Log::spy();
});
function pushToSentinelFromIp(string $ip, ?string $token = 'review-fix-e-invalid-token'): void
{
$headers = $token === null ? [] : ['Authorization' => 'Bearer '.$token];
test()->withServerVariables(['REMOTE_ADDR' => $ip])
->postJson('/api/v1/sentinel/push', ['containers' => []], $headers)
->assertUnauthorized();
}
function sentinelFailureReasons(): array
{
return AuditEvent::query()
->where('event', 'webhook.sentinel.signature_failed')
->orderBy('id')
->get()
->map(fn (AuditEvent $event): string => $event->metadata['reason'].'@'.$event->ip_address)
->all();
}
test('a burst of failed sentinel pushes records one audit event per reason and source ip', function () {
foreach (range(1, 5) as $attempt) {
pushToSentinelFromIp('203.0.113.10');
}
pushToSentinelFromIp('203.0.113.10', token: null);
pushToSentinelFromIp('203.0.113.11');
expect(sentinelFailureReasons())->toBe([
'decrypt_failed@203.0.113.10',
'token_missing@203.0.113.10',
'decrypt_failed@203.0.113.11',
]);
});
test('a failure is recorded again after the deduplication window', function () {
pushToSentinelFromIp('203.0.113.10');
$this->travel(4)->minutes();
pushToSentinelFromIp('203.0.113.10');
$this->travel(2)->minutes();
pushToSentinelFromIp('203.0.113.10');
expect(sentinelFailureReasons())->toBe([
'decrypt_failed@203.0.113.10',
'decrypt_failed@203.0.113.10',
]);
});
test('repeated unauthenticated api requests record one audit event per source ip', function () {
foreach (range(1, 3) as $attempt) {
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])->getJson('/api/v1/projects')->assertUnauthorized();
}
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.21'])->getJson('/api/v1/projects')->assertUnauthorized();
expect(AuditEvent::query()->where('event', 'api.auth.unauthenticated')->pluck('ip_address')->all())
->toBe(['203.0.113.20', '203.0.113.21']);
});
test('failed logins record one audit event per email and source ip in each window', function () {
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.30']);
foreach (['victim@example.com', 'VICTIM@example.com', 'victim@example.com', 'other@example.com'] as $email) {
event(new Failed('web', null, ['email' => $email, 'password' => 'wrong']));
}
$this->travel(61)->seconds();
event(new Failed('web', null, ['email' => 'victim@example.com', 'password' => 'wrong']));
expect(AuditEvent::query()->where('event', 'auth.user.login_failed')->orderBy('id')->pluck('metadata')->pluck('attempted_email')->all())
->toBe(['victim@example.com', 'other@example.com', 'victim@example.com']);
});
test('events outside the unauthenticated failure set are never deduplicated', function () {
auditLog('webhook.github.deployment_queued', ['reason' => 'push']);
auditLog('webhook.github.deployment_queued', ['reason' => 'push']);
auditLog('api.auth.ability_denied', ['reason' => 'missing ability']);
auditLog('api.auth.ability_denied', ['reason' => 'missing ability']);
expect(AuditEvent::query()->count())->toBe(4);
});
@@ -0,0 +1,132 @@
<?php
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutDefer();
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
Server::flushIdentityMap();
Log::spy();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
function createAuditedResourceForHeartbeatTest(string $model, Environment $environment): Model
{
if ($model === Application::class) {
return Application::factory()->create(['environment_id' => $environment->id]);
}
$passwords = [
StandalonePostgresql::class => ['postgres_password' => 'review-fix-e-password'],
StandaloneMysql::class => ['mysql_root_password' => 'review-fix-e-password', 'mysql_password' => 'review-fix-e-password'],
StandaloneMariadb::class => ['mariadb_root_password' => 'review-fix-e-password', 'mariadb_password' => 'review-fix-e-password'],
StandaloneMongodb::class => ['mongo_initdb_root_password' => 'review-fix-e-password'],
StandaloneRedis::class => [],
StandaloneKeydb::class => ['keydb_password' => 'review-fix-e-password'],
StandaloneDragonfly::class => ['dragonfly_password' => 'review-fix-e-password'],
StandaloneClickhouse::class => ['clickhouse_admin_password' => 'review-fix-e-password'],
StandaloneSqlite::class => [],
];
return $model::create([
'uuid' => fake()->uuid(),
'name' => 'review-fix-e-database',
'status' => 'exited',
'environment_id' => $environment->id,
'destination_type' => Server::class,
'destination_id' => 0,
...$passwords[$model],
]);
}
test('container heartbeat and restart tracking writes are not audited as user changes', function (string $model) {
$resource = createAuditedResourceForHeartbeatTest($model, $this->environment);
AuditEvent::query()->delete();
$resource->update(['status' => 'running:healthy', 'last_online_at' => now()]);
$resource->update(['restart_count' => 3, 'last_restart_at' => now(), 'last_restart_type' => 'crash']);
$resource->update(['config_hash' => 'review-fix-e-hash']);
$resource->update($model === Application::class
? ['container_present' => false, 'restart_limit_reached' => true]
: ['started_at' => now()]);
expect(AuditEvent::query()->count())->toBe(0);
$resource->update(['name' => 'review-fix-e-renamed', 'restart_count' => 0]);
expect(AuditEvent::query()->sole()->metadata['changed_fields'])->toBe(['name']);
})->with([
Application::class,
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
StandaloneSqlite::class,
]);
test('application dns check results are not audited as user changes', function () {
$application = Application::factory()->create(['environment_id' => $this->environment->id]);
AuditEvent::query()->delete();
$application->update(['domain_dns_statuses' => ['https://example.com' => ['status' => 'ok']], 'custom_healthcheck_found' => true]);
expect(AuditEvent::query()->count())->toBe(0);
});
test('server heartbeat and check state writes are not audited as user changes', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
AuditEvent::query()->delete();
$server->sentinelHeartbeat();
$server->update([
'sentinel_waiting_since' => now(),
'unreachable_count' => 2,
'high_disk_usage_notification_sent' => true,
'log_drain_notification_sent' => true,
'validation_logs' => 'review-fix-e-validation',
'is_validating' => true,
'detected_traefik_version' => 'v3.1.0',
'traefik_outdated_info' => ['current' => 'v3.1.0'],
]);
expect(AuditEvent::query()->count())->toBe(0);
$server->update(['description' => 'review-fix-e-description']);
expect(AuditEvent::query()->sole()->metadata['changed_fields'])->toBe(['description']);
});
+28
View File
@@ -2,6 +2,8 @@
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Livewire\SettingsOauth;
use App\Livewire\Team\AuditLog;
use App\Models\AuditEvent;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use App\Models\Team;
@@ -235,3 +237,29 @@ it('toggles provider enabled state from the action button', function () {
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue();
});
it('stores instance authentication audit events without the admin current team', function () {
$this->withoutDefer();
$otherTeam = Team::factory()->create();
$instanceAdmin = actingAsInstanceAdmin();
$instanceAdmin->teams()->attach($otherTeam, ['role' => 'owner']);
$instanceAdmin->unsetRelation('teams');
session(['currentTeam' => $otherTeam]);
Livewire::test(SettingsOauth::class)
->set('disable_registration_when_oauth_enabled', true)
->call('saveRegistrationPolicy')
->assertHasNoErrors();
$event = AuditEvent::query()->where('event', 'ui.instance.authentication.updated')->sole();
expect($event->team_id)->toBeNull();
$otherTeamAdmin = User::factory()->create();
$otherTeam->members()->attach($otherTeamAdmin, ['role' => 'admin']);
$this->actingAs($otherTeamAdmin);
session(['currentTeam' => $otherTeam]);
Livewire::test(AuditLog::class)
->assertDontSee($instanceAdmin->email)
->assertDontSee($event->description);
});