---
title: "Audit log"
description: "Who did what, to what, when, and from where."
url: "https://saved.sh/docs/security/audit-log"
---

The 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 [#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 [#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 secrets
```

Two 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 [#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 [#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](/docs/backups/runs) 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.

<Callout>
  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.
</Callout>

## Secrets are never recorded [#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 [#reading-it]

The dashboard offers filtering under **Settings → Audit log**. The same filters are query
parameters on the API:

```bash
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 [#limits-worth-knowing]

<Callout type="warn">
  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.
</Callout>

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.

## Next [#next]

<Cards>
  <Card href="/docs/dashboard/audit-log" title="Audit log in the dashboard" description="The same trail, with filters." />

  <Card href="/docs/security/permissions" title="Permissions" description="Who can read the trail, and who can do the things it records." />

  <Card href="/docs/api/errors" title="API" description="Querying it programmatically." />
</Cards>
