Skip to content

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:

WhereGovernsWho manages it
Membersthe organization itself — invite, remove, renamethe organization
App accessyour application — this documentthe organization
Organization roleswhat a member can do, as this organization defines itthe 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.

QuestionWhere
What capabilities exist?Access controlPermissions
What roles carry them?Access controlRoles — one registry, every role assignable at any width
What kinds of thing can a role be scoped to?Access controlResources — the type registry, like document
Who holds a role tenant-wide?DirectoryUsers → 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 controlResources → 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 controlPermissionsRegister 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 key field — 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. and udibo. 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 controlRolesCreate 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: DirectoryUsers → the person → Roles.

  • API: POST /api/identity/{tenantId}/users/{userId}/roles with { "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 controlResources → 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, write and share; define viewer, editor and owner once. editor on note:n_123 and editor on folder:f_7 are the same role on different resources, and your code asks write on 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:write and folders:write; define note-editor and folder-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 editor that carries every type's keysnotes:write and folders:write in one role. It works, and a note grant carrying folders:write is 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.

TypeScript
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_token carry 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": "..." } }
JSON
{
  "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.

TypeScript
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", "..."] } }
TypeScript
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_credentials token 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_tenant resource of Udibo's tenant, written through the same assignRole and read through the same permissionsFor your grants are; there is no private path for our own use. An organization that owns a tenant holds it as org:<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 refused 403 when the caller is a user of the tenant and does not hold, at the target scope, every identity.* permission the role carries; your own permission keys are never bounded — and an invitation naming a roleId is 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 through POST …/resource-grants/revoke is refused 409, 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 #editor edge.

  • 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.