Skip to content
Udibo
On this page
View Markdown

Permissions and roles

Control what people can do in your own application. This guide covers what to register in Udibo Identity, how the answer reaches your code, and the guarantees you can build on.

Find your task

You want to…Start here
Add access control for the first timeRegister permissions, then define and assign roles
Find the right dashboard or APIWhere each thing is managed
Decide how to model a team, project, or documentWorked examples
Enforce access in a backend requestRead the answer
Let your backend place or revoke resource grantsPrepare a machine client
Check a specific organization or resource nowUse the hosted check endpoint
Evaluate what the model supportsGuarantees and current limits

New to tenants, organizations, or tokens? Read the concepts first. For a complete application, use the client portal walkthrough.

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 and resolve current permissions through introspection, UserInfo, or /api/check. A JWT access token keeps the permissions recorded when it was signed, and the id_token carries none; see Reading the answer for the scopes and freshness of each surface. 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

Keep these separate when you configure access:

KindWhat it governsWhere to start
Membership tierWhether an organization member may manage its members and settingsThe organization's Members tab
Application rolePermissions your own application reads, at one of the three widths in this guideAccess control → Roles to define it; assign it at the appropriate width
Dashboard accessWhat your team may configure in UdiboGive your team dashboard access

An organization's owner, admin, or member tier does not automatically grant application permissions. Assign an application role separately. Giving someone dashboard access also does not give their end-user account access to 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 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?Directory → Organizations → the organization → Members → the person's App roles; or the caller-relative organization API in your application
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.

The API and Management API entries below name management API operations for reference. They accept only the dashboard's own session or a token from Udibo's own sign-in, which you cannot get outside the dashboard. A token your tenant issues, including a client-credentials token, is refused with 401; see the management API.

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 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 takes it away from everyone at once: the next introspection, UserInfo response or check leaves it out, and no newly signed JWT access token carries it. Your roles are not edited. A role still listing the key keeps the entry, which its page marks as no longer registered, and confers nothing through it. Registering the same key again restores it to every role that still lists it, so remove it from those roles first if you are reusing the name for something else.

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.

The slugs owner, admin and member are reserved for organization membership tiers. Use other slugs for application roles, such as app-reader, editor or app-admin.

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.

Up to 500 tenant-wide roles per tenant. Each one a person holds is a slug in their roles claim, and in org_roles when they hold it in an organization, so the cap keeps those claims bounded the way the permission cap bounds permissions. The 501st tenant-wide role is refused as a validation error on the slug field, and the message states the cap. An organization's own roles do not count toward it. Retiring a tenant-wide role frees its place.

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 through the same member-roles route, which accepts the tenant's roles and the organization's own, 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

Resolution reads the role definition on each live introspection, UserInfo, or /api/check request, so an edit changes the next live answer for every holder in scope, with no assignment changing. Existing JWT access tokens retain their signed permission claims; newly issued credentials use the updated definition. Reading the answer explains which surface to use when you need current permissions. 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}/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: Directory → Organizations → the organization → Members. For a member who has accepted, choose App roles, select a role under Assign a role, and choose Assign. The heading identifies the organization; View tenant-wide roles switches back to assignments that apply everywhere. Use Revoke beside an assignment to remove it from this organization. Assigning or revoking requires dashboard permission to manage organizations and remains subject to the role's authority rules.

  • Your application: POST /api/organizations/{id}/members/{userId}/roles with { "roleId": "..." } on your tenant's host, called for a signed-in owner or admin of that organization. Build this into your application when your customers should decide who gets access without entering your Udibo dashboard.

  • Management API: the tenant-wide 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.

Your application places grants, with its own credential — your users never do. You decide in your own code who may share what: ask /api/check whether the person holds a key of yours, such as documents:share, on that resource, and if they do, place the grant as your application. We store it and resolve it. We never interpret your keys, and we never decide a grant from what the person holding the session happens to hold — sharing is your application's logic, not a screen we host. There is no end-user grant surface, and there will not be one.

Where your application places it: your own origin, your own credential.

POST https://{tenantId}.udibo.com/api/resource-grants
Authorization: Bearer <your application's client-credentials token>
{ "subject": { "type": "user", "id": "..." },
  "roleId": "...",
  "resource": { "type": "document", "id": "readme" } }

GET /api/resource-grants?type=document&id=readme lists every grant on one resource — what a "who has access" panel is built from — and POST /api/resource-grants/revoke takes a grant back off, naming it the way it was placed. A grant held by a deleted organization, or by a deleted or disabled user, stays in that list so you can still revoke it, and carries subjectActive: false: it confers nothing until the holder is restored, so leave it out of the panel or show it as inactive. All three take your application's own client_credentials token, minted by your own authorization server and resolving your application's service account; an end user's access token is refused 403 on every one of them, because whether a person may share, or may even see who else can, is a decision your code makes and not one we could make for you. They answer only on your tenant's own origin (udibo.com answers 404) and are never cached. Successful grant creation and revocation are audited against that service account and the client that called. Listing grants emits no audit event, and re-granting an existing tuple adds no duplicate creation event.

Prepare a machine client for resource grants

First register a separate confidential machine application, choose its Allowed scopes, and create a secret for your backend.

Authorize your service account first. Resource-grant access requires both a scope on the token and a machine permission assigned by a tenant Owner or Admin:

OperationScope to allow and requestMachine permission
List grantsidentity:organizations:readRead resource grants (resource_grants.read)
Place or revoke grantsidentity:organizations:writeWrite resource grants (resource_grants.write)

Read access does not include write access, and write access does not include read access. Configure both pairs when your backend needs both. Selecting an allowed scope does not assign the machine permission. New applications have neither machine permission.

Assign them in the dashboard. Open the application's detail page and find Machine permissions, which appears for a client-credentials application. Check Read resource grants, Write resource grants or both, then choose Save machine permissions. Only a tenant Owner or Admin sees the section, and changing it also needs the Manage organizations permission; without it the section lists what is assigned, or None assigned. Clearing both boxes and saving revokes them, including for tokens already issued, on their next request. These permissions are separate from your permission registry: registering the same names there does not authorize a bot, and ordinary application edits cannot assign them.

From your backend, request a Client Credentials token from your tenant's /api/oauth2/token endpoint with the required scopes, then use its access_token on the resource-grant endpoint on that same tenant origin. Follow the backend token-request example.

The same set is readable and replaceable at /api/identity/{tenantId}/applications/{id}/machine-permissions on www.udibo.com, but that management API route accepts only a credential from Udibo's own sign-in, which you cannot obtain outside the dashboard today; see the management API.

The worked example, end to end. Say your application lets anyone holding document:write on a document add others as readers or writers. When Alice opens the share dialog, your server asks POST /api/check whether Alice holds document:write on { "type": "document", "id": "readme" }, and renders the dialog only if she does. When she adds Bob as an editor, your server — not her browser, and not her token — calls POST /api/resource-grants with your own credential, naming Bob, your editor role and that document. We store the tuple and resolve it. document:write meant "may share" because your code said so; we never knew it, and nothing in our request path read that key.

It places your roles, not ours. The roleId comes from your own registry. The built-in ladder (owner, admin, support, member) is curated by identity — conferring one requires already holding it at that scope, and an application credential resolves no person who could — so builtInRole is refused 400 here and stays an administrator's action on POST /api/identity/{tenantId}/resource-grants.

An administrator can still place a grant, from the dashboard or the API above; the two addresses write the same rows and the same audit events, and differ only in who is allowed to call them.

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 into a JWT access token stays there 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 app-reader carrying the first, editor the first two, app-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 by Acme's owner through your application, 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 application grants her note-owner on { "type": "note", "id": "n_123" } with its own credential (see Your application places grants in §3); 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 resource-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 keys — notes: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.

Four 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 and the UserInfo response carry it directly. The id_token never carries it, nor roles or org_roles: it says who signed in, and keeping authorization out of it keeps its size bounded however many permissions and roles you register. All three surfaces that carry the set 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.

Earlier releases put permissions, roles and org_roles in the id_token. If your application reads any of them from the id_token — a session built from its claims at sign-in, or useAuthorization() from @udibo/oauth2/react behind a DirectClient, which decodes the id_token — read them from introspection or UserInfo instead. Behind a DirectClient, authorizationFromClaims(await client.getUserInfo()) builds the same Authorization from UserInfo.

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 carries what was resolved when it was signed. A role granted after that is not in it 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 org_id in 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. On a Udibo-managed tenant, granting or revoking owner through POST …/resource-grants or POST …/resource-grants/revoke is refused 409, since ownership moves only by transfer, and revoking its last admin is refused 409, exactly as on the Team page. The owner role on your own resources is unaffected.

  • 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 member roles list what its members hold there; neither is a per-role view.

  • Your application's own grant surface places a role of yours only. A built-in slug on POST /api/resource-grants is refused 400; an administrator places that one from the dashboard.

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