Console
Sign in
Docs for Console and CLI user
On this page
  1. Conventions
  2. Probes
  3. API description
  4. GET /openapi.json
  5. OpenID Connect provider
  6. GET /oauth/authorize
  7. GET /oauth/tenant/callback
  8. POST /oauth/token
  9. GET /.well-known/openid-configuration
  10. GET /oauth/jwks
  11. GET /oauth/userinfo
  12. Decision contract
  13. POST /v1/decisions
  14. Access context contract
  15. GET /v1/access-context
  16. POST /v1/access-context
  17. Administration
  18. POST /v1/admin/tenants
  19. GET /v1/admin/tenants
  20. POST /v1/admin/users
  21. GET /v1/admin/users
  22. PUT /v1/admin/grants
  23. DELETE /v1/admin/grants/{id}
  24. GET /v1/admin/grants
  25. POST /v1/admin/groups
  26. GET /v1/admin/groups
  27. PUT /v1/admin/roles
  28. GET /v1/admin/roles
  29. DELETE /v1/admin/roles/{id}
  30. GET /v1/admin/audit
  31. Session in twelve calls

HTTP API

Every Access endpoint with its request, response, errors and a curl example, ending with a tenant's whole session in twelve calls. It is written for developers and operators who call Access; the rules behind each endpoint are in the specification, cited as §N.

Read bySecurity engineerApplication developer

Conventions#

  • Bodies are JSON. Errors are {"error": "<code>", "message": "<text>"}, except the token endpoint, which uses the OAuth shape {"error": "<code>", "error_description": "<text>"}.
  • Every response carries x-request-id (the client's own value if it sent a valid one), x-content-type-options: nosniff, x-frame-options: DENY and referrer-policy: no-referrer (§5.6).
  • The examples use https://access.example.com; replace it with your ACCESS_PUBLIC_URL (Configuration).
  • When a call fails, look the status and error code up in Troubleshooting.

Probes#

EndpointResponse
GET /healthz200 {"status":"ok","service":"access","version":"<version>"} (§12.1).
GET /readyz200 {"status":"ready"}, 503 {"status":"draining"} or 503 {"status":"not-ready"} (§12.1).
GET /metricsPrometheus text. 404 when ACCESS_METRICS_TOKEN is unset, 401 when the bearer token does not match.
bash
curl -sS https://access.example.com/healthz
curl -sS https://access.example.com/readyz
curl -sS https://access.example.com/metrics -H "authorization: Bearer $ACCESS_METRICS_TOKEN"

API description#

GET /openapi.json#

The OpenAPI description of the served paths and response shapes. The repository's API check (bun run api:check) compares it with the registered routes.

OpenID Connect provider#

GET /oauth/authorize#

ParameterRule
client_idRequired. Names the tenant (§5.2).
redirect_uriRequired. One of the tenant's clientRedirectUris; otherwise 400 invalid_request.
response_typeRequired. code.
scopeOptional. openid profile email groups.
stateOptional. Returned to the gateway.
nonceOptional. Bound to the issued token.
code_challenge, code_challenge_methodOptional. S256.
tenantOnly when the tenant's client is shared_client.
login_hintOptional. An e-mail, used only when the client is shared.

Response: 302 to the tenant's provider, 400 invalid_request (no tenant, bad parameters, unregistered redirect_uri) or 403 tenant_disabled.

GET /oauth/tenant/callback#

Where the tenant's provider returns the browser with code and state. Response: 302 to the request's redirect_uri with Access's code and the gateway's state. Refusals (§5.6): 400 login_state_invalid, 400 invalid_request, 403 identity_rejected, 403 identity_unknown, 403 principal_disabled, 403 provider_error, 403 tenant_disabled, 502 upstream_unavailable. An unexpected failure is 500 internal_error and never carries a dependency's error.

POST /oauth/token#

Exchanges the code for tokens; the client authenticates with client_secret_basic.

http
POST /oauth/token
Authorization: Basic {client_id}:{client_secret}
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…
FieldRule
grant_typeRequired. authorization_code.
codeRequired. The login code. Single use, 60 seconds.
redirect_uriRequired. Same as in the authorization request.
code_verifierRequired when the request carried a code_challenge.
json
{
  "access_token": "eyJ…",
  "token_type": "Bearer",
  "expires_in": 300,
  "id_token": "eyJ…"
}

No refresh token (§7.2); cache-control: no-store.

ErrorWhen
401 invalid_clientCredentials missing, unreadable or wrong.
400 invalid_requestBad grant type, missing code or redirect URI.
400 invalid_grantCode unknown, expired, already used, or bound to another client, redirect URI or challenge.

GET /.well-known/openid-configuration#

The discovery document. Fields: §7.5.

GET /oauth/jwks#

The service's public keys; during a rotation, the current and previous key (§4.2).

GET /oauth/userinfo#

The claims of a bearer token's principal: sub, email, name, roles.

bash
curl -sS https://access.example.com/oauth/userinfo -H "authorization: Bearer $ACCESS_TOKEN"

401 when the token is expired, signed with another key, or issued for another audience.

Decision contract#

POST /v1/decisions#

http
POST /v1/decisions
Authorization: Bearer {ACCESS_DECISION_TOKEN}
Content-Type: application/json

{"subject":"…","workspaceId":"…","routeId":"…","roles":["…"]}
FieldRule
subjectRequired. A principal id (UUID).
workspaceIdOptional. Defaults to the empty string.
routeIdOptional. Defaults to the empty string.
rolesOptional. Defaults to an empty list. Roles are context, never policy (§8.4).
json
{"allow": true, "reason": "allowed"}
{"allow": false, "reason": "the principal does not have the role of the workspace", "reasonCode": "missing_role"}

Only a denial carries reasonCode (missing_role or access_denied), which always agrees with the text (§8.2, §8.7). The gateway treats every refusal as a denial:

ErrorWhen
401 unauthorizedCredential missing or wrong.
400 bad_requestBody is not JSON, or the subject is not a string or not a principal id.
503 service_unavailableDatabase down, or the decision took longer than ACCESS_DECISION_TIMEOUT_MS.

Credentials (§8.8): the deployment's bearer token (any principal) or a tenant's credentials (its principals only).

Access context contract#

A data-owning service reads a principal's complete capabilities for a workspace and route (§9). The credential is a dedicated read-only one from ACCESS_CONTEXT_TOKENS, scoped to listed tenants.

GET /v1/access-context#

Contract discovery, used by consumers as a readiness check.

http
GET /v1/access-context
Authorization: Bearer {access context credential}
json
{"versions":[1],"maxPermissionKeys":4096,"maxLifetimeMs":5000}

401 unauthorized when the credential is missing or wrong.

POST /v1/access-context#

A principal's context.

http
POST /v1/access-context
Authorization: Bearer {access context credential}
Content-Type: application/json

{"version":1,"subject":"…","tenantId":"…","workspaceId":"…","routeId":"…"}
FieldRule
versionRequired. 1; any other value is 400 bad_request.
subjectRequired. A principal id (UUID).
tenantIdRequired. The request's tenant (UUID).
workspaceIdRequired. The request's workspace.
routeIdRequired. The request's route.

Unknown or mistyped fields are refused; the body is at most 64 KiB.

json
{
  "version": 1,
  "allow": true,
  "subject": "…",
  "tenantId": "…",
  "workspaceId": "…",
  "routeId": "…",
  "permissionKeys": ["content.editor", "content.reader"],
  "evaluatedAt": "2026-10-21T16:00:00.000Z",
  "validUntil": "2026-10-21T16:00:05.000Z"
}

permissionKeys is complete, sorted and deduplicated; more than 4096 keys is 503 service_unavailable (§9.3). A denial is 200 with allow: false, empty permissionKeys and no validUntil.

ErrorWhen
401 unauthorizedCredential missing or wrong.
403 forbiddenThe credential may not ask about this tenant.
400 bad_requestBody not understood.
503 service_unavailableThe policy could not be fully evaluated.

Administration#

The twelve administrative endpoints live under /v1/admin, next to GET /v1/admin/audit; every call sends Authorization: Bearer <credential>. The credential is ACCESS_ADMIN_TOKEN or one from ACCESS_ADMIN_TOKENS (§4.6); a tenant credential acts only for its tenant, and naming another is 403 forbidden. Limits, pagination, error codes and auditing: §10.1.

POST /v1/admin/tenants#

Upsert by slug (§10.2).

bash
curl -sS -X POST https://access.example.com/v1/admin/tenants \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" \
  -H "content-type: application/json" \
  -d '{
        "slug": "acme",
        "displayName": "Acme Inc",
        "issuer": "https://login.acme.example",
        "idpClientId": "access-acme",
        "idpClientSecret": "the-secret-of-the-provider",
        "idpRedirectUri": "https://access.example.com/oauth/tenant/callback",
        "emailDomain": "acme.example",
        "clientId": "gateway-acme",
        "clientSecret": "the-secret-of-the-gateway",
        "clientRedirectUris": ["https://gateway-acme.example.com/auth/callback"],
        "autoProvision": false,
        "sharedClient": false,
        "default": false,
        "enabled": true
      }'
FieldRule
slugRequired. [a-z0-9-]{2,63}. The row key.
displayNameRequired.
issuerRequired. The tenant's provider issuer.
idpClientIdRequired.
idpClientSecretRequired. Encrypted at rest, never returned.
idpRedirectUriOptional. Default https://{ACCESS_PUBLIC_URL}/oauth/tenant/callback.
idpScopesOptional. Default ["openid","email","profile"].
idpAlgorithmsOptional. Default ["RS256"].
emailDomainOptional. Unique when set.
clientIdRequired. The client id of the tenant's gateway at Access. Unique across tenants.
clientSecretRequired. Encrypted at rest, never returned. On replacement, the old secret stays valid for 24 hours alongside the new one.
clientRedirectUrisOptional. Default []. The gateway's callback URLs; a tenant with none refuses every login.
autoProvisionOptional. Default false.
sharedClientOptional. Default false. Platform operators only (§10.1).
defaultOptional. Default false. At most one tenant. Platform operators only.
enabledOptional. Default true.

409 conflict when the client id belongs to another tenant or a second default would appear; 403 forbidden when a tenant credential changes sharedClient or default.

json
{
  "id": "6f0f…", "slug": "acme", "displayName": "Acme Inc",
  "issuer": "https://login.acme.example", "emailDomain": "acme.example",
  "clientId": "gateway-acme",
  "clientRedirectUris": ["https://gateway-acme.example.com/auth/callback"],
  "autoProvision": false, "sharedClient": false,
  "default": false, "enabled": true,
  "createdAt": "2026-01-08T10:00:00.000Z"
}

GET /v1/admin/tenants#

Tenants with principal and grant counts.

bash
curl -sS https://access.example.com/v1/admin/tenants \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"
json
{
  "tenants": [
    {
      "id": "6f0f…", "slug": "acme", "displayName": "Acme Inc",
      "issuer": "https://login.acme.example", "emailDomain": "acme.example",
      "clientId": "gateway-acme", "autoProvision": false, "sharedClient": false,
      "default": false, "enabled": true,
      "userCount": 42, "grantCount": 57,
      "createdAt": "2026-01-08T10:00:00.000Z"
    }
  ]
}

POST /v1/admin/users#

Upsert by (tenant, externalSubject) (§10.4).

bash
curl -sS -X POST https://access.example.com/v1/admin/users \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" \
  -H "content-type: application/json" \
  -d '{
        "tenant": "acme",
        "externalSubject": "00u1a2b3c4d5",
        "email": "alice@acme.example",
        "displayName": "Alice",
        "status": "active"
      }'
FieldRule
tenantRequired. The tenant slug.
externalSubjectRequired. The sub from the tenant's provider.
emailOptional. Published in the token until the provider says otherwise.
displayNameOptional.
statusOptional. active (default) or disabled.

404 not_found when the tenant is unknown.

json
{
  "id": "b1c2…", "tenant": "acme", "externalSubject": "00u1a2b3c4d5",
  "email": "alice@acme.example", "displayName": "Alice", "status": "active",
  "lastLoginAt": null, "createdAt": "2026-01-08T10:05:00.000Z"
}

Batch of principals#

Send principals instead of one principal:

bash
curl -sS -X POST https://access.example.com/v1/admin/users \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" \
  -H "content-type: application/json" \
  -d '{
        "principals": [
          {"tenant": "acme", "externalSubject": "00u1a2b3c4d5", "email": "alice@acme.example"},
          {"tenant": "acme", "externalSubject": "00u1a2b3c4d6", "email": "bob@acme.example"}
        ]
      }'

At most 1 000 per batch. Items are written separately, and a failed item does not stop the others (§10.4).

json
{
  "principals": [
    {"tenant": "acme", "externalSubject": "00u1a2b3c4d5", "id": "b1c2…", "created": true},
    {"tenant": "acme", "externalSubject": "00u1a2b3c4d6", "id": null, "created": false,
     "error": "not_found", "message": "the tenant does not exist"}
  ]
}

GET /v1/admin/users#

bash
curl -sS "https://access.example.com/v1/admin/users?tenant=acme&limit=50" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"
ParameterRule
tenantOptional. A tenant slug. Absent: every tenant.
statusOptional. active or disabled.
emailOptional. Exact e-mail match.
limitOptional. Default 50, maximum 200.
offsetOptional. Default 0.
json
{
  "principals": [
    {
      "id": "b1c2…", "tenant": "acme", "externalSubject": "00u1a2b3c4d5",
      "email": "alice@acme.example", "displayName": "Alice", "status": "active",
      "lastLoginAt": "2026-01-08T10:05:00.000Z",
      "createdAt": "2026-01-08T10:00:00.000Z"
    }
  ],
  "total": 42, "limit": 50, "offset": 0
}

Ordering and paging: §10.5.

PUT /v1/admin/grants#

Upsert by (tenant, user, permission, workspace, route) (§10.6).

bash
curl -sS -X PUT https://access.example.com/v1/admin/grants \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" \
  -H "content-type: application/json" \
  -d '{
        "tenant": "acme",
        "userId": "b1c2…",
        "permission": "workspace.read",
        "workspaceId": "11111111-1111-4111-8111-111111111111",
        "routeId": "workspace-api",
        "expiresAt": null
      }'
FieldRule
tenantRequired. The tenant slug.
userIdThe principal id. Exactly one of userId and group is required.
groupThe group key. Exactly one of userId and group is required.
permissionThe permission key, created in the tenant's scope if missing. Exactly one of permission and role is required.
roleThe role key. The grant holds the role's permissions. An unknown role is 404 not_found.
workspaceIdOptional. null (default) means every workspace of the tenant.
routeIdOptional. null (default) means every route.
expiresAtOptional. An ISO 8601 instant; null (default) never expires.
grantedByOptional. Who granted it, for the audit log.
json
{
  "id": "d3e4…", "tenant": "acme", "userId": "b1c2…",
  "permission": "workspace.read",
  "workspaceId": "11111111-1111-4111-8111-111111111111",
  "routeId": "workspace-api", "expiresAt": null, "revokedAt": null,
  "createdAt": "2026-01-08T10:06:00.000Z"
}

Batch of grants#

Send {"grants": [ … ]} instead of one grant; the response reports each item:

json
{
  "grants": [
    {"tenant": "acme", "id": "d3e4…", "created": true},
    {"tenant": "acme", "id": null, "created": false,
     "error": "not_found", "message": "the principal does not exist"}
  ]
}

DELETE /v1/admin/grants/{id}#

Revokes a grant; the row is kept with revoked_at set.

bash
curl -sS -X DELETE https://access.example.com/v1/admin/grants/d3e4… \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -i
http
HTTP/1.1 204 No Content

Idempotent; 404 not_found for an unknown id (§10.7).

GET /v1/admin/grants#

bash
curl -sS "https://access.example.com/v1/admin/grants?tenant=acme&live=true" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"
ParameterRule
tenantOptional. A tenant slug. Absent: every tenant.
userIdOptional. A principal id.
liveOptional. true: grants neither revoked nor expired. false: the others. Absent: all.
limitOptional. Default 50, maximum 200.
offsetOptional. Default 0.
json
{
  "grants": [
    {
      "id": "d3e4…", "tenant": "acme", "userId": "b1c2…",
      "permission": "workspace.read",
      "workspaceId": "11111111-1111-4111-8111-111111111111",
      "routeId": "workspace-api", "expiresAt": null, "revokedAt": null,
      "grantedBy": "ops@example.com",
      "createdAt": "2026-01-08T10:10:00.000Z"
    }
  ],
  "total": 57, "limit": 50, "offset": 0
}

POST /v1/admin/groups#

Upsert by key; membership rules in §10.9.

bash
curl -sS -X POST https://access.example.com/v1/admin/groups \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" \
  -H "content-type: application/json" \
  -d '{
        "tenant": "acme",
        "key": "team-platform",
        "displayName": "The platform team",
        "members": ["00u1a2b3c4d5", "00u9z8y7x6w5"]
      }'
FieldRule
tenantRequired. The tenant slug.
keyRequired. [a-z0-9][a-z0-9._-]{0,63}.
displayNameOptional.
membersOptional. The members' external subjects.
json
{
  "id": "e5f6…", "tenant": "acme", "key": "team-platform",
  "displayName": "The platform team",
  "members": ["00u1a2b3c4d5", "00u9z8y7x6w5"],
  "createdAt": "2026-01-08T10:20:00.000Z"
}

GET /v1/admin/groups#

Groups, newest first.

bash
curl -sS "https://access.example.com/v1/admin/groups?tenant=acme&limit=20" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"
QueryRule
tenantOptional. A tenant slug. Unknown tenant: 404 not_found.
keyOptional. A group key.
limitOptional. Default 50, maximum 200.
offsetOptional. Default 0.
json
{
  "groups": [
    {
      "id": "e5f6…", "tenant": "acme", "key": "team-platform",
      "displayName": "The platform team",
      "members": ["00u1a2b3c4d5", "00u9z8y7x6w5"],
      "createdAt": "2026-01-08T10:20:00.000Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

PUT /v1/admin/roles#

Upsert by key; replaces the role's permissions and nested roles (§10.11).

bash
curl -sS -X PUT https://access.example.com/v1/admin/roles \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" \
  -H "content-type: application/json" \
  -d '{
        "tenant": "acme",
        "key": "role-editor",
        "displayName": "The editors",
        "description": "What the editors may do",
        "permissions": ["workspace.write"],
        "roles": ["role-reader"]
      }'
FieldRule
tenantRequired. The tenant slug.
keyRequired. [a-z0-9][a-z0-9._-]{0,63}.
displayNameOptional.
descriptionOptional.
permissionsOptional. Permission keys the role holds. Missing keys are created in the tenant's scope.
rolesOptional. Role keys the role names. An unknown key is 404 not_found.

A role that names itself, directly or through others, is 409 conflict.

json
{
  "id": "a1b2…", "tenant": "acme", "key": "role-editor",
  "displayName": "The editors",
  "description": "What the editors may do",
  "permissions": ["workspace.write"],
  "roles": ["role-reader"],
  "createdAt": "2026-01-08T10:30:00.000Z"
}

GET /v1/admin/roles#

Roles, newest first.

bash
curl -sS "https://access.example.com/v1/admin/roles?tenant=acme&limit=20" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"
QueryRule
tenantOptional. A tenant slug. Unknown tenant: 404 not_found.
keyOptional. A role key.
limitOptional. Default 50, maximum 200.
offsetOptional. Default 0.
json
{
  "roles": [
    {
      "id": "a1b2…", "tenant": "acme", "key": "role-editor",
      "displayName": "The editors",
      "permissions": ["workspace.write"],
      "roles": ["role-reader"],
      "createdAt": "2026-01-08T10:30:00.000Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

DELETE /v1/admin/roles/{id}#

Removes a role that no grant names (§10.13).

bash
curl -sS -X DELETE https://access.example.com/v1/admin/roles/a1b2c3d4-… \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

GET /v1/admin/audit#

Reads the change audit (§10.15).

bash
curl -sS "https://access.example.com/v1/admin/audit?tenant=acme&limit=20" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"
QueryRule
tenantOptional. A tenant slug. Unknown tenant: 404 not_found.
actionOptional. An action name.
limitOptional. Default 50, maximum 200. An export carries at most 10000.
offsetOptional. Default 0.
formatOptional. json (default), or csv / jsonl for an export. Anything else: 400 invalid_request.

Actions: tenant.created, tenant.updated, principal.created, principal.updated, group.created, group.updated, grant.created, grant.updated, grant.revoked, role.created, role.updated, role.removed, and for a role's memberships role.permission.granted, role.permission.revoked, role.role.nested, role.role.unnested.

json
{
  "records": [
    {
      "id": "0f2c…",
      "at": "2026-01-08T10:30:00.000Z",
      "action": "grant.revoked",
      "targetId": "9fbfda13-…",
      "tenant": "acme",
      "operator": "6b1f2c9d0e3a",
      "address": "10.0.0.7",
      "requestId": "8452a08e-ebd7-4e61-ae99-fe051f3e531d"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

operator is the credential's name, or the first twelve hex characters of its digest (§10.1).

Exporting the audit#

Add format to get a file:

bash
# CSV, for a spreadsheet
curl -sS "https://access.example.com/v1/admin/audit?format=csv&tenant=acme" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -o acme-audit.csv

# JSON Lines, for a script
curl -sS "https://access.example.com/v1/admin/audit?format=jsonl&tenant=acme" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -o acme-audit.jsonl

Filters apply. An export holds at most 10 000 rows; page with limit and offset beyond that.

csv
id,at,action,targetId,tenant,operator,address,requestId
0f2c…,2026-01-08T10:30:00.000Z,grant.revoked,9fbfda13-…,acme,6b1f2c9d0e3a,10.0.0.7,8452a08e-…

Session in twelve calls#

The twelve calls of the session take a tenant from registration to revocation, using each administration endpoint once. You need curl and jq.

bash
export ACCESS=https://access.example.com
export ACCESS_ADMIN_TOKEN=…

# 1. Register the tenant and its gateway
curl -sS -X POST $ACCESS/v1/admin/tenants \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -H "content-type: application/json" \
  -d '{"slug":"acme","displayName":"Acme Inc","issuer":"https://login.acme.example",
       "idpClientId":"access-acme","idpClientSecret":"…","clientId":"gateway-acme",
       "clientSecret":"…","emailDomain":"acme.example",
       "clientRedirectUris":["https://gateway-acme.example.com/auth/callback"]}'

# 2. List tenants with their counts
curl -sS $ACCESS/v1/admin/tenants -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

# 3. Create the principal of an external identity
USER_ID=$(curl -sS -X POST $ACCESS/v1/admin/users \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -H "content-type: application/json" \
  -d '{"tenant":"acme","externalSubject":"00u1a2b3c4d5","email":"alice@acme.example"}' | jq -r .id)

# 4. List the tenant's principals
curl -sS "$ACCESS/v1/admin/users?tenant=acme" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

# 5. Grant a permission on a workspace and route
GRANT_ID=$(curl -sS -X PUT $ACCESS/v1/admin/grants \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -H "content-type: application/json" \
  -d '{"tenant":"acme","userId":"'"$USER_ID"'","permission":"workspace.read",
       "workspaceId":"11111111-1111-4111-8111-111111111111","routeId":"workspace-api"}' | jq -r .id)

# 6. List the tenant's grants
curl -sS "$ACCESS/v1/admin/grants?tenant=acme" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

# 7. Create a group; a grant to the group applies to every member
curl -sS -X POST $ACCESS/v1/admin/groups \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -H "content-type: application/json" \
  -d '{"tenant":"acme","key":"team-platform","displayName":"The platform team",
       "members":["00u1a2b3c4d5"]}'

# 8. List the tenant's groups
curl -sS "$ACCESS/v1/admin/groups?tenant=acme" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

# 9. Create a role holding a permission
ROLE_ID=$(curl -sS -X PUT $ACCESS/v1/admin/roles \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN" -H "content-type: application/json" \
  -d '{"tenant":"acme","key":"role-editor","displayName":"The editors",
       "permissions":["workspace.write"]}' | jq -r .id)

# 10. List the tenant's roles
curl -sS "$ACCESS/v1/admin/roles?tenant=acme" \
  -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

# 11. Remove the role (no grant names it)
curl -sS -X DELETE $ACCESS/v1/admin/roles/$ROLE_ID -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

# 12. Revoke the grant when access must end
curl -sS -X DELETE $ACCESS/v1/admin/grants/$GRANT_ID -H "authorization: Bearer $ACCESS_ADMIN_TOKEN"

To grant a role, send "role":"role-editor" instead of "permission" in step 5. scripts/cli/admin-smoke.ts runs the same sequence against a deployment:

bash
ACCESS_URL=$ACCESS ACCESS_ADMIN_TOKEN=$ACCESS_ADMIN_TOKEN bun run scripts/cli/admin-smoke.ts