Role-based access control
Contexts, roles, scopes and grants — how a permission decision is actually made.
Most access-control bugs come from a model that answers "what is this user?" when the real question is "what is this user here?". nulvless only ever answers the second one.
There is no global "admin" flag. Permission always exists at the intersection of a user and a resource.
The four pieces
contextstringA kind of resource — "org", "project". Roles are defined inside one.
roleRoleA named bundle of scopes, belonging to exactly one context.
scopestringOne capability — "members:write". What your code actually checks.
grantGrantOne user holding one role on one resource. The thing that exists at runtime.
Read it as a sentence:
Grace holds the owner role — a role defined in the org context, carrying the scopes
members:writeandbilling:read— on the resource acme.
Change the resource and it is a different grant. Grace can be owner on acme
and hold nothing at all on globex, with no special-casing in your code.
Nothing here is called an organization
Most identity products ship an opinion: users belong to organizations, an organization has members, and if your product is shaped differently you fight the model. nulvless has no such entity.
context and resource are strings you choose. The platform stores no
schema for them and never validates that acme exists — it only records that a
grant points at it. That is what makes one mechanism cover every shape:
| Your concept | context | resource |
|---|---|---|
| Organizations | org | the org slug — acme |
| Projects | project | the project id — proj_16tbwq34 |
| Workspaces | workspace | the workspace uuid |
| Stores in a marketplace | store | the store id |
| A single document | document | the document id |
| Your own back office | platform | a constant, e.g. internal |
Nothing about the platform changes between those rows. You are not modelling organizations — you are saying this role applies to things of this kind, and this grant applies to that specific one.
How the pieces connect
context "project" ← a kind of thing, named by you
│
└── role "editor" ← defined inside exactly one context
│ key: 'editor'
│ scopes: ['project:write', 'project:read']
│
└── grant ← the role, a user, and one concrete resource
user: usr_8f21
resource: 'proj_16tbwq34'- The context ties a role to a kind of thing. An
editorrole inprojectcan never be granted meaningfully on an org — the roles you list for a context are the only ones that belong there. - The role carries the permissions. Scopes live on the role, not on the user, so changing a role moves everyone holding it at once.
- The grant supplies the specific thing. It is the only place a concrete resource identifier appears.
A role is therefore reusable by construction: editor is defined once and
granted on a thousand projects. The role has no idea which ones.
Because resources are opaque, hierarchy is yours
The platform sees acme and proj_16tbwq34 as unrelated strings — it does not
know one contains the other. Grants never cascade.
That is a constraint and a feature. It means you can encode any hierarchy you like, including none, but you must check the levels you want to honour. If organization owners should reach every project inside, say so explicitly — see nested resources.
Choosing scope names
Scopes are yours too. The only rule that matters is that they name a capability, not a screen or a role:
// Good — survives a redesign.
['members:read', 'members:write', 'billing:read', 'projects:delete']
// Bad — a UI detail and a role wearing a scope's clothes.
['settings-page', 'is_admin']A resource:action convention reads well and groups naturally, but nothing
enforces it.
Context is a property of the role, not of the grant
This trips people up. When you invite someone you name a resource and a
role id. The context came along with the role when the role was defined —
you never pass it to invite.
// Roles are defined once, per context.
await nulvless.auth.admin.roles.create({
name: 'Administrator',
key: 'admin',
context: 'org',
scopes: ['members:read', 'members:write', 'projects:read'],
});
// Grants point at a resource and reuse a role.
await nulvless.auth.admin.grants.set({
resource: 'acme',
granted_to_user_id: userId,
role_id: adminRoleId,
});How a decision is made
One call answers both questions — is the token still valid, and does its holder have what you named:
const { data, error } = await nulvless.auth.verify(
{ resource: 'acme', roles: ['owner', 'admin'] },
{ headers: { Authorization: `Bearer ${token}` } },
);resourcestringOptionalWhich resource the question is about. Omit to check the token only.
rolesstring[]OptionalMatched against the role key. Any one of them is enough. Ignored unless
resource is given.
scopesstring[]OptionalRequired scopes. The holder must have all of them.
Ask, do not fetch and decide
Fetching the user's grants and comparing them in your own code re-implements the
decision — and drifts the moment a role changes. Name the requirement and let
verify answer.
The four ways to ask
// 1. Is this token still valid? Nothing about permissions.
await nulvless.auth.verify(undefined, { headers });
// 2. Does the holder have a capability here?
await nulvless.auth.verify({ resource: 'acme', scopes: ['members:write'] }, { headers });
// 3. Does the holder have one of these roles here?
await nulvless.auth.verify({ resource: 'acme', roles: ['owner', 'admin'] }, { headers });
// 4. Does the session itself carry these scopes, independent of any resource?
await nulvless.auth.verify({ scopes: ['platform:support'] }, { headers });The two dimensions do not combine the same way:
| Semantics | |
|---|---|
scopes | All of them are required. |
roles | Any one of them is enough. |
So scopes: ['members:read', 'members:write'] demands both, while
roles: ['owner', 'admin'] accepts either.
Two things that surprise people
roles matches the role's key, not its name. A role created with
key: 'admin' and name: 'Administrator' is checked as 'admin'.
roles is ignored when you omit resource. Without a resource there is no
grant to read a role from, so only scopes are evaluated — against the
session's own scopes. A roles-only check with no resource silently passes.
Telling "not signed in" from "not allowed"
verify separates the two, and your API should preserve the difference:
const { data, error } = await nulvless.auth.verify(
{ resource, scopes: ['members:write'] },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (error?.status_code === 401) {
// The token is missing, expired or revoked → send them to sign in.
}
if (error?.status_code === 403) {
// Authenticated fine, but does not hold it → show a forbidden state.
}Collapsing both into 401 is a common bug: it bounces a signed-in user back to the login screen, where signing in again changes nothing.
A 403 also carries a code, and one of them is worth handling on its own:
forbidden_resource_access403No grant on this resource, or it is blocked, or a required scope or role is missing.
forbidden_access_waiting_accept403A grant exists but is still invited or requested — the invitation was never
accepted.
That second code is the difference between "you have no access" and "you have an invitation sitting in your inbox", and it is worth saying so in the UI.
What comes back
{
user: { id },
session: {
id,
scopes, // the effective scopes on this resource
amr, // how they authenticated: password, otp, totp…
sudo, // whether sudo is currently active
sudo_expires_at,
},
}Only a grant whose status is granted ever passes. An invitation that was never
accepted is inert, and a blocked grant denies.
session.scopes is the resolved set for that resource — useful for driving a UI
that hides what the user cannot do, after the server has already decided.
Roles versus scopes in the check
Check scopes, not roles, wherever you can. A role is a label your customers rename; a scope is the capability your code depends on.
// Brittle — breaks when a role key changes or a new role should also pass.
await nulvless.auth.verify({ resource, roles: ['admin'] });
// Durable — keeps working as roles are reorganised.
await nulvless.auth.verify({ resource, scopes: ['members:write'] });Roles are the right check when the rule genuinely is about seniority — "only an owner can delete the organization".
One-off permissions
A grant does not need a role. Omit role_id and it carries bare scopes — useful
for access that no role should describe, like a single automation or a one-time
exception:
await nulvless.auth.admin.grants.set({
resource: 'acme',
granted_to_user_id: botUserId,
scopes: ['projects:read'],
});You can also add scopes on top of a role for one person:
await nulvless.auth.grants.update(grantId, {
scopes: ['members:read', 'billing:read'],
});Both are exceptions, and exceptions are invisible on a roles screen. If you cannot answer "who can see billing?" by reading the roles alone, the model has started to drift — prefer a new role once the same exception appears twice.
The grant list is your tenant list
A pattern worth naming, because it removes a table most teams build: to list the organizations a user belongs to, list their grants in that context.
const { data: grants } = await nulvless.auth.grants.list('org');
const slugs = grants.map((grant) => grant.resource);
const organizations = await db.organizations.findBySlugs(slugs);There is no membership table to keep in sync, because membership is the grant — one less thing in your schema that can disagree with permissions.
Seeding the first grant
The person who creates a resource should not have to accept an invitation to it. Create the grant as the platform, in the same transaction that creates the resource:
await db.transaction(async (tx) => {
const org = await tx.organizations.create({ slug, name });
await nulvless.auth.admin.grants.set({
resource: org.slug,
granted_to_user_id: creatorId,
role_id: OWNER_ROLE_ID,
});
return org;
});admin.grants.set skips the invitation entirely — the grant exists immediately.
Keeping it in the transaction means a failure here leaves no orphaned resource
that nobody can administer.
Cleaning up
When a resource is deleted, its grants must go too, or they outlive what they pointed at:
await nulvless.auth.admin.grants.deleteByResource(org.slug);Acting as the user, or as the platform
Two namespaces do overlapping things, and picking the wrong one is a real mistake:
| Acts as | Use for | |
|---|---|---|
auth.grants | The signed-in user | Anything a person does — inviting, accepting, leaving |
auth.admin.grants | The platform | Seeding owners, cleanup, back-office tooling |
auth.grants enforces the caller's own permissions and records them in the
audit trail, so forward their headers:
import { forwardUserHeaders } from '@nulvless/nulvless-js';
await nulvless.auth.grants.invite(params, {
headers: forwardUserHeaders(req),
});auth.admin.grants bypasses that by design. It is a back-office tool, not a
shortcut for user actions — a grant it creates carries no record of who asked.