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
parent 923525a310
commit a2096c6f68
25 changed files with 1438 additions and 42 deletions
+39
View File
@@ -82,6 +82,12 @@ class Github extends Controller
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -92,6 +98,12 @@ class Github extends Controller
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
auditLogWebhookFailure('github', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -131,6 +143,15 @@ class Github extends Controller
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -224,6 +245,13 @@ class Github extends Controller
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
}
@@ -311,6 +339,17 @@ class Github extends Controller
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'app',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'github_app_id' => $github_app->id,
]);
}
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],