Build a custom role with the Access API
How to build, assign, and verify custom Sanity roles via the Access API, with recipes for common scenarios.
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 first.
Before you start
This is a paid feature
This feature is available on certain Enterprise plans. Talk to sales to learn more.
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 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.
{
"name": "all-docs",
"action": "mode",
"params": { "mode": "publish" }
}This shape is what enables dataset and history scoping via additional params fields:
{
"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.
{ "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.filterwhen a GROQ filter alone is enough to scope the permission. Best for simple type-matching filters (Recipe: Content-type-restricted editor: "edit articles only"), release-action filtering (Recipe: Release reviewer: "schedule but not publish"), and any case where you don't need dataset or history scoping.sanity.document.filter.modewhen you need first-classparams.datasetfor dataset scoping (Recipes: Read-only analyst, Single-dataset editor),params.historyfor history scoping, or the canonicalmode: "publish"shape for expressing publish authority (Recipe: Attribute-based editing).
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.
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.
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.)
Step 3: Assign the role to a user
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.
# 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:
- Remove the role's assignments from each user (required).
- Delete the role (now allowed; will reject if any assignment remains).
- 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:
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"
{
"data": {
"sanity.document.filter.update": true,
"sanity.project.members.invite": false
}
}For deeper testing patterns, including a CI integration sketch, see the verification section 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.
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) needssanity.project:deployStudio.sanity dataset copy(optional, for snapshot-based deploys) needssanity.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:
# 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:
# 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 deployfrom the CLI authenticates with a user token. Set theSANITY_AUTH_TOKENenvironment variable to override the user token.- Granting
createonsanity-project-datasetsis 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.)
Permissions granted: schedule, unschedule, edit, archive, unarchive. Permissions withheld: publish, delete.
Payloads:
# 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:
# 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:
# 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/clientneedsanity.project:readto 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 /actionsendpoint 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.datasetscoping.
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:
# 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:
# 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-docspermission can be reused across many roles; it's a packaging of "filter = true." Consider naming itall-docs-filterif 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:
# 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:
# 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
articlecan point to aproductvia 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
updategrant covers delete. Grantingupdateonarticles-onlyallows the editor to delete matchingarticledocuments at the data-mutate gate, in addition to updating them. Filter scope is enforced (non-articledocuments remain protected), but if your intent was edit-only, gate deletion higher up in your application. See two common traps when verifying denials.
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:
# 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:
"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:
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[]._refis 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 thesanityUserIdvalue.- The
modegrant covers delete on matching documents.modeonsanity.document.filter.modegrants delete capability for documents matching the filter (the same data-mutate gate semantics asupdateonsanity.document.filter; see two common traps when verifying denials). 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.
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"
{
"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.
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 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?"
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 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` |
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 uses the mode-only pattern specifically to avoid this trap.
Patterns seen in the wild:
- Edit but not publish: Grant
mode: "create"but notmode: "publish". This is exactly how the built-in contributor role is composed. - Type-restricted editor: Grant
updateandcreatewith a filter like_type == "article". - Dataset-scoped editor: Grant
mode: "publish"withparams.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
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 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
assignedDocsarray), 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 lists the full set of capabilities you can and can't express in custom roles today.