Audit Log
Append-only record of who did what to the platform, one entry per mutating administrative or resource action.
Overview
Every mutating (POST/PUT/PATCH/DELETE) request under /api/v1 that performs an authorization check is recorded once, post-commit, attributed to the principal (a user or API key). Denied attempts (403) are logged too.
The recorded action is the permission-action string that authorized the request (e.g. secrets:DeleteSecret); resource_srn is the SRN it was authorized against. Traces record what an agent did inside a run; the Activity feed records what agents did autonomously with no principal; the audit log records what a principal did to the platform. Comparison: Activity vs. the audit log vs. traces.
The API is read-only; writes go through a fire-and-forget queue, so auditing never blocks or fails the request. Reads are recorded only when the project opts in; see Read auditing.
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Gate a Dangerous Tool with Guardrails - Step 11 (Read the governance trail)
- Cap Spend Per End User - Step 11 (Observe before you enforce)
Data Model
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (e.g. audit_…) |
project_id | string | Owning project; null for global actions (e.g. users:CreateUser) |
principal_type | string | user or api_key; null for platform-originated entries (see System-originated entries) |
principal_id | string | Public id of the principal (user_… or key_…); null for platform-originated entries |
action | string | The permission-action string that authorized the request |
resource_srn | string | SRN the action targeted; type-level (srn:{project}:{type}:*) on creates |
resource_public_id | string | Target resource id — from the SRN, or the response body id on creates |
status | integer | HTTP status of the response (recorded post-commit) |
request_id | string | Per-request correlation id (also returned in the X-Request-Id response header) |
ip | string | Client IP |
user_agent | string | Request User-Agent |
detail | object | Kind-specific payload; see Multiple checks per request |
created_at | string | ISO 8601 creation timestamp (rows are immutable — there is no updated_at) |
Key Concepts
Request correlation (X-Request-Id)
Every response carries X-Request-Id, stored as request_id. A caller-supplied value is honored; otherwise one is generated.
Resource SRN precision
On get/update/delete, resource_srn is precise (srn:{project}:secret:sec_…) and resource_public_id its last segment. Creates authorize before the resource exists, so resource_srn is type-level (srn:{project}:secret:*) and resource_public_id comes from the response body id.
Multiple checks per request
A route making several checks (e.g. binding a trigger checks triggers:CreateTrigger and the target's start permission) produces exactly one entry:
- On success, the primary
actionis the first (route-level) check. - On a
403, the primary is the denied check.
Remaining checks are recorded under detail.additional_checks ({ action, resource, allowed } each).
System-originated entries
Events no principal authorized leave principal_type and principal_id null and are identified by action. There is no null-principal filter; filter by action (quotas:MonitorBreach, guardrails:Evaluate). Producers:
- Quota monitoring — a monitor-mode quota breach writes
action: quotas:MonitorBreach, the quota as resource,detail.kind: quota_monitor_breach(metric, window, limit, observed value); once per window, mirroring thequota.exceededwebhook. - Guardrail evaluations — an evaluation that changed the outcome (
route_to_approval,blocked,tripwire) writesaction: guardrails:Evaluate, the guardrail as resource,detail.kind: guardrail_evaluationwith the full record (governing version, resolved class, decision, guard outcome, context snapshot, provenance). Plainexecuteevaluations are not audited; they stay in the guardrails' own evaluation records. Aroute_to_approvalentry also carriesapproval_idindetail.
Read auditing
By default only mutations are recorded. A project opts into read auditing with audit_reads_enabled on the project:
- CLI
- SDK
- curl
soat update-project --project-id proj_ABC --audit-reads-enabled true
const { data, error } = await soat.projects.updateProject({
path: { project_id: 'proj_ABC' },
body: { audit_reads_enabled: true },
});
if (error) throw new Error(JSON.stringify(error));
curl -X PATCH https://api.example.com/api/v1/projects/proj_ABC \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "audit_reads_enabled": true }'
A GET then produces the same entry shape: the authorizing action (secrets:GetSecret, secrets:ListSecrets), its SRN, and the status. The flag is per-project:
- A read naming no project is never recorded. Unscoped enumeration (
GET /api/v1/secretswithoutproject_id) is not attributable to a project; passproject_idto have list reads audited. - Turning it on for one project leaves other projects' reads unrecorded.
The flag is cached in-process: a change applies immediately on the serving instance and within 30 seconds elsewhere.
Append-only & retention
Entries are never updated or deleted through the API; the model layer rejects updates and single-row deletes. A daily sweep (also run at startup) prunes rows older than the retention window (see Configuration). Archive before expiry with the NDJSON export.
NDJSON export
GET /api/v1/audit-log/export streams a project's entries as newline-delimited JSON, oldest first, same fields as the read API, for archival and shipping to a SIEM, data lake, or an LGPD/GDPR subject-access request.
project_idis required.- Every list filter (
action,principal_id,resource_public_id,resource_srn,from,to) applies identically. - The response streams and pages internally.
- Authorized by
audit:ExportAuditEntries, separate fromaudit:ListAuditEntries.
The export is not an MCP or builtin tool action (unbounded stream); tools use the paged list-audit-entries with the same filters.
audit.entry_created webhook
Every persisted project-scoped entry emits an audit.entry_created webhook event with the full entry as data, in the read API's snake_case shape. Subscribe with audit.* or the exact name:
- CLI
- SDK
- curl
soat create-webhook --project-id proj_ABC \
--url https://siem.example.com/soat --events "audit.entry_created"
const { data, error } = await soat.webhooks.createWebhook({
body: {
project_id: 'proj_ABC',
url: 'https://siem.example.com/soat',
events: ['audit.entry_created'],
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/webhooks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"project_id": "proj_ABC",
"url": "https://siem.example.com/soat",
"events": ["audit.entry_created"]
}'
Global entries (project_id null, e.g. users:CreateUser) emit nothing; platform-originated entries do, with null principal fields.
Who may act on an audit entry
Every route that acts on one audit entry is authorized against that audit entry's SRN,
srn:<project_id>:audit:<entry_id>, not against the project. A policy may therefore name the audit entries it
covers:
{
"statement": [
{
"effect": "Allow",
"action": ["audit:GetAuditEntry"],
"resource": ["srn:proj_V1StGXR8Z5jdHi6B:audit:audit_V1StGXR8Z5jdHi6B"]
}
]
}
Refusals keep the shapes IAM defines: a read the caller may not perform is 404 (an audit entry it may not see does not announce itself), a write is 403, and a credential scoped to another project is 403 API_KEY_PROJECT_SCOPE. A write on a resource in a project none of the caller's policies name is 404 too — the same answer their read would get, so a refusal never confirms existence across a tenant boundary.
Listing audit entries stays project-scoped: GET /api/v1/audit-log asks whether the caller may list audit entries in a project at all, so a policy that names individual audit entries grants no listing.
Configuration
| Environment Variable | Required | Description |
|---|---|---|
AUDIT_RETENTION_DAYS | No | Retention window in days (default 365). Rows older than this are pruned. |
AUDIT_QUEUE_MAX_SIZE | No | Max entries buffered in memory (default 1000). On overflow entries are dropped and counted. |
AUDIT_RETENTION_SWEEP_DISABLED | No | Set to true to disable the daily retention sweep. |
Examples
List audit entries
- CLI
- SDK
- curl
soat list-audit-entries --project-id proj_ABC --action secrets:DeleteSecret
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({ baseUrl: 'https://api.example.com', token: 'sk_...' });
const { data, error } = await soat.audit.listAuditEntries({
query: { project_id: 'proj_ABC', action: 'secrets:DeleteSecret' },
});
if (error) throw new Error(JSON.stringify(error));
curl -X GET "https://api.example.com/api/v1/audit-log?project_id=proj_ABC&action=secrets:DeleteSecret" \
-H "Authorization: Bearer <token>"
Export a project's entries as NDJSON
- CLI
- SDK
- curl
soat export-audit-entries --project-id proj_ABC --from 2026-01-01T00:00:00Z
const { data, error } = await soat.audit.exportAuditEntries({
query: { project_id: 'proj_ABC', from: '2026-01-01T00:00:00Z' },
});
if (error) throw new Error(JSON.stringify(error));
// `data` is the raw NDJSON body — one JSON object per line.
curl -X GET "https://api.example.com/api/v1/audit-log/export?project_id=proj_ABC" \
-H "Authorization: Bearer <token>" > audit-log.ndjson