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, DELETE — RecordAuditLog.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 blockRecordAuditLog.php:20-41:
shouldAudit()returns false for read-only methods → middleware is a no-op.- A UUID is generated with
Str::uuid()and stored asaudit_request_idon the request attributes —RecordAuditLog.php:26-27. This becomes therequest_idcolumn value (see column table below). - The middleware calls
$next($request)inside atry/catch(\Throwable)block —RecordAuditLog.php:32-40. Exceptions are re-thrown after the audit row is written in thefinallyblock. persistAuditLog()is called in thefinallyblock, so the row is written even if the controller throws —RecordAuditLog.php:39.- If
persistAuditLog()itself throws, the error is logged atwarninglevel 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_at — AuditLog.php:9.
Column reference
| Column | Type | Source | Notes |
|---|---|---|---|
user_id | int (nullable) | $request->user()?->id | RecordAuditLog.php:60 |
workspace_id | int (nullable) | AuditLogSanitizer::inferWorkspaceId() | RecordAuditLog.php:61 |
action | string | X-Audit-Action header, or "{METHOD} {uri}" fallback | RecordAuditLog.php:62 |
resource_type | string (nullable) | AuditLogSanitizer::inferResourceType() | RecordAuditLog.php:63 |
resource_id | string (nullable) | AuditLogSanitizer::inferResourceId() | RecordAuditLog.php:64 |
request_id | string (UUID) | Server-generated Str::uuid() | RecordAuditLog.php:65 |
method | string | $request->method() | RecordAuditLog.php:66 |
route | string | $request->route()?->uri() or $request->path() | RecordAuditLog.php:67 |
ip_address | string | $request->ip() | RecordAuditLog.php:68 |
user_agent | string (nullable) | $request->userAgent() | RecordAuditLog.php:69 |
request_data | JSON | AuditLogSanitizer::summarizeRequest() | RecordAuditLog.php:70; cast to array — AuditLog.php:32 |
response_data | JSON (nullable) | AuditLogSanitizer::summarizeResponse() | RecordAuditLog.php:71; cast to array — AuditLog.php:33 |
status_code | int | HTTP status, or exception code on throw | RecordAuditLog.php:57 |
status | string | 'success' (< 400) or 'failure' (>= 400) | RecordAuditLog.php:73 |
duration_ms | int | round((microtime(true) - $startedAt) * 1000) | RecordAuditLog.php:74 |
error_message | string (nullable) | Set only when status_code >= 400 | RecordAuditLog.php:75-77 |
created_at | datetime | now() | RecordAuditLog.php:78 |
💡 Tip: The
request_idcolumn is the server-generated UUID — it is not theX-Audit-Request-Idheader 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 onaction+user_id+created_atrange.
Relationships
AuditLog::user()—belongsTo(User::class)—AuditLog.php:37AuditLog::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:
workspace_idrequest body/query param (integer, > 0).- Walk route-model-binding parameters:
Workspace→ direct id;Process→process->workspace_id;Tag/Finder/Location/Map/Set→ first associated workspace. - Returns
nullif 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_syncedonly. - Invite endpoint → records
email,user_id,expires_atonly. - AI endpoints (
ai-location-imports,ai-location-improvements) → recordsid,status,process_id,batch_id,related_id,summaryonly. - 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 listGET /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.]