Audit log
Who did what, to what, when, and from where.
View as MarkdownThe audit trail answers one question per row: who did what, to what, when, and from where, inside one workspace. It is the record you reach for when something is missing, when someone leaves, or when an auditor asks.
Reading it needs audit:read, and there is no permission that writes to it, because
there is no endpoint that does.
It is read in the dashboard under Settings → Audit log, or over the API. There is no
sctl audit command yet.
Append-only, structurally
A trail a caller can edit is not a trail. The shape enforces that rather than documenting it:
| Guarantee | How |
|---|---|
| No edits | The event model has no setters. An event is built once and read back |
| No deletes | The storage port is Append and List, and nothing else |
| No forged writes | There is no write endpoint and no audit:write permission |
The only writer is the code performing the action being recorded. You cannot append to your own trail, and neither can a leaked key.
The event
workspace_id the tenancy boundary, and always the query key
occurred_at when
actor { type: user | machine | system, id, display }
action a dotted verb, e.g. backup.created
target { type, id, display }
context { ip, user_agent, request_id }
metadata small, flat, no secretsTwo design choices worth knowing when you read it:
Names are frozen at write time. actor.display and target.display record the email or
name as it was, rather than joining to live records. The trail must still read correctly after
the member is removed, the key revoked, or the backup deleted, and joining would make exactly
the entries that matter most read as "unknown".
actor.type tells you the kind of principal without reading names.
| Type | Means |
|---|---|
user | A person, identified by their user ID |
machine | An API key or worker, identified by its key ID, which is the handle a revoke targets |
system | Us: a sweep, or a state change with no caller |
What is recorded
The action catalog is closed on purpose. Free-form strings would make filtering useless within a month.
| Family | Actions |
|---|---|
| Backups | backup.created, backup.configured, backup.activated, backup.paused, backup.resumed, backup.triggered, backup.deleted |
| Runs and artifacts | run.triggered, artifact.download_url_issued, artifact.locked, artifact.deleted |
| Members and roles | member.invited, member.role_changed, member.removed, invitation.revoked, invitation.resent, role.created, role.updated, role.permissions_changed, role.deleted |
| Workers and keys | worker.created, worker.token_rotated, worker.deleted, api_key.created, api_key.revoked |
| Buckets and connections | destination.linked, destination.updated, destination.verified, destination.unlinked, connection.created, connection.deleted |
| Billing | payment_method.added, payment_method.default_changed, payment_method.removed, coupon.redeemed, billing.state_changed, billing.purge_due |
| Workspace | workspace.created, workspace.updated, workspace.deleted |
Two deliberate omissions
Scheduled runs are not recorded. Only run.triggered, a run someone asked for, appears. A
schedule firing has no actor, and thousands of them would bury the entries a human actually
caused. "Did last night's backup run?" is the run history's question,
not this one's.
Reads are not recorded, with one exception. artifact.download_url_issued is on the
trail because we never see the fetch itself: it goes straight from you to object storage. The
issued URL is therefore the only evidence that a copy left, which makes it the
compliance-relevant moment.
Read that exception precisely. It records that a download was authorised, not that it completed. Someone who requested a URL and never used it looks identical to someone who downloaded the artifact. It is the last moment we can observe, by design.
Secrets are never recorded
metadata carries identifiers and short scalars only: never a credential, a token, a source
payload, a presigned URL, or a fragment of one.
This rule is stricter here than anywhere else in the system, for a structural reason: the audit log is read by more people, kept longer, and exported more often than any other record we hold, so a secret that leaked into it would leak the furthest.
| Action | Recorded | Not recorded |
|---|---|---|
backup.configured | That it changed, and the source type | Host, port, database, credentials |
worker.created, worker.token_rotated | The worker ID and name | The token, ever |
api_key.created | The granted permission slugs | The key secret |
artifact.download_url_issued | The artifact and its run ID | The presigned URL |
destination.verified | The bucket and the resulting state | The S3 key pair |
payment_method.* | The card brand and last four | The card number |
Values are truncated and empty ones dropped, which makes the rule easy to hold. It is enforced by the callers, not by the store, so it is a discipline rather than a mechanism.
Reading it
The dashboard offers filtering under Settings → Audit log. The same filters are query parameters on the API:
curl -H "Authorization: Bearer $TOKEN" \
"https://api.saved.sh/v1/workspaces/$WORKSPACE_ID/audit?action=backup.deleted"| Parameter | Filters by |
|---|---|
actor | Who did it |
action | What they did, e.g. backup.deleted |
target_type | The kind of thing acted on |
target | One specific thing, by id |
cursor | The next page |
Queries worth running on a schedule rather than after an incident:
| Question | Filter |
|---|---|
| Who deleted things? | artifact.deleted, backup.deleted |
| Did anyone take a copy of the data? | artifact.download_url_issued |
| Were credentials minted or rotated? | api_key.created, worker.token_rotated |
| Did permissions change? | role.permissions_changed, member.role_changed |
| Did someone unlink a bucket? | destination.unlinked |
The second row is the one that answers "did our data leave", and it is worth reviewing even when nothing is wrong.
Limits worth knowing
The trail records our actions on your workspace. It does not record what happened inside your own systems, on your worker's host, or in your own bucket after an artifact was delivered there. It also does not record membership changes made directly in the identity provider rather than through us.
If you need audit events in a SIEM, export them and forward them; the trail is queryable through the API and is the source of truth on our side.