---
title: "Permissions"
description: "The permission, not the role, is what the backend enforces."
url: "https://saved.sh/docs/security/permissions"
---

Authorization reduces to one question on every request: &#x2A;*is the required permission in this
principal's permission set?**

Roles exist, but only as a way of bundling permissions onto a membership. The backend reads
the `permissions` claim and &#x2A;*never reads `role`**. That means a custom role you invent is
enforced correctly with no change on our side, because we never knew about roles in the first
place.

## The catalog [#the-catalog]

27 permissions, in ten families. This list is the whole surface.

| Family           | Permissions                                                                                         |
| ---------------- | --------------------------------------------------------------------------------------------------- |
| **Workspaces**   | `workspaces:read`, `workspaces:write`                                                               |
| **Members**      | `members:read`, `members:write`, `members:invite`                                                   |
| **Roles**        | `roles:read`, `roles:write`                                                                         |
| **Workers**      | `workers:read`, `workers:write`, `workers:revoke`                                                   |
| **API keys**     | `api-keys:read`, `api-keys:write`, `api-keys:revoke`                                                |
| **Billing**      | `billing:read`, `billing:write`                                                                     |
| **Backups**      | `backups:read`, `backups:write`                                                                     |
| **Destinations** | `destinations:read`, `destinations:write`                                                           |
| **Connections**  | `connections:read`, `connections:write`                                                             |
| **Artifacts**    | `artifacts:read`, `artifacts:upload`, `artifacts:confirm`, `artifacts:download`, `artifacts:delete` |
| **Audit**        | `audit:read`                                                                                        |

You can read the live list from the API rather than trusting this page:

```bash
curl -fsS https://api.saved.sh/v1/permissions -H "Authorization: Bearer $TOKEN"
```

### Why backups and artifacts are separate families [#why-backups-and-artifacts-are-separate-families]

Because "can configure what gets backed up" and "can download the data" are different powers,
and a great many people should have the first without the second.

`backups:write` lets someone change a schedule or add a source. `artifacts:download` lets
them walk out with the contents. Splitting the families is what makes a read-only operator
role, or a CI key that can trigger but not exfiltrate, expressible at all.

### Artifacts are split five ways for the same reason [#artifacts-are-split-five-ways-for-the-same-reason]

| Permission           | Power                                                      |
| -------------------- | ---------------------------------------------------------- |
| `artifacts:read`     | See that an artifact exists, its size, checksum and key ID |
| `artifacts:upload`   | Request a presigned upload URL for a run                   |
| `artifacts:confirm`  | Declare an upload durably stored                           |
| `artifacts:download` | Get the bytes                                              |
| `artifacts:delete`   | Remove one                                                 |

A worker needs the middle two and must not have the others. A monitoring integration needs
the first and nothing else. Collapsing these into `artifacts:write` would make both of those
impossible.

## Roles [#roles]

| Role         | Carries                         |
| ------------ | ------------------------------- |
| `admin`      | Every permission in the catalog |
| `member`     | **Nothing**, by default         |
| Custom roles | Any subset you choose           |

<Callout type="warn">
  **A bare `member` has no access at all.** That is deliberate: a new member is not
  accidentally granted anything by existing. Assign them `admin` or a custom role, or they
  will see an empty workspace and wonder why.
</Callout>

The workspace creator is assigned `admin`. Custom roles are created in the dashboard, are
self-serve, and need no deploy from us: a membership may hold several roles, and the effective
permission set is their union.

## Machine limits [#machine-limits]

Some permissions are refused on machine credentials no matter what you ask for.

### Human-only [#human-only]

| Permission                                           | Why a machine may not hold it                                                                                                           |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `workers:read`, `workers:write`, `workers:revoke`    | A compromised key could mint further worker credentials                                                                                 |
| `api-keys:read`, `api-keys:write`, `api-keys:revoke` | One leaked key could mint more, and revoking the original would not revoke what it made                                                 |
| `billing:read`, `billing:write`                      | The workspace's cards                                                                                                                   |
| `destinations:write`                                 | Linking a bucket hands over a long-lived key pair, and `skip_permanent` pointed at an attacker's bucket redirects every future artifact |
| `connections:write`                                  | The OAuth flow ends at a browser consent screen. A machine could only start a flow it cannot finish                                     |

`destinations:read` and `connections:read` return metadata only, never a credential or token,
and remain available to machine keys so automation can render or reconcile a delivery plan.

<Callout>
  This is enforced at **two layers**, not one. A human-only slug is rejected when a key is
  created, and stripped from the principal on every validation. The second layer exists because
  creation-time validation cannot reach a key minted before the gate existed, or edited
  directly in the identity provider.
</Callout>

### Worker-only [#worker-only]

`artifacts:confirm` is refused on an API key. Confirming an upload is a worker's own signal
about its own work: an admin can trigger a backup, but never confirms one. The orchestration
hub also **requires** this permission to admit a worker at all, which makes it the
worker/automation discriminator.

### The two-permission worker key [#the-two-permission-worker-key]

A worker key carries exactly `artifacts:upload` and `artifacts:confirm`. It cannot read
backup definitions, list artifacts, download anything, or manage workers, including itself.

That is the whole reason a worker is safe to run on a machine you would not trust with an API
key.

## Designing roles [#designing-roles]

Some shapes worth copying.

| Role                         | Permissions                                                       | For                                            |
| ---------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
| **Read-only auditor**        | `workspaces:read`, `backups:read`, `artifacts:read`, `audit:read` | Compliance review with no ability to read data |
| **Operator**                 | The above plus `backups:write`, `workers:read`                    | Runs the backups, cannot exfiltrate            |
| **Restorer**                 | `backups:read`, `artifacts:read`, `artifacts:download`            | Can actually recover, and nothing else         |
| **CI key** (machine)         | `backups:read`, `backups:write`                                   | Applies definitions from a pipeline            |
| **Monitoring key** (machine) | `backups:read`, `artifacts:read`                                  | Watches without touching                       |

The distinction between the first three is the one worth internalising: **reading about a
backup and reading its contents are different permissions**, and most people only need the
first.

## Enforcement [#enforcement]

Every workspace-scoped route declares the permission it requires. A request with a valid
credential and the wrong permission gets `403`, not `404`, so it is clear whether the problem
is identity or authorization.

Two gates are not permissions, because no permission could express them:

* **Creating a workspace** requires a human principal. A brand-new user has no workspace and
  therefore no workspace-scoped claim to check.
* **Tenancy** is a property of the credential rather than a permission, so there is no
  permission that would let one workspace read another.

## Next [#next]

<Cards>
  <Card href="/docs/security/api-keys" title="API keys" description="Applying a subset of this catalog to a machine." />

  <Card href="/docs/dashboard/roles" title="Roles" description="Creating custom roles in the dashboard." />

  <Card href="/docs/security/audit-log" title="Audit log" description="Recording who used which power." />
</Cards>
