RBAC and authorize

Gate has two permission systems. They share a permission-string grammar and nothing else. Conflating them is the single most common integration mistake, so start here.

Control planeProject RBAC
GovernsAdministration of GateWhat your product's users may do
SubjectsGate users and service accountsAny principal id you choose
Roles defined byGate — five fixed rolesYou, per project
Assigned throughOrganization membership, or key scopesRole bindings
Enforced byGate, on every /api/v1 callYour product, by asking POST /authorize
Interpreted byGateNobody — Gate stores and matches strings

Control plane

Five roles. A user's role on the organization determines what they may administer; a service account's key scopes do the same job. The full matrix is in Scopes and roles.

  • owner — everything, including deleting the organization
  • admin — everything except organization:delete
  • developer — projects and configuration, but not config:reveal, credential:manage, or billing:manage, and no organization or member mutation
  • billing — billing management plus read access
  • viewer — read only

You do not define these and cannot add to them. Dynamic access control is enabled for organizations, capped at 25 roles each.

Project RBAC

This is the system your product uses. You define roles with whatever permission strings mean something to you, bind them to your own principal ids, and ask Gate for decisions.

Creating a project seeds two roles:

SlugPermissions
project-admin["*"]
member["profile:read"]

Define a role

POST/projects/{projectId}/rolesproject:update
curl
curl -sS -X POST \
  "https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID/roles" \
  -H "content-type: application/json" \
  -H "x-api-key: $GATE_API_KEY" \
  -d '{
    "name": "Album Editor",
    "slug": "album-editor",
    "description": "Can edit albums but not delete them",
    "permissions": ["album:read", "album:update", "photo:upload"]
  }'

Permission strings must match:

Text
^(\*|[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*)$

Lowercase resource:action, or the literal *. Up to 100 per role. slug defaults to a slugified name and is unique within the project.

Bind a role

POST/projects/{projectId}/role-bindingsproject:update
curl
curl -sS -X POST \
  "https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID/role-bindings" \
  -H "content-type: application/json" \
  -H "x-api-key: $GATE_API_KEY" \
  -d '{
    "roleId": "'"$ROLE_ID"'",
    "principalType": "user",
    "principalId": "usr_2f9c1e",
    "expiresAt": "2026-12-31T23:59:59.000Z"
  }'

principalType is user or service_account. principalId is opaque to Gate — use whatever identifier your product already has. expiresAt is optional; once it passes, the binding stops counting with no cleanup needed.

Bindings are unique on (roleId, principalType, principalId). A principal may hold several roles, and their permissions union.

Asking for a decision

POST/authorizeproject:read
JSON
{
  "projectId": "6f1c9e7a-2b44-4a0f-9f1a-2c9d2b6ac1a3",
  "principalType": "user",
  "principalId": "usr_2f9c1e",
  "permission": "album:update"
}
JSON
{
  "data": {
    "allowed": true,
    "roles": ["album-editor", "member"],
    "decisionId": "018f3a2c-1d4e-7c3a-9b21-6f9c2d1e4a55"
  }
}

allowed is true when any unexpired binding holds a role whose permissions contain either the exact string or *. roles lists the slugs that were considered — useful for debugging and for logging why something was permitted. decisionId correlates the decision with your own logs.

export async function can(principalId: string, permission: string): Promise<boolean> {
  const { allowed } = await gate<{ allowed: boolean; roles: string[]; decisionId: string }>(
    "/authorize",
    {
      method: "POST",
      body: JSON.stringify({
        projectId: GATE_PROJECT_ID,
        principalType: "user",
        principalId,
        permission,
      }),
    },
  );
  return allowed;
}

Choosing where to enforce

Both systems can express "may this caller do X", so pick by who the caller is.

  • A token scope answers "may this client, as a whole, do this?" — check it at the API boundary during token verification.
  • A project permission answers "may this particular user do this to this thing?" — check it with /authorize when the answer depends on the user.

Scope checks are free and local; /authorize is a round trip. Use scopes to reject obviously unauthorized traffic early, and /authorize for the per-user decision that follows.

Audit

GET/projects/{projectId}/audit-logsaudit:read

Returns the latest 100 entries, newest first. Every administrative mutation is recorded, along with every secret reveal, with the actor, target, request id, and IP address.

/authorize decisions are not written to the audit log — they are far too frequent. Log decisionId on your side if you need that trail.