feat(observability): add structured audit log channel for API and webhook events

Introduce a dedicated `audit` log channel (daily rotation, configurable retention via
LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to
record state-changing API operations and webhook events.

Instrumented:

- API mutation endpoints (create / update / delete / start / stop / restart) across
  applications, services, databases (incl. backups, env vars, storage), servers,
  projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider
  tokens, Hetzner server provisioning, instance enable/disable.
- Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and
  Stripe, plus the Sentinel push endpoint.
- Authentication and authorization outcomes via the global exception handler and
  the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied).

The helper is wrapped in try/catch so logging failures never affect the request
path. Successful operations log at `info`; suspicious/denied requests log at
`warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`.

Includes a feature test suite covering the helper, the webhook providers and the
new auth/authorization log paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-04-28 14:50:37 +02:00
co-authored by Claude Opus 4.7
parent 923525a310
commit a2096c6f68
25 changed files with 1438 additions and 42 deletions
@@ -596,6 +596,14 @@ class DatabasesController extends Controller
StopDatabaseProxy::dispatch($database);
}
auditLog('api.database.updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'message' => 'Database updated.',
]);
@@ -826,6 +834,15 @@ class DatabasesController extends Controller
dispatch(new DatabaseBackupJob($backupConfig));
}
auditLog('api.database.backup_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $backupConfig->uuid,
'frequency' => $backupConfig->frequency,
'save_s3' => (bool) $backupConfig->save_s3,
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@@ -1045,6 +1062,14 @@ class DatabasesController extends Controller
dispatch(new DatabaseBackupJob($backupConfig));
}
auditLog('api.database.backup_updated', [
'team_id' => $teamId,
'backup_uuid' => $backupConfig->uuid,
'database_id' => $backupConfig->database_id,
'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([
'message' => 'Database backup configuration updated',
]);
@@ -1779,6 +1804,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
@@ -1838,6 +1873,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
@@ -1897,6 +1942,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
@@ -1953,6 +2008,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
@@ -2039,6 +2104,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
@@ -2075,6 +2150,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
@@ -2133,6 +2218,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@@ -2217,6 +2312,13 @@ class DatabasesController extends Controller
dockerCleanup: $request->boolean('docker_cleanup', true)
);
auditLog('api.database.deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json([
'message' => 'Database deletion request queued.',
]);
@@ -2329,6 +2431,14 @@ class DatabasesController extends Controller
$backup->delete();
DB::commit();
auditLog('api.database.backup_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'delete_s3' => $deleteS3,
'executions_deleted' => $executions->count(),
]);
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
@@ -2451,6 +2561,14 @@ class DatabasesController extends Controller
$execution->delete();
auditLog('api.database.backup_execution_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'execution_uuid' => $request->execution_uuid,
'delete_s3' => $deleteS3,
]);
return response()->json([
'message' => 'Backup execution deleted.',
]);
@@ -2633,6 +2751,13 @@ class DatabasesController extends Controller
}
StartDatabase::dispatch($database);
auditLog('api.database.started', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json(
[
'message' => 'Database starting request queued.',
@@ -2724,6 +2849,14 @@ class DatabasesController extends Controller
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
auditLog('api.database.stopped', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'docker_cleanup' => $dockerCleanup,
]);
return response()->json(
[
'message' => 'Database stopping request queued.',
@@ -2801,6 +2934,13 @@ class DatabasesController extends Controller
RestartDatabase::dispatch($database);
auditLog('api.database.restarted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json(
[
'message' => 'Database restarting request queued.',
@@ -3017,6 +3157,13 @@ class DatabasesController extends Controller
}
$env->save();
auditLog('api.database.env_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3145,6 +3292,12 @@ class DatabasesController extends Controller
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
auditLog('api.database.env_bulk_upserted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_count' => $updatedEnvs->count(),
]);
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -3266,6 +3419,13 @@ class DatabasesController extends Controller
'comment' => $request->comment ?? null,
]);
auditLog('api.database.env_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3351,8 +3511,17 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$envKey = $env->key;
$envUuid = $env->uuid;
$env->forceDelete();
auditLog('api.database.env_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -3599,6 +3768,15 @@ class DatabasesController extends Controller
]);
}
auditLog('api.database.storage_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
}
@@ -3797,6 +3975,15 @@ class DatabasesController extends Controller
$storage->save();
auditLog('api.database.storage_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
}
@@ -3870,8 +4057,18 @@ class DatabasesController extends Controller
$storage->deleteStorageOnServer();
}
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete();
auditLog('api.database.storage_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']);
}
}