Permissions and Roles
Purpose: the customer-facing guide to authorizing your own application with Udibo Identity — what to register, how it reaches your code, and what we promise never to do with it. Everything here is a published commitment: changing a rule in this document changes a promise.
The whole model in one paragraph
You register permissions — capability strings your application interprets,
like posts:write. You define roles that carry them. You assign a role
to a person at one of three widths: across your whole tenant, inside one
organization, or on a single resource of your own — a document, a project,
whatever your application owns. We store the assignment, resolve it on every
request, and hand your application the resulting set. We never interpret a
permission string. What posts:write means is decided entirely by your code.
The three widths, once, because it is the whole model: a tenant-wide assignment counts everywhere; an organization assignment counts only inside that organization; a resource assignment counts only on that one resource. They union — holding a permission at any width that applies means you hold it. Nothing narrows: an organization assignment never leaks to another organization, and a grant on one document never answers for another.
The word "role" means three different things here
This trips people up, so it is worth thirty seconds up front. On your organization page, three sections each use the word, and they govern different worlds:
| Where | Governs | Who manages it |
|---|---|---|
| Members | the organization itself — invite, remove, rename | the organization |
| App access | your application — this document | the organization |
| Organization roles | what a member can do, as this organization defines it | the organization |
And separately, Administrators governs your Udibo dashboard. None of these confers anything in the others. Only App access — and its tenant-wide counterpart on a user's page — reaches your application.
Where each thing is managed
Roles are defined in one place and assigned in three, and the sidebar shows only the first. Nothing on the Roles page says where a role is held; that is decided when someone assigns it.
| Question | Where |
|---|---|
| What capabilities exist? | Access control → Permissions |
| What roles carry them? | Access control → Roles — one registry, every role assignable at any width |
| What kinds of thing can a role be scoped to? | Access control → Resources — the type registry, like document |
| Who holds a role tenant-wide? | Directory → Users → the person → Roles |
| Who holds a role inside an organization? | The organization's own page, at /organizations/{id} on your tenant, → App access — its owner or admin does this, not the dashboard |
| Who holds a role on one resource? | Access control → Resources → the type → the resource (or the resource-grants API) |
The same editor can be held all three ways at once by different people. A
role's page shows how many assignments carry it, across every width, so an edit
to what it carries is made knowing whom it reaches; it does not yet break that
number down by width.
1. Register the permissions your app checks for
A permission is a string. Register the ones your code will check, so the rest of the system can offer them for selection instead of asking someone to type them.
Dashboard: Access control → Permissions → Register permission. Click a permission in the list to edit its description or remove it.
API:
POST /api/identity/{tenantId}/permissions
{ "key": "posts:write", "description": "Publish and edit posts" }Rules:
Lowercase letters, digits, and
. : _ -separators, starting with a letter. Up to 120 characters.Up to 500 keys per tenant. The cap is what keeps a token carrying a subject's permissions bounded by construction rather than by truncating it. The 501st registration is refused as a validation error on the
keyfield — the message states the cap, so do not parse it for which key was rejected.No word is reserved. Your keys name capabilities in your own application, so you may register anything the format allows —
identity.andudibo.included. A key of yours that happens to read like one of ours means nothing here: every key is resolved only within the tenant that registered it.The key is not editable, because roles reference it by value. Delete and re-register to rename; the key frees for reuse immediately.
Deleting a permission does not edit your roles. A role still listing it simply stops conferring it.
2. Define roles that carry them
Dashboard: Access control → Roles → Create role, then tick the permissions it carries. Click a role in the list to change what it carries or retire it. An organization's own roles are on that organization's page under its Roles tab, the same way.
Retiring a role, or changing its slug, is refused while anything still holds
it. The API answers 409 — naming how many members, pending invitations and
grants still hold the role — and the dashboard shows the same refusal as a
notice on the role's page or its edit form. Memberships, invitations and group
grants narrowed to a role (@org:acme#editor) name the role by its slug, so a
rename would leave them naming a role that no longer exists; editing the name,
description or permissions while keeping the slug is always fine. Retirement
neither demotes nor revokes: reassign or remove the holders first, then retire
or rename. Once nothing holds it, the delete is a soft one and the slug frees
for reuse.
API:
POST /api/identity/{tenantId}/organization-roles
{ "slug": "editor", "name": "Editor", "permissions": ["posts:write"] }A role carrying nothing is a legitimate starting point. A role naming a
permission you have not registered is refused, by index — permissions.1 tells
you which entry is wrong, not just that one of them is.
One registry, three widths
Every role on the Roles page is defined once and can be held at any width:
across the tenant, inside one organization, or on one resource. You do not
define a tenant version and an organization version of editor; you define
editor and decide the width each time you assign it. A tenant-wide or
organization assignment resolves into the same permissions claim; a resource
assignment is answered by POST /api/check only, since resource grants never
ride the credential. Either way your application never needs to know how someone
came to hold it.
An organization's page also has an Organization roles section where its manager can compose roles of their own, from the same registry. Those roles are granted on the same page under App access — the picker lists the tenant's roles and the organization's own, told apart — and count inside that organization only: one cannot be held tenant-wide, on a resource, or in another organization. Define the vocabulary your whole application shares on the Roles page, and leave an organization's own roles to what only that organization needs.
Editing a role changes it for everyone holding it
This is the sharp edge, and it is deliberate. Resolution reads the role definition on every request, so adding a permission widens it for every holder immediately, with no assignment changing. That is what makes roles worth having rather than a list of per-person grants — and it is why every definition write is audited with the full resulting permission set, so a widening is one diffable row in your audit log.
3. Assign a role to someone
Three widths, and the difference is the whole point.
Tenant-wide — the role applies everywhere, including inside every organization. This is what a product without organizations wants.
Dashboard: Directory → Users → the person → Roles.
API:
POST /api/identity/{tenantId}/users/{userId}/roleswith{ "roleId": "..." }.
Inside one organization — the role applies only there. The same person can hold different roles in different organizations, and holds nothing from membership alone. Organizations covers the organization itself: creating one, inviting members, and how a credential comes to be scoped to one of them.
Dashboard: the organization page → App access. Granting here is the organization's own manager's job, not yours — you define the vocabulary, they decide who gets what.
API: the same route with
{ "roleId": "...", "organizationId": "..." }.
On one resource — the role applies only to that resource. First register the
kinds of thing you scope authority to (Dashboard: Access control →
Resources → Register resource type). A type key is a word like document:
lowercase letters, digits and _ - separators, starting with a letter, up to 64
characters, and up to 100 types per tenant. You never register the individual
resources — a grant against { type, id } creates the record on first use, and
the id is whatever your own system calls it, up to 255 characters.
POST /api/identity/{tenantId}/resource-grants
{ "subject": { "type": "user", "id": "..." },
"roleId": "...",
"resource": { "type": "document", "id": "readme" } }The subject can be a user or an organization — an organization grant admits
every accepted member, so you hand a whole team a resource in one call. An
organization subject may also name role: which of its members exercise the
grant (the Zanzibar userset subject, org:acme#admin). That is a built-in
organization role — owner, admin, member, where a higher role satisfies a
lower one — or one of your own tenant-wide role slugs, matched exactly: the
same vocabulary a membership can carry, so a slug no membership could hold is
refused 400 rather than stored as a grant that confers to nobody. Omit it and
every accepted member holds the grant; '' is not the member role. A grant
with a role and one without are two distinct grants on the same resource,
revoked independently, and each listed row carries its subjectRole (null for
every member). The role is roleId or builtInRole — exactly one. A
built-in role (owner, admin, member, support) needs no definition of
yours: it carries a fixed permission set and cannot be retired, and it counts
everywhere a defined role would, including in the roles claim. The four are a
ladder — owner above admin above support above member — and support is
the read-only support seat: every non-sensitive read permission plus
identity.users.impersonate, and no write, secret or export. Granting or
revoking a built-in role requires holding it — or a higher one — at that scope
yourself, and never less than admin, whether directly, tenant-wide, or through
an organization grant that admits you — the grant's own role decides which of
its members that is, exactly as it decides who holds the grant's permissions, so
a grant naming no role lets any accepted member curate at that rank; an
administrator's seat over the tenant stands in when they are not one of its
users. Otherwise the call is refused 403. Repeating a grant returns the
existing one rather than creating a second — but it still answers 201, so the
status code does not tell you whether anything was written.
POST /api/identity/{tenantId}/resource-grants/revoke takes the same body back
off again.
A grant outlives nothing. An organization holding a live grant cannot be
deleted: the delete is refused 409 and names the resources that block it,
because a group grant resolves only through a live organization and deleting one
would cut every member's access silently. Revoke or move what it holds first —
see organizations.md.
Placing a grant is an administrator's call, not an end user's. There is no
self-serve grant surface for your users. An administrator places one from the
dashboard or through the API above — the two run the same checks and write the
same audit event. Today the API accepts the credential your dashboard session
carries and not a machine credential minted in your own tenant, so a production
application cannot place a grant on its own yet. An application permission such
as documents:share does not grant authority to call this management endpoint.
The dashboard browses what authorization holds, never what you have. The Resources list shows, per type, how many resources carry a live grant and how many grants that is. A type's page lists those resources — most recently granted first, with the grant count and who holds something there — and each opens the resource's own page: every grant on it, a Revoke beside each, and a form to grant a role to a user (by email, username or id) or to an organization (by slug or id, optionally narrowed to the members holding a role). A box on the type page opens any resource by the id your application uses, whether or not anything has been granted on it yet, so a first grant can be placed there. Read the list as what authorization has touched, never as an inventory: a resource appears once something is granted on it and drops out when its last grant is revoked, so nothing you have never shared is there, and something you deleted still is while a grant survives. Your own database remains the record of what exists. The list offers Next and Previous and no total beyond the type's counts, and there is no search over resource ids — an exact id opens a resource; a prefix would need an index this table does not carry.
The type's activity log is a page of its own. Every grant written and every grant revoked against resources of that type, most recent first, with the holder, the role, and when; each entry links to the resource's page. It is a log, not an inventory, for the same reason as above, and it carries no total count because counting it would mean scanning every grant of the type.
Removing a resource type is refused while live grants sit on live resources of
that type. The API answers 409 — naming how many live grants, accepted or
still pending, are scoped to resources of that type — until you revoke them; the
dashboard shows the same refusal as a form error on the type's page. Two kinds
of grant are not counted. A grant whose role has been retired confers nothing
and does not block; it is retired along with the type, so restoring the role
later cannot bring it back. And a grant on a resource record that is no longer
live resolves to nothing, so it does not block either — the count is over what
still resolves, not over every row that ever named the key. Nothing in the
product removes a resource record today (one is created on first grant and
kept), so in practice every grant you have not revoked, carrying a role you have
not retired, counts. Once nothing live references it, the delete is a soft one:
checks naming that type start being refused and the key frees for reuse.
One name is unavailable, and it is not ours. You cannot register a resource
type called organization, because your tenant already scopes roles to
organizations — a resource type by that name would be a second, different thing
with the same name, and a check request could not say which you meant. Every
other word is yours, tenant and identity_tenant included.
Assignment is idempotent: assigning a role someone already holds returns the existing assignment rather than an error, so a retried request is safe.
Revoking (DELETE .../roles/{roleId}) stops the role counting immediately: the
next introspection, UserInfo response or check no longer sees it. What was
already signed — a JWT access token, an id_token — carries what it carried
until it is re-issued; see below.
Worked examples
Three shapes cover most applications. They are not modes; a tenant can use any of them together, and a check unions whatever applies.
Plain roles, no organizations
A single-workspace app: every user belongs to the tenant and nothing else.
Register notes:read, notes:write and users:manage; define member
carrying the first, editor the first two, admin all three; assign each
person one of them tenant-wide from their user page. The token's
permissions claim carries the union, and your code reads it offline. That is
the whole of RBAC, and a tenant that never registers a resource type or creates
an organization stops here.
Organizations, with a role that means something inside each
A B2B app where each customer company is an organization. The same editor from
the Roles page is granted to Bob inside Acme from Acme's App access section,
and to nobody else. When Bob's active organization is Acme his token carries
notes:write; when he switches to another organization it does not. A
tenant-wide support-agent role assigned to your own staff still counts inside
every organization, because tenant-wide never narrows.
One document shared with one person
A note-taking app where a note has an owner and can be shared. Register the
types note and folder on the Resources page, and define note-owner,
editor and viewer on the Roles page. When Alice creates note n_123, an
administrator grants her note-owner on { "type": "note", "id": "n_123" } —
today from the dashboard or with a dashboard-session credential, see Placing a
grant above; when she shares it with Bob, editor on the same resource; with
her whole team, viewer to the organization Acme (every accepted member, or
only its admins). Your database keeps the note; Udibo keeps who may do what to
it. Rendering Bob's list is one batch check over the ids you already have.
Several resource types that each want an "editor"
Permission keys are yours, and a check always names the resource it is about, so there are two clean ways to do this and one to avoid.
Generic verbs, one role. Register
read,writeandshare; defineviewer,editorandowneronce.editoronnote:n_123andeditoronfolder:f_7are the same role on different resources, and your code askswriteon whichever it is rendering. This is the simplest and reads well when every type supports the same verbs.Type-prefixed keys, one role per type. Register
notes:writeandfolders:write; definenote-editorandfolder-editor. Use this when the types genuinely differ in what editing means, or when a tenant-wide holder of one must not get the other.Avoid a single
editorthat carries every type's keys —notes:writeandfolders:writein one role. It works, and a note grant carryingfolders:writeis harmless because a check on a note never asks for it, but every widening to one type silently widens the other, and tenant-wide holders get both.
Roles do not inherit across types either way: editor on a folder says nothing
about the notes in it (see Limits today).
Reading the answer
The permissions claim
A credential we issue for a person carries a permissions claim: a sorted
array of the keys that subject holds, resolved for the organization the
credential is scoped to plus their tenant-wide roles. The claim is absent
when the subject holds nothing, so your code can treat missing and empty alike.
Three things bound where you will find it. A client_credentials credential
carries no permissions claim at all — a machine has no roles and no self. An
opaque access token carries no claims at its own surface, so read the set
off the introspection response; a JWT access token, the id_token and the
UserInfo response all carry it directly. All four resolve it through the same
computation, for the same organization — the one the credential was issued for.
What differs between them is only when the set inside that organization was
resolved.
interface SubjectClaims {
sub: string;
permissions?: string[];
}
export function holds(claims: SubjectClaims, permission: string): boolean {
return claims.permissions?.includes(permission) ?? false;
}This is the fast path, and for most applications it is the only one you need. How fresh it is depends on which surface you read it from:
The introspection and UserInfo responses resolve it when you ask. Each re-resolves the set from your assignments as they stand, so a role granted a moment ago already counts and a revoked one has already stopped.
A JWT access token and the
id_tokencarry what was resolved when they were signed. A role granted after that is not in them until the credential is re-issued — the trade the format makes for validating with no round trip.A copy your own application kept is as old as the copy — a session projection built once at sign-in, a store in the browser. This is the staleness most applications actually meet; re-read the claim instead.
Which organization the set was resolved for is settled when the credential is
issued. A credential carries one organization — the one named explicitly on
its refresh, else the one the login session had picked at issuance — and every
surface resolves the set for that organization for as long as the subject
still holds it. So an introspection or UserInfo response — or an introspection
of the family's refresh token — cannot answer for a different organization than
the id_token issued alongside it, and switching organizations changes what the
next credential carries rather than what an outstanding one reports. If the
membership behind it is revoked, the organization claims drop and the set
narrows to your tenant-wide roles — never another organization's.
POST /api/check
For the cases the claim cannot cover — an organization the credential does not carry, any resource-level question (resource grants never ride the credential), and a live answer for a JWT reader, whose claims were fixed when the token was signed — ask us directly.
POST https://{your-tenant-host}/api/check
Authorization: Bearer {the user's access token}
{ "permissions": ["posts:write", "posts:delete"],
"resource": { "type": "organization", "id": "..." } }{
"subject": "...",
"resource": { "type": "organization", "id": "..." },
"results": { "posts:write": true, "posts:delete": false }
}Omit resource and you get the credential's own scope — the organization the
token was issued for plus tenant scope, which is exactly what its permissions
claim resolved, so you never have to echo org_id to keep the two agreeing. A
token issued for no organization still gets tenant scope alone. The organization
is read from the token; no request field can name it, so asking about a
different one still means naming it in resource. The resource in the answer
says which scope was used, and is null only when the answer really was
tenant-wide.
So a resource-less answer is credential-dependent, and you must not apply it
tenant-wide. Two people using your application hit this endpoint with the same
body and get answers scoped to different organizations, because their tokens
carry different ones. Read the resource the answer comes back with, or name
the scope you meant — resource: { type: "organization", id } for a
per-organization gate, your own resource for a per-object one. Both are
unchanged by this and say what they mean.
Name one of your registered resource types instead of organization to ask
about one of your own resources:
"resource": { "type": "document", "id": "readme" }. permissions also accepts
a bare string for the one-permission case, and up to 50 keys in one request.
async function check(
host: string,
accessToken: string,
permissions: string[],
resource?: { type: string; id: string },
): Promise<Record<string, boolean>> {
const response = await fetch(`https://${host}/api/check`, {
method: "POST",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
},
body: JSON.stringify(
resource ? { permissions, resource } : { permissions },
),
});
if (!response.ok) throw new Error(`check failed: ${response.status}`);
const answer = await response.json() as {
results: Record<string, boolean>;
};
return answer.results;
}The list question. When you are rendering a page of resources and need to know which ones the caller may act on, ask once instead of once per row:
POST https://{your-tenant-host}/api/check/batch
{ "permissions": ["docs:edit"],
"resource": { "type": "document", "ids": ["readme", "roadmap", "..."] } }async function checkMany(
host: string,
accessToken: string,
permissions: string[],
resource: { type: string; ids: string[] },
): Promise<Record<string, Record<string, boolean>>> {
const response = await fetch(`https://${host}/api/check/batch`, {
method: "POST",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ permissions, resource }),
});
if (!response.ok) throw new Error(`batch check failed: ${response.status}`);
const answer = await response.json() as {
results: Record<string, Record<string, boolean>>;
};
return answer.results;
}The answer is keyed by id, then by permission. Up to 100 ids of one
registered type — the batch has no organization branch, so organization is
not a type it accepts; ask about an organization on /api/check. Each id is
answered exactly as /api/check would answer it alone. (In our own measurements
the batch answers 100 candidates for the cost of about one-and-a-half single
checks.)
Three things about these endpoints worth knowing:
It is on your own host, not
www.udibo.com— the same origin as the token endpoint that issued the credential. Your users' tokens live in your tenant, and that is where they are validated.It requires no scope. Authentication is the whole gate, because the answer is strictly less than the caller's own token already tells them.
It answers only for the caller. There is no field naming somebody else, and a
client_credentialstoken is refused — a machine credential has no self, and defaulting to the application's owner would answer about a person who did not call.
What we promise
We never interpret your permission strings. Not for billing, not for features, not for support tooling. They are opaque to us by design.
A role of yours can never confer Udibo dashboard authority, whatever its permissions are called. Authority over a tenant comes from a grant over that tenant, never from a role one of your subjects holds — so the two can share a word without sharing a meaning.
We run on the same seam you do. Udibo's own dashboard authority is a grant on an
identity_tenantresource of Udibo's tenant, written through the sameassignRoleand read through the samepermissionsForyour grants are; there is no private path for our own use. An organization that owns a tenant holds it asorg:<id>#admin, so only its owner/admin members administer it — the userset subject above, applied to ourselves.A definition role placed or widened by one of the tenant's own users must be within what they hold at that scope. Assigning a
roleId— on a resource, inside an organization, or tenant-wide, through the API or the dashboard — is refused403when the caller is a user of the tenant and does not hold, at the target scope, everyidentity.*permission the role carries; your own permission keys are never bounded — and an invitation naming aroleIdis refused on the same rule as an outright grant. Editing a role that is already placed is bounded the same way, at every scope it is placed at, counting a placement from the moment it is written: an invitation nobody has accepted yet bounds the edit exactly as an accepted one does, because accepting re-checks nothing. A revoked placement stops bounding it, since it confers nothing and cannot come back except through a placement that runs the rule again. An administrator working the tenant from their seat confers on the tenant's behalf. Revoking the last owner or the last admin of a Udibo-managed tenant throughPOST …/resource-grants/revokeis refused409, exactly as it is on the Administrators page.Every write is audited in your tenant's log, with the permission set on role writes and the role and scope on assignments — resource grants included (
admin.resource_grant.*,admin.resource_type.*).
Limits today
A permission is held or not held — there are no negative permissions and no precedence rules. Two roles carrying overlapping sets simply union.
Roles do not inherit. Udibo's own built-in access levels do (owner satisfies admin), but a role you define matches exactly and implies nothing — including across resources: there is no "editor on the project implies editor on its documents" derivation yet. Express the wider grant directly.
A resource assignment names a role, not a bare relation, the same as every other width: you grant "the role that carries what editing means," not a standalone
#editoredge.A role's page counts its holders across every width without saying which. The type pages list resources with a preview of their holders, and an organization's own App access lists what its members hold there; neither is a per-role view.
A held role cannot be retired or re-slugged, and nothing does either for you. The refusal counts what holds it; reassigning or removing each holder is a separate action on the member, invitation or grant.
Last verified 2026-09-13.

