These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Backend — audit logging

Every mutating request against the authenticated API is logged to audit_logs. The frontend cooperates by sending X-Audit-* headers; the RecordAuditLog middleware writes a row containing request metadata, a sanitized request body, a sanitized response body, timing, and error details.

For the frontend side of this contract (how and when X-Audit-* headers are set), see Next.js → Backend integration.


RecordAuditLog middleware

File: app/Http/Middleware/RecordAuditLog.php
Alias: audit — registered in bootstrap/app.php:24

When it runs

The audit middleware is applied to all three authenticated route groups in routes/api.php:34,53,220 — covering essentially every protected endpoint. It is not applied to public routes (POST /register, POST /login, POST /webhooks/paddle, GET /ext/finders/details/{key}).

Which requests are logged

Only mutating HTTP methods are persisted: POST, PUT, PATCH, DELETERecordAuditLog.php:43-45. GET and HEAD requests pass through without writing a row.

Request lifecycle

request enters → shouldAudit() check → generate UUID → run $next() → persistAuditLog() in finally block

RecordAuditLog.php:20-41:

  1. shouldAudit() returns false for read-only methods → middleware is a no-op.
  2. A UUID is generated with Str::uuid() and stored as audit_request_id on the request attributes — RecordAuditLog.php:26-27. This becomes the request_id column value (see column table below).
  3. The middleware calls $next($request) inside a try/catch(\Throwable) block — RecordAuditLog.php:32-40. Exceptions are re-thrown after the audit row is written in the finally block.
  4. persistAuditLog() is called in the finally block, so the row is written even if the controller throws — RecordAuditLog.php:39.
  5. If persistAuditLog() itself throws, the error is logged at warning level and swallowed — RecordAuditLog.php:80-85. The audit log never breaks the request.

AuditLog model

File: app/Models/AuditLog.php
Table: audit_logs
$timestamps = false — the model manually sets created_atAuditLog.php:9.

Column reference

ColumnTypeSourceNotes
user_idint (nullable)$request->user()?->idRecordAuditLog.php:60
workspace_idint (nullable)AuditLogSanitizer::inferWorkspaceId()RecordAuditLog.php:61
actionstringX-Audit-Action header, or "{METHOD} {uri}" fallbackRecordAuditLog.php:62
resource_typestring (nullable)AuditLogSanitizer::inferResourceType()RecordAuditLog.php:63
resource_idstring (nullable)AuditLogSanitizer::inferResourceId()RecordAuditLog.php:64
request_idstring (UUID)Server-generated Str::uuid()RecordAuditLog.php:65
methodstring$request->method()RecordAuditLog.php:66
routestring$request->route()?->uri() or $request->path()RecordAuditLog.php:67
ip_addressstring$request->ip()RecordAuditLog.php:68
user_agentstring (nullable)$request->userAgent()RecordAuditLog.php:69
request_dataJSONAuditLogSanitizer::summarizeRequest()RecordAuditLog.php:70; cast to arrayAuditLog.php:32
response_dataJSON (nullable)AuditLogSanitizer::summarizeResponse()RecordAuditLog.php:71; cast to arrayAuditLog.php:33
status_codeintHTTP status, or exception code on throwRecordAuditLog.php:57
statusstring'success' (< 400) or 'failure' (>= 400)RecordAuditLog.php:73
duration_msintround((microtime(true) - $startedAt) * 1000)RecordAuditLog.php:74
error_messagestring (nullable)Set only when status_code >= 400RecordAuditLog.php:75-77
created_atdatetimenow()RecordAuditLog.php:78

💡 Tip: The request_id column is the server-generated UUID — it is not the X-Audit-Request-Id header value sent by the frontend. The frontend UUID is not stored in the current schema; it appears only in the frontend audit header. If you need to correlate a frontend trace with a backend row, match on action + user_id + created_at range.

Relationships

  • AuditLog::user()belongsTo(User::class)AuditLog.php:37
  • AuditLog::workspace()belongsTo(Workspace::class)AuditLog.php:42

AuditLogSanitizer

File: app/Services/AuditLogSanitizer.php
Injected into RecordAuditLog via constructor — RecordAuditLog.php:15-18.

Workspace inference (inferWorkspaceId)

AuditLogSanitizer.php:33-70 — resolution order:

  1. workspace_id request body/query param (integer, > 0).
  2. Walk route-model-binding parameters: Workspace → direct id; Processprocess->workspace_id; Tag/Finder/Location/Map/Set → first associated workspace.
  3. Returns null if no workspace is found.

Resource type inference (inferResourceType)

AuditLogSanitizer.php:72-105 — walks route parameters and maps model class to string: Workspace → 'workspace', Process → 'process', Tag → 'tag', Finder → 'finder', Location → 'location', Map → 'map', Set → 'set'. Returns the first match, or null.

Resource ID inference (inferResourceId)

AuditLogSanitizer.php:107-116 — returns (string) $parameter->id for the first route parameter that has an id property.

Request summarization (summarizeRequest)

AuditLogSanitizer.php:118-164 — produces the request_data JSON:

  • File uploads — records filename, size, and MIME type; redacts file keys from the scalar input.
  • Password change on PUT /me — replaces the full body with {name, email, password_changed: true}.
  • Invite endpoint — reduces to {email, user_id}.
  • Finder sync endpoint — reduces to {finder_key_suffix: "****xyz123"} (last 6 chars unmasked).
  • All other requests — runs sanitizeValue() on the full input.

Response summarization (summarizeResponse)

AuditLogSanitizer.php:166-213 — produces the response_data JSON:

  • StreamedResponse / BinaryFileResponse{type: 'stream'}.
  • HTTP 204 → {type: 'no_content'}.
  • Finder sync endpoint → records hash, published_at, cdn_synced only.
  • Invite endpoint → records email, user_id, expires_at only.
  • AI endpoints (ai-location-imports, ai-location-improvements) → records id, status, process_id, batch_id, related_id, summary only.
  • All other responses → sanitizeValue() applied to the full JSON payload.

Key sanitization (sanitizeValue)

AuditLogSanitizer.php:243-269 — redacts any key whose normalized name matches the SENSITIVE_KEYS list or contains secret, password, token, authorization, api_key, apikey, or starts with paddle_. String values longer than 4000 characters are truncated with AuditLogSanitizer.php:264.

Sensitive key list — AuditLogSanitizer.php:23-30: password, password_confirmation, current_password, token, authorization, invite_url.


Admin access to audit logs

routes/api.php:168-169 — admin-only routes:

  • GET /admin/audit-logs — paginated list
  • GET /admin/audit-logs/{auditLog} — single record detail

Both are handled by AdminConsoleController and require role:admin.


Retention policy

🔴 [NEEDS CLARIFICATION: No audit log cleanup job or scheduled prune was found in routes/console.php or app/Console/. The audit_logs table grows unbounded. Retention policy (soft/hard delete schedule, partition strategy, or archiving plan) is not yet defined.]