# Build a custom role with the Access API

This page covers the patterns you'll use to build, assign, and verify custom roles via the Access API. If you're new to roles, read [the concepts page](https://www.sanity.io/docs/content-lake/roles-concepts) first.

## Before you start

*This is a paid feature, available on the Enterprise plan.*

You'll need:

- A Sanity project and a token that can call the Access API. An admin user token (from `sanity login`) works; a project- or organization-scoped robot token also works for most operations.
- Comfort making authenticated HTTP requests with curl or a similar client, and reading and writing JSON request bodies.

The examples on this page use [API version](https://www.sanity.io/docs/content-lake/api-versioning) `v2026-07-11`. Pin to a version that suits your project's needs.

## The `action` and `params.mode` pattern

Every recipe on this page relies on one pattern that surprises most authors the first time they see it. Read this section before you write any role JSON.

Both `sanity.document.filter` and `sanity.document.filter.mode` are GROQ-filter-backed types: they scope a permission to documents matching a `config.filter` predicate. They differ in how the action is expressed. For `sanity.document.filter.mode`, the outer `action` field is always the literal string `"mode"`. The semantic action you actually care about (`read`, `create`, or `publish`) goes inside `params.mode`.

```json
{
  "name": "all-docs",
  "action": "mode",
  "params": { "mode": "publish" }
}
```

This shape is what enables dataset and history scoping via additional `params` fields:

```json
{
  "name": "all-docs",
  "action": "mode",
  "params": { "mode": "publish", "dataset": "staging" }
}
```

For permissions of type `sanity.document.filter` (the non-`.mode` variant) and for project-level types like `sanity.project.datasets`, the `action` field carries the semantic action directly: `read`, `create`, `update`. Available actions vary by type: project-level types like `sanity.project.datasets` also include `delete`, while `sanity.document.filter` has no `delete` action (its `update` grant covers delete). No `params` block needed.

```json
{ "name": "sanity-project-datasets", "action": "read" }
```

When you read role JSON, ask yourself: is this a `.mode` permission? If yes, the `action` is `"mode"` and the real action is in `params.mode`. If no, the `action` is the action.

### Choosing between the two types

The recipes on this page use both. Pick based on what scoping you need:

- **sanity.document.filter** when a GROQ filter alone is enough to scope the permission. Best for simple type-matching filters (Recipe: [Content-type-restricted editor](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api): "edit articles only"), release-action filtering (Recipe: [Release reviewer](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api): "schedule but not publish"), and any case where you don't need dataset or history scoping.
- **sanity.document.filter.mode** when you need first-class `params.dataset` for dataset scoping (Recipes: [Read-only analyst](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api), [Single-dataset editor](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api)), `params.history` for history scoping, or the canonical `mode: "publish"` shape for expressing publish authority (Recipe: [Attribute-based editing](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api)).

If you're not sure, start from the closest recipe match above.

**Avoid mixing them in the same role without understanding the dataset-bypass gotcha:** the `params.dataset` scope on a `sanity.document.filter.mode` grant doesn't constrain sibling `sanity.document.filter` grants in the same role; those escape dataset scope and apply to all datasets. If you need dataset scoping, use `sanity.document.filter.mode` for all the document-plane permissions in that role.

## The four-step flow

Building a custom role follows four steps in order. The example below creates an "article-editor" role: a role that can update and create documents of type `article`, and read articles.

### Step 1: Create a custom permission

Custom permissions are scoped to a resource (organization or project) and addressed by their `name`. The `name` is the unique identifier within the resource scope.

```bash
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "articles-only",
    "title": "Articles only",
    "description": "Only allow access to articles",
    "type": "sanity.document.filter",
    "config": { "filter": "_type == \"article\"" }
  }'
```

The `type` field references the predefined permission type that this custom permission specializes. The `config.filter` field holds a GROQ predicate that narrows the permission to documents matching the filter.

### Step 2: Create a role that uses the permission

A role bundles permissions with two flags (`appliesToUsers` and `appliesToRobots`) controlling who the role can be assigned to.

```bash
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "article-editor",
    "title": "Article editor",
    "description": "Can edit and manage articles; reads articles",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      { "name": "articles-only",          "action": "update" },
      { "name": "articles-only",          "action": "create" },
      { "name": "articles-only",          "action": "read" }
    ]
  }'
```

The first three entries (`sanity-project`, `sanity-project-members`, `sanity-project-roles`) are predefined permissions that almost every role needs: the user has to be able to read basic project metadata for the API client to initialize. The last three entries grant `update`, `create`, and `read` actions on the custom `articles-only` permission you defined in Step 1. (Note that on `sanity.document.filter`, the `update` action allows both update and delete on matching documents; see [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).)

### Step 3: Assign the role to a user

```bash
curl -X PUT "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/users/$SANITY_USER_ID/roles/article-editor" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
```

`$SANITY_USER_ID` is the target user's Sanity user ID (not their email). You can look it up with `GET /v2026-07-11/access/project/$PROJECT_ID/users`.

### Step 4: Teardown (in reverse order)

When you tear down a role, the API enforces one part of the cleanup order and silently rewrites the rest. Remove the assignments before deleting the role; clean up the permissions last to avoid silently degrading other roles.

```bash
# 1. Unassign the role from each user
curl -X DELETE "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/users/$SANITY_USER_ID/roles/article-editor" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# 2. Delete the role
curl -X DELETE "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles/article-editor" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# 3. Delete the custom permission (only after confirming no other roles depend on it; see warning below)
curl -X DELETE "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions/articles-only" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
```

### Cleanup behavior: what the API enforces vs. what it allows

- **Required:** Remove role assignments before deleting a role. The API rejects role deletion while the role is assigned to any user; the role needs to be removed from users first.
- **Allowed but discouraged:** The API permits permission deletion even when roles still reference the permission. When this happens, the API silently strips the dangling entries from each affected role's `permissions[]` array. No error and no notification; the activity log records the permission deletion itself, but not which roles lost grants. The role and its user assignments remain, but the role's effective permission set narrows.

**Why this matters:** if a role's only document-scoping grant comes from a custom permission that gets deleted, users assigned to that role can lose document-plane access without any explicit signal. The only way to detect it after the fact is to `GET` the role and compare its `permissions[]` array to what you expected.

**Recommended teardown order:**

1. Remove the role's assignments from each user (required).
2. Delete the role (now allowed; will reject if any assignment remains).
3. Delete the custom permission only after confirming no other roles reference it. If other roles do reference it, decide whether to update those roles first (replace the reference) or accept the silent narrowing.

If you're deleting a permission as part of a refactor rather than a teardown (the permission stays in use but you're restructuring), follow the same step 3: update affected roles before deleting the permission.

### Verify the role applied

After Step 3, confirm the role took effect by calling `/user-permissions/me/check` as the assigned user:

```bash
curl "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/user-permissions/me/check?permissions=sanity.document.filter.update&permissions=sanity.project.members.invite" \
  -H "Authorization: Bearer $USER_TOKEN"
```

```json
{
  "data": {
    "sanity.document.filter.update": true,
    "sanity.project.members.invite": false
  }
}
```

For deeper testing patterns, including a CI integration sketch, see the [verification section](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api) below.

## Recipes

Each recipe below follows the same shape: persona, mechanism, payloads, verification, caveats. Every recipe targets `resourceType=project` unless noted; adapt the `resourceId` and the URL path for organization-scoped roles.

### CI/CD robot: minimum-permission deploy token

**Persona:** A CI pipeline that deploys the Studio on every main-branch merge. It must not be able to mutate content or manage users.

> [!NOTE]
> **Robot tokens work on Access API operations.** Most Access API endpoints accept a project- or organization-scoped robot token, including the four-step flow above and `/user-permissions/me*` (which returns the robot's effective permissions when queried with a robot token). The exception is `/users/me`, which surfaces user-identity state and requires a user session. If you've seen a "user session required" 401 from a robot-token call, it's coming from `/users/me`, not from the Access API in general. Robot tokens authenticate the CI flow described in this recipe end to end.

**Capabilities needed:**

- `sanity deploy` (the Studio) needs `sanity.project:deployStudio`.
- `sanity dataset copy` (optional, for snapshot-based deploys) needs `sanity.project.datasets:{read, create}`.

**Mechanism:** Define a robot-only role (`appliesToUsers: false`, `appliesToRobots: true`) with only deploy-relevant permissions. Create a robot under the role and use its token for CI.

**Anti-pattern to avoid:** Assigning the built-in `administrator` role to a CI robot because it's faster to set up. Any pipeline compromise becomes a full project takeover, including members and tokens. Custom roles exist for exactly this reason.

**Payloads:**

```bash
# Step 1: Create the role (no custom permissions needed; all are predefined).
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "ci-deploy",
    "title": "CI/CD deploy",
    "description": "Minimum permissions for Studio deploy pipelines",
    "appliesToUsers": false,
    "appliesToRobots": true,
    "permissions": [
      { "name": "sanity-project",          "action": "deployStudio" },
      { "name": "sanity-project-datasets", "action": "read" },
      { "name": "sanity-project-datasets", "action": "create" }
    ]
  }'

# Step 2: Create a robot under this role.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/robots" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "GitHub Actions deploy",
    "memberships": [
      {
        "resourceType": "project",
        "resourceId": "'"$PROJECT_ID"'",
        "roleNames": ["ci-deploy"]
      }
    ]
  }'
# The response includes the robot's bearer token. Store it as a repository secret.
# To set an explicit token expiry (recommended for CI), include "expiresAt": "<ISO-8601 timestamp>".
```

**Verification:**

```bash
# Verify the robot's permissions directly with /user-permissions/me/check
# (robot tokens work fine on this endpoint), or attempt the gated action.
curl "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/user-permissions/me/check?permissions=sanity.project.deployStudio" \
  -H "Authorization: Bearer $ROBOT_TOKEN"
# Expected: { "data": { "sanity.project.deployStudio": true } }
```

**Caveats:**

- `sanity deploy` from the CLI authenticates with a user token. Set the `SANITY_AUTH_TOKEN` environment variable to override the user token.
- Granting `create` on `sanity-project-datasets` is broader than "copy into an existing dataset." If snapshotting isn't needed, drop it.

### Release reviewer: schedule but not publish

**Persona:** A content-team member who prepares releases and schedules go-live times, but requires a senior editor's approval before the actual publication.

**Mechanism:** Release actions decompose into permission checks against synthetic document IDs of the form `_.releases.<releaseId>.actions.<action>`. Granting `update` on a filter that matches a subset of these IDs authorizes specific release actions. Withhold the filter match for `_.releases.*.actions.publish` and the user can't publish. (The `update` action here authorizes release actions like schedule and unschedule; it does not interact with the data-plane document mutation gate where the "update grants delete" mechanic applies. See [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).)

**Permissions granted:** schedule, unschedule, edit, archive, unarchive. **Permissions withheld:** publish, delete.

**Payloads:**

```bash
# Step 1: Create the filter permission.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "release-reviewer-scope",
    "title": "Release reviewer scope",
    "description": "Allows schedule, unschedule, edit, archive, and unarchive on releases; withholds publish and delete",
    "type": "sanity.document.filter",
    "config": {
      "filter": "_id in path(\"_.releases.*.actions.schedule\") || _id in path(\"_.releases.*.actions.unschedule\") || _id in path(\"_.releases.*.actions.edit\") || _id in path(\"_.releases.*.actions.archive\") || _id in path(\"_.releases.*.actions.unarchive\")"
    }
  }'

# Step 2: Create the role bundling release scoping with baseline read access.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "release-reviewer",
    "title": "Release reviewer",
    "description": "Can schedule releases but not publish them",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      { "name": "release-reviewer-scope", "action": "update" }
    ]
  }'

# Step 3: Assign to the user as in the four-step flow above.
```

**Verification:** This role is best validated end to end. Assign the role to a test user, then attempt both `schedule` and `publish` actions: schedule should succeed; publish should return a 403 from the release dry-run check.

**Caveats:**

- The filter string exposes synthetic document ID paths. These are stable today but may be replaced by a higher-level abstraction in a future release.
- This role grants only release-action authority. It doesn't include the document-editing permissions needed to change release contents. Combine it with an editor role if the reviewer should also edit release drafts.

### Read-only analyst: dataset-scoped

**Persona:** A BI analyst who queries production data via the API from an external tool. The role must never mutate.

**Mechanism:** Grant read-only access on documents plus the minimum project metadata. Deny everything else by omission. The role applies to robots so the analyst's tool can use a scoped token.

**Payloads:**

```bash
# Step 1: Create the read-scope filter (optional; narrows read to the production dataset).
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "analyst-read-scope",
    "title": "Analyst read scope",
    "description": "Read-only permission for analysts",
    "type": "sanity.document.filter.mode",
    "config": { "filter": "true" }
  }'

# Step 2: Create the role.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "read-only-analyst",
    "title": "Read-only analyst",
    "description": "Query production data; no mutations",
    "appliesToUsers": true,
    "appliesToRobots": true,
    "permissions": [
      { "name": "sanity-project",          "action": "read" },
      { "name": "sanity-project-datasets", "action": "read" },
      {
        "name": "analyst-read-scope",
        "action": "mode",
        "params": { "mode": "read", "dataset": "production" }
      }
    ]
  }'
```

**Verification:**

```bash
# A query should succeed.
curl "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/query/production?query=*\[_type==%22article%22\]\[0...5\]" \
  -H "Authorization: Bearer $ROBOT_TOKEN"

# A mutation should return 403.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $ROBOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"nope"}}]}'
# Expected: 403 with an insufficient-permissions error.
```

**Caveats:**

- Most uses of `@sanity/client` need `sanity.project:read` to fetch project metadata. Pure GROQ queries with an explicit dataset don't need it, but most other operations do; if you omit the grant, some operations will return a misleading error.
- The Content Lake `POST /actions` endpoint is a mutation surface; read-only roles will (correctly) return 403 there. Some teams conflate "doesn't mutate" with "can call any endpoint that returns data," so be explicit when documenting the role for your stakeholders.
- If the analyst needs to enumerate all datasets (not only production), drop the `params.dataset` scoping.

### Single-dataset editor

**Persona:** A content contributor who edits in `staging` but must never touch `production`.

**Mechanism:** Native `params.dataset` scoping. The `sanity.document.filter.mode` permission's `params.dataset` field is first-class. The Access API gates the grant to that dataset without filter-string escape hatches.

**Payloads:**

```bash
# Step 1: Create an unscoped filter permission (the "everything" filter).
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "all-docs",
    "title": "All documents",
    "description": "Access all documents",
    "type": "sanity.document.filter.mode",
    "config": { "filter": "true" }
  }'

# Step 2: Role granting publish-mode scoped to the staging dataset.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "staging-editor",
    "title": "Staging editor",
    "description": "Full editing rights in staging dataset only",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",          "action": "read" },
      { "name": "sanity-project-members",  "action": "read" },
      { "name": "sanity-project-roles",    "action": "read" },
      { "name": "sanity-project-datasets", "action": "read" },
      {
        "name": "all-docs",
        "action": "mode",
        "params": { "mode": "publish", "dataset": "staging" }
      }
    ]
  }'
```

**Verification:**

```bash
# Staging mutation succeeds.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/staging" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"test"}}]}'

# Production mutation returns 403.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"nope"}}]}'
```

**Caveats:**

- The `all-docs` permission can be reused across many roles; it's a packaging of "filter = true." Consider naming it `all-docs-filter` if you'll stack it into other recipes.
- If you scope to a dataset that doesn't exist yet, the grant is inert but valid. Create the dataset first.

### Content-type-restricted editor

**Persona:** A marketing editor who should edit and manage `article` documents only, not `product` or `config`.

**Mechanism:** `sanity.document.filter` with a type-matching filter, granted at `update` and `create` actions. Reads are scoped to articles by the same filter: deny-by-omission means the role reads articles only. See the caveat about reference integrity below.

**Payloads:**

```bash
# Step 1: Article-only filter.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "articles-only",
    "title": "Articles only",
    "description": "Access articles only",
    "type": "sanity.document.filter",
    "config": { "filter": "_type == \"article\"" }
  }'

# Step 2: Role with update and create on articles, read on everything.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "article-editor",
    "title": "Article editor",
    "description": "Edits and manages articles only; reads references to other types",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      { "name": "articles-only",          "action": "update" },
      { "name": "articles-only",          "action": "create" },
      { "name": "articles-only",          "action": "read"   }
    ]
  }'
```

**Verification:**

```bash
# Creating an article succeeds.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"ok"}}]}'

# Creating a product returns 403.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"product","sku":"nope"}}]}'
```

**Caveats:**

- **Reference leakage.** An `article` can point to a `product` via a reference field. Because reads are scoped to articles, the editor can't see products in the reference picker. Consider situations like this when creating narrow permissions.
- After a permission change, the Studio's permission cache may briefly show the pre-role state. Buttons might appear available even though the action will return 403 at submit time. Ask the user to reload after a role change.
- **The update grant covers delete.** Granting `update` on `articles-only` allows the editor to delete matching `article` documents at the data-mutate gate, in addition to updating them. Filter scope is enforced (non-`article` documents remain protected), but if your intent was edit-only, gate deletion higher up in your application. See [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).

### Attribute-based editing: "edit what's assigned to me"

**Persona:** An editor who can edit only documents where they're listed in an `assignees` array.

**Mechanism:** `sanity.document.filter.mode` with a user-attribute template. The filter resolves at request time using the calling user's identity or stored attributes. For the simple "is the caller listed in this document's assignees?" pattern, use `identity()` directly.

**Payloads:**

```bash
# Step 1: Filter based on identity().
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "assignee-editor-filter",
    "title": "Documents I am assigned to",
    "description": "User-assigned document access",
    "type": "sanity.document.filter.mode",
    "config": {
      "filter": "identity() in assignees[]._ref"
    }
  }'

# Step 2: Role.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "assignee-editor",
    "title": "Assignee editor",
    "description": "Edit documents where the user is in the assignees array",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      {
        "name": "assignee-editor-filter",
        "action": "mode",
        "params": { "mode": "publish" }
      }
    ]
  }'
```

For attribute-based variants (for example, team membership set admin-side), reference `user::attributes()` in the filter:

```json
"config": {
  "filter": "team == user::attributes().teamId"
}
```

At request time, `user::attributes().teamId` resolves to the calling user's stored `teamId` attribute.

**Verification:** Filters are evaluated per document per caller, so verification is easiest as the affected user:

```bash
curl "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/query/production?query=*\[_type==%22article%22\]" \
  -H "Authorization: Bearer $USER_TOKEN"
# Returns only articles where the user is in assignees.
```

**Caveats:**

- **Filters must be simple.** No joins, no subqueries, no custom functions. A filter like `*[_type == "user" && _id == identity()].assignedDocs[]._ref` is rejected at permission-create time. Denormalize the data (put the assignee list in the article document itself) or gate the feature higher up in your application.
- **Attribute propagation:** User-attribute changes push to the permission layer immediately on write, typically landing within a second; a polling fallback catches any missed updates within roughly 10-30 seconds.
- **identity() is the user's Sanity user ID**, not an email or profile ID. When you denormalize, store the `sanityUserId` value.
- **The mode grant covers delete on matching documents.** `mode` on `sanity.document.filter.mode` grants delete capability for documents matching the filter (the same data-mutate gate semantics as `update` on `sanity.document.filter`; see [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api)). Behavior on documents that do NOT match the filter is not currently documented as a contract; for security-critical denials on this recipe, verify end to end against documents your role both should and should not match.

## Verifying a custom role

After you've assigned a role, two patterns are useful for verification.

### Single-call check

`/user-permissions/me/check` reads from the authoritative permission store, so it's consistent within milliseconds of a role edit.

```bash
curl "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/user-permissions/me/check?permissions=sanity.document.filter.update&permissions=sanity.project.datasets.create" \
  -H "Authorization: Bearer $USER_TOKEN"
```

```json
{
  "data": {
    "sanity.document.filter.update": true,
    "sanity.project.datasets.create": false
  }
}
```

Each `permissions` query parameter is a dot-delimited `<type>.<action>` string. The endpoint checks the coarse `(resource, action)` pair only; it doesn't evaluate `params.mode` or `params.dataset`. For mode-scoped or dataset-scoped permissions, supplement the check with a real action against a test environment.

### CI integration pattern

For end-to-end validation in CI, set up a known test user, assign the role, run permission checks plus real actions, and tear down.

```typescript
import {createClient} from '@sanity/client'

const admin = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET_NAME',
  token: 'ADMIN_TOKEN',
  apiVersion: '2026-07-11',
  useCdn: false,
})

const testUser = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET_NAME',
  token: 'TEST_USER_TOKEN',
  apiVersion: '2026-07-11',
  useCdn: false,
})

beforeAll(async () => {
  await admin.request({method: 'POST', uri: `/access/project/${PROJECT_ID}/permissions`, body: {/* permission */}})
  await admin.request({method: 'POST', uri: `/access/project/${PROJECT_ID}/roles`, body: {/* role */}})
  await admin.request({method: 'PUT',  uri: `/access/project/${PROJECT_ID}/users/${SANITY_USER_ID}/roles/article-editor`})
})

afterAll(async () => {
  await admin.request({method: 'DELETE', uri: `/access/project/${PROJECT_ID}/users/${SANITY_USER_ID}/roles/article-editor`})
  await admin.request({method: 'DELETE', uri: `/access/project/${PROJECT_ID}/roles/article-editor`})
  await admin.request({method: 'DELETE', uri: `/access/project/${PROJECT_ID}/permissions/articles-only`})
})

test('article-editor can update articles but not products', async () => {
  const res = await testUser.request({
    method: 'GET',
    uri: `/access/project/${PROJECT_ID}/user-permissions/me/check?permissions=sanity.document.filter.update`,
  })
  expect(res.data['sanity.document.filter.update']).toBe(true)

  // Mutations are gated at request time by the Content Lake.
  await expect(testUser.create({_type: 'product', sku: 'nope'})).rejects.toThrow(/403|insufficient/i)
})
```

**Note on robot tokens:** The `/user-permissions/me/check` endpoint accepts robot tokens, so a robot can verify its own granted permissions directly with the same query pattern shown above. (`/users/me` is the one `/me` endpoint that requires a user session, but the permission-check path doesn't.)

**Re-verify after permission changes.** If a custom permission referenced by this role has been deleted, the role's `permissions[]` array is silently rewritten (see [Cleanup behavior](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api) above). After any permission deletion, re-fetch any role that referenced it and confirm its `permissions[]` matches your expectations.

**Note on role propagation.** Role and permission changes propagate to the Content Lake mutation gate asynchronously: the role API records the change immediately, then rebuilds the affected dataset ACLs and pushes them to the Content Lake, which applies them in real time as they arrive. A single change typically lands within seconds; under churn (many roles created or modified in close succession), the throttled rebuild step can take noticeably longer. The `/access/...` endpoints answer from the authoritative store and aren't affected. For CI pipelines that create roles and immediately attempt mutations, build in a retry window.

### Two common traps when verifying denials

When a custom role is supposed to deny an action, the cleanest test is to attempt the action and observe a 403. Two traps can make a denial look like it works when it doesn't.

**Confirm the identity you're testing as.** If the test identity is also a member of another role that grants the action (for example, an administrator account in the same project), the action will succeed and the denial test silently passes. Before concluding a denial works, call `/user-permissions/me/check` with the test identity's token and confirm the relevant `(type, action)` returns `false`. The check returns the authoritative grant set for whatever identity the token represents, so a `false` there guarantees no other role on that identity is granting the action.

**update on sanity.document.filter grants delete capability.** On `sanity.document.filter` permissions, granting `update` allows both update and delete mutations at the data-mutate gate. There's no separate `delete` action on this permission type, and no update-without-delete grant available. A role with `{name: <filter-perm>, action: update}` can delete documents matching the filter, even though the role body contains no `delete` entry. To deny deletion while allowing edits, gate deletion at a different layer (a workflow tool, a higher-tier role required for destructive actions, or a server-side check before the mutation hits the Content Lake).

These traps share a shape: a role looks restrictive on paper, but the actual capability surface is broader than the role body suggests. When designing a custom role for a security-sensitive surface, verify denials with a real action attempt against a known-isolated test identity, rather than by reading the role body alone.

## Reference: intent to permission

This section maps user-visible capabilities to the Access API permissions you'd grant in a custom role to enable or restrict each capability.

The tables answer the question: "I want a custom role that can do X. Which permission do I grant?"

> [!NOTE]
> The tables use `<type>:<action>` capability notation to describe permissions. To use these in a role body, build a custom permission with the `type` field set to the dotted type (`sanity.document.filter`, `sanity.document.filter.mode`) and reference it by your custom name in the role's `permissions[].name`. See [Permission names: dotted vs. hyphenated](https://www.sanity.io/docs/content-lake/roles-concepts) in the concepts page for the format reference.

### Document editing

These permissions gate the surfaces where users edit content: creating, publishing, deleting, discarding, and duplicating documents, plus the read-only banner on the form itself.

| Capability | Permission to grant | Notes |
| --- | --- | --- |
| Create new documents | `sanity.document.filter:create` | Scope with a filter (for example, `_type == "article"`) to restrict creation to specific document types. |
| Publish a document | `sanity.document.filter.mode` with `params.mode: "publish"` | The publish mode grants read, create, and update on matching documents, and decomposes internally into update-published, create-published, and delete-draft. A `sanity.document.filter:update` grant also works, but `mode: "publish"` is the canonical way to express "publish authority." |
| Unpublish a document | `sanity.document.filter:update` (or `mode: "publish"`) | Decomposes into delete-published and update-draft. |
| Delete a document | `sanity.document.filter:update` | The same `update` grant covers delete; the document plane doesn't have a separate `delete` action. |
| Discard draft changes | `sanity.document.filter:update` |  |
| Duplicate a document | `sanity.document.filter:create` | Same as create; scope with a filter to restrict duplication targets. |
| Edit a field (form editability and read-only banner) | `sanity.document.filter:update` | Without this, the Studio renders the form with a read-only banner. Combine with `mode: "create"` for draft-only authoring (gating on `drafts.*` document IDs; matches the contributor starter role). |
| Field-level revert in the diff viewer | `sanity.document.filter:update` |  |

> [!WARNING]
> The `params.dataset` scope on a `sanity.document.filter.mode` permission applies only to that permission. 
> If the same role also grants `sanity.document.filter:create` or `:update` actions (the non-mode type), those non-mode grants are not constrained by the sibling mode grant's `params.dataset`: they apply to all datasets. To scope a role to a single dataset for both mode-style and non-mode-style grants, scope each one explicitly.
> For `sanity.document.filter.mode` grants, set `params.dataset` on the role's grant.
> For `sanity.document.filter` (non-mode) grants, either use a filter string that includes a dataset check, or don't combine the two patterns in one role.
> The [single-dataset editor recipe](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api) uses the mode-only pattern specifically to avoid this trap.

**Patterns seen in the wild:**

- **Edit but not publish:** Grant `mode: "create"` but not `mode: "publish"`. This is exactly how the built-in contributor role is composed.
- **Type-restricted editor:** Grant `update` and `create` with a filter like `_type == "article"`.
- **Dataset-scoped editor:** Grant `mode: "publish"` with `params.dataset: "staging"`.

### Content releases

Releases are bundles of versioned documents that publish atomically. Each release action (create, publish, schedule, archive, edit metadata) is gated by a grant on a synthetic document ID of the form `_.releases.<releaseId>.actions.<action>`. You grant `sanity.document.filter:update` with a filter that matches the action IDs you want to authorize.

| Capability | Permission to grant | Filter to match |
| --- | --- | --- |
| Create a new release | `sanity.document.filter:update` | `_.releases.*.actions.create` |
| Publish a release | `sanity.document.filter:update` | `_.releases.*.actions.publish` |
| Schedule a release | `sanity.document.filter:update` | `_.releases.*.actions.schedule` |
| Unschedule a release | `sanity.document.filter:update` | `_.releases.*.actions.unschedule` |
| Archive a release | `sanity.document.filter:update` | `_.releases.*.actions.archive` |
| Unarchive a release | `sanity.document.filter:update` | `_.releases.*.actions.unarchive` |
| Delete a release | `sanity.document.filter:update` | `_.releases.*.actions.delete` |
| Edit release metadata (title, description, schedule) | `sanity.document.filter:update` | `_.releases.*.actions.edit` |
| Revert a release (creates a new reverting release) | `sanity.document.filter:update` | `_.releases.*.actions.create` |
| Discard a version document inside a release | `sanity.document.filter:update` | Plain document filter; no synthetic ID |
| Unpublish a version document inside a release | `sanity.document.filter:update` | Plain document filter; no synthetic ID |

### Comments, mentions, and tasks

> [!TIP]
> Worth knowing up front
> Comments and mentions check read permission only on the target document. There's no separate write check for leaving a comment or @-mentioning someone. A "reviewer-cannot-comment" role isn't expressible today; revoking read also hides the document.

| Capability | Permission to grant |
| --- | --- |
| See and leave comments on a document | `sanity.document.filter:read` on the target document |
| Appear as a mention candidate to other users | `sanity.document.filter:read` on the target document (per-user check) |
| Be assignable as a task assignee | `sanity.document.filter:read` on the target document (per-user check) |

### Project membership and access requests

Two capabilities live at the project-management level rather than the document plane.

| Capability | Permission to grant | Notes |
| --- | --- | --- |
| Invite new members to the project | `sanity.project.members:invite` | Covers the "Invite members" button in the Studio's navbar. |
| Submit a request for access to a resource | No grant required | The endpoint is self-request: `POST /v2024-07-01/access/{resourceType}/{resourceId}/requests`. |
| Approve or decline an access request | `sanity.project.members:invite` | `PUT .../requests/{id}/accept` or `PUT .../requests/{id}/decline`. |

The full request-access flow is pinned to `v2024-07-01` and covers five endpoints. See the [Access API HTTP reference](https://www.sanity.io/docs/http-reference/access-api) for the complete request schemas.

## Troubleshooting

Custom roles can't express everything. If you're trying to build a role and the filter mechanism rejects your design, three patterns usually cover the gap:

- **Denormalize the data.** If your filter needs to follow a reference (for example, looking up a user document to read its `assignedDocs` array), copy the data you're filtering on into the document itself. Filters can read fields on the document being checked; they can't follow references to other documents.
- **Gate higher up.** If the access rule requires logic the filter system can't express (subqueries, custom functions, complex branching), enforce it in your application layer or a webhook rather than in the role.
- **Test in a non-production environment first.** Assign the role to a test user account in a staging dataset (or a separate test project) and attempt the actions you expect to allow and deny before rolling out to production users.

The [concepts page](https://www.sanity.io/docs/content-lake/roles-concepts) lists the full set of capabilities you can and can't express in custom roles today.

