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 plane | Project RBAC | |
|---|---|---|
| Governs | Administration of Gate | What your product's users may do |
| Subjects | Gate users and service accounts | Any principal id you choose |
| Roles defined by | Gate — five fixed roles | You, per project |
| Assigned through | Organization membership, or key scopes | Role bindings |
| Enforced by | Gate, on every /api/v1 call | Your product, by asking POST /authorize |
| Interpreted by | Gate | Nobody — 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 organizationadmin— everything exceptorganization:deletedeveloper— projects and configuration, but notconfig:reveal,credential:manage, orbilling:manage, and no organization or member mutationbilling— billing management plus read accessviewer— 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:
| Slug | Permissions |
|---|---|
project-admin | ["*"] |
member | ["profile:read"] |
Define a role
/projects/{projectId}/rolescurl -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:
^(\*|[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
/projects/{projectId}/role-bindingscurl -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
/authorize{
"projectId": "6f1c9e7a-2b44-4a0f-9f1a-2c9d2b6ac1a3",
"principalType": "user",
"principalId": "usr_2f9c1e",
"permission": "album:update"
}{
"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;
}type authorizeRequest struct {
ProjectID string `json:"projectId"`
PrincipalType string `json:"principalType"`
PrincipalID string `json:"principalId"`
Permission string `json:"permission"`
}
type authorizeResponse struct {
Data struct {
Allowed bool `json:"allowed"`
Roles []string `json:"roles"`
DecisionID string `json:"decisionId"`
} `json:"data"`
}
func (c *Client) Can(ctx context.Context, principalID, permission string) (bool, error) {
var out authorizeResponse
err := c.post(ctx, "/authorize", authorizeRequest{
ProjectID: c.projectID,
PrincipalType: "user",
PrincipalID: principalID,
Permission: permission,
}, &out)
return out.Data.Allowed, err
}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
/authorizewhen 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
/projects/{projectId}/audit-logsReturns 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.