Authorization recipes
Concrete scenarios — multi-tenant SaaS, nested resources, external collaborators, service access.
Each of these is a shape teams keep rebuilding. They are all the same four pieces from the model arranged differently.
Multi-tenant SaaS
The default shape: users belong to organizations, and hold a role in each.
Define the roles once, per context.
const context = 'org';
await nulvless.auth.admin.roles.create({
name: 'Owner', key: 'owner', context,
scopes: ['org:delete', 'billing:write', 'members:write', 'projects:write'],
});
await nulvless.auth.admin.roles.create({
name: 'Admin', key: 'admin', context,
scopes: ['members:write', 'projects:write'],
});
await nulvless.auth.admin.roles.create({
name: 'Member', key: 'member', context,
scopes: ['projects:read'],
});Remember that verify matches roles by key, so it is 'owner' and 'admin'
that appear in checks — not 'Owner'.
Seed the creator as owner, transactionally, so a resource never exists without an administrator:
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: ownerRoleId,
});
return org;
});List tenants from grants, not from a membership table:
const { data: grants } = await nulvless.auth.grants.list('org');
const orgs = await db.organizations.findBySlugs(grants.map((g) => g.resource));Check on every request, naming the resource:
const { error } = await nulvless.auth.verify(
{ resource: req.params.org, scopes: ['projects:write'] },
{ headers: { Authorization: `Bearer ${token}` } },
);Nested resources
An organization containing projects, where some people should reach one project but not the whole organization.
Use two contexts, and check the one the route is actually about:
// Roles in 'org' for organization-wide access.
// Roles in 'project' for a single project.
await nulvless.auth.admin.roles.create({
name: 'Project editor', key: 'editor', context: 'project',
scopes: ['project:write'],
});A project-scoped grant names the project as its resource:
await nulvless.auth.admin.grants.set({
resource: project.id,
granted_to_user_id: userId,
role_id: projectEditorRoleId,
});Grants do not cascade. An owner on acme holds nothing on acme/website
unless a grant says so. If organization owners should reach every project, check
both explicitly — one call for the project, and a fallback to the organization.
async function canEditProject(token: string, org: string, project: string) {
const direct = await nulvless.auth.verify(
{ resource: project, scopes: ['project:write'] },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!direct.error) return true;
const viaOrg = await nulvless.auth.verify(
{ resource: org, scopes: ['projects:write'] },
{ headers: { Authorization: `Bearer ${token}` } },
);
return !viaOrg.error;
}Making that inheritance explicit is deliberate: a permission that arrives from two levels up is exactly the kind that surprises people during an audit.
External collaborators
A contractor who should see one project and nothing else needs no special mechanism — that is just a grant with a narrow role on one resource.
await nulvless.auth.grants.invite(
{
invites: [{ email: '[email protected]', role_id: viewerRoleId }],
resource: project.id,
message: project.name,
},
{ headers: forwardUserHeaders(req) },
);They never appear in the organization's member list, because they hold no grant on the organization. When the engagement ends:
await nulvless.auth.grants.revoke({ id: grantId, nonce });One person, two hats
The same user holding different roles in different places needs nothing special.
// owner of their own company
{ resource: 'grace-consulting', role: { key: 'owner' } }
// member of a client's org
{ resource: 'acme', role: { key: 'member' } }Every check names a resource, so the answer differs per resource without a single conditional in your code. This is the payoff of refusing a global admin flag.
Granting one extra capability
Someone needs one thing their role does not carry. Two options, and the second is usually wrong:
Add a scope to the grant — a per-person exception:
await nulvless.auth.grants.update(grantId, {
scopes: ['members:read', 'billing:read'],
});Or make a role for it — reusable and auditable:
await nulvless.auth.admin.roles.create({
name: 'Finance', key: 'finance', context: 'org',
scopes: ['billing:read', 'billing:write'],
});Prefer the role. Per-grant scopes are invisible on a roles screen, so a year later nobody can answer "who can see billing?" without walking every grant.
Back-office and support tooling
Your internal staff are not members of your customers' organizations, and should not be given grants there — that would put them in the customer's member list.
Use auth.admin from your own back-office service, which acts
as the platform rather than as a user, and gate it with your own staff
authentication.
const { data } = await nulvless.auth.admin.grants.list({ resource: 'acme' });auth.admin carries no record of which staff member asked, because it acts as
the platform. Log that on your side — the audit trail cannot recover it for you.
Deleting a resource
await db.transaction(async (tx) => {
await tx.organizations.delete(slug);
await nulvless.auth.admin.grants.deleteByResource(slug);
});Skipping this leaves grants pointing at nothing — harmless until a slug is reused, at which point the old members quietly reappear.