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 time | Register permissions, then define and assign roles |
| Find the right dashboard or API | Where each thing is managed |
| Decide how to model a team, project, or document | Worked examples |
| Enforce access in a backend request | Read the answer |
| Let your backend place or revoke resource grants | Prepare a machine client |
| Check a specific organization or resource now | Use the hosted check endpoint |
| Evaluate what the model supports | Guarantees 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:
| Kind | What it governs | Where to start |
|---|---|---|
| Membership tier | Whether an organization member may manage its members and settings | The organization's Members tab |
| Application role | Permissions your own application reads, at one of the three widths in this guide | Access control → Roles to define it; assign it at the appropriate width |
| Dashboard access | What your team may configure in Udibo | Give 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.
| 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? | 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
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 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}/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: 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}/roleswith{ "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:
| Operation | Scope to allow and request | Machine permission |
|---|---|---|
| List grants | identity:organizations:read | Read resource grants (resource_grants.read) |
| Place or revoke grants | identity:organizations:write | Write 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,writeandshare; defineviewer,editorandresource-owneronce.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.
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.
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": "..." } }{
"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. On a Udibo-managed tenant, granting or revokingownerthroughPOST …/resource-grantsorPOST …/resource-grants/revokeis refused409, since ownership moves only by transfer, and revoking its last admin is refused409, exactly as on the Team page. Theownerrole 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
#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 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-grantsis refused400; 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.

