RBAC
Roles + permissions + the permission() handler guard. Roles compile to scopes.
@voltro/plugin-rbac adds role-based access control to Voltro. It builds on top of the existing scope system in @voltro/protocol: a role is a named bundle of permission strings, and the plugin compiles roles to scopes — there is no parallel "role axis". The permission() handler guard rejects a call when the caller's resolved scope set doesn't include the required permission, reusing the same hasScope / admin:full machinery a raw API-key scope goes through.
Install
// app.config.ts
import { rbacPlugin } from '@voltro/plugin-rbac'
export default {
type: 'api' as const,
name: 'myApi',
store: 'postgres' as const,
plugins: [
rbacPlugin({
// Declarative role → permission map. Each role lists the
// permission strings (== scope strings) it grants.
roles: {
viewer: ['notes:read', 'comments:read'],
editor: ['notes:read', 'notes:write', 'comments:read', 'comments:write'],
admin: ['*'], // wildcard — compiles to the admin:full bypass
},
}),
],
}The plugin installs an interceptor that runs once per mutation / query / action, BEFORE the executor: it resolves the caller's role slugs, compiles them to a flat scope set, merges that with the subject's raw scopes, and stashes the result where permission() reads it.
Roles compile to scopes
A role has no runtime identity once resolved — compileRoles(['editor'], roleMap) returns a flat permission-string set that IS a scope set. That set is checked through the same hasScope the framework uses for raw scopes, so a user with role editor and an API key minted with scopes: ['notes:write'] are indistinguishable to permission(). The '*' wildcard in any granted role compiles to the admin:full blanket bypass.
import { compileRoles } from '@voltro/plugin-rbac'
compileRoles(['viewer', 'editor'], {
viewer: ['notes:read'],
editor: ['notes:read', 'notes:write'],
})
// → ['notes:read', 'notes:write'] (deduped union)
compileRoles(['admin'], { admin: ['*'] })
// → ['admin:full'] (wildcard → blanket bypass)Declarative guards: on the descriptor
The framework enforces authorization declaratively from the descriptor — you don't have to remember an in-handler check (forgetting one is a silent authz hole). Add guards: to defineMutation / defineQuery / defineAction; the framework runs it in the dispatch spine before the executor (for a mutation, before the transaction even opens), fails with a typed ScopeError, and auto-merges ScopeError into the wire error union so the client decodes the denial typed.
export const updateNote = defineMutation({
name: 'notes.update',
input: Schema.Struct({ id: Schema.String, teamId: Schema.String, title: Schema.String }),
output: Schema.Struct({ id: Schema.String }),
guards: [{ scope: 'notes:write' }], // ← enforced before the executor
})The check runs against the caller's effective scope set — raw subject scopes ∪ rbac role-derived scopes — so a role that grants notes:write satisfies the guard exactly like a raw API-key scope. admin:full bypasses every guard.
- Multiple scopes, AND:
guards: [{ scope: ['notes:write', 'team:member'] }](defaultmode: 'all'). - Multiple scopes, OR:
guards: [{ scope: ['notes:write', 'notes:admin'], mode: 'any' }]. - Several guards: every entry must pass (AND across the array).
guards: is browser-safe by construction: it carries scope strings plus an optional pure resource extractor (input → id, the same discipline as target.identify) — never a server function, never a DB call. So a descriptor bundled value-level into the browser drags in nothing server-only.
guards: [{ scope: 'roadmaps:write', resource: (input) => input.teamId }]The resource extractor is passed to a resource-aware scope resolver for row/resource-scoped (ReBAC-style) checks. With no resolver registered it is advisory — the guard checks the subject's global scopes. Register one and the guard scopes to the resource (see next).
Resource-scoped guards (multi-tenant / per-team)
A subject-global guard asks "does the caller hold scope S anywhere". For a multi-tenant app whose subjects are minted with scopes: [] and whose real permissions are per-resource (owner in team A, viewer in team B), that's the wrong question — you need "does the caller hold S on this resource". Register a resolver and the resource extractor stops being advisory:
// rbac path — compile per-resource roles through the same `roles` map:
rbacPlugin({
roles: { owner: ['roadmaps:write'], viewer: ['roadmaps:read'] },
resolveResourceRoles: (subject, teamId) => db.rolesFor(subject.id, teamId), // → role slugs on THAT team
})
// or, without rbac, register a resolver directly against your own tables:
import { setResourceScopeResolver } from '@voltro/protocol'
setResourceScopeResolver(({ subject, scope, resource }) =>
Effect.succeed(/* does subject hold `scope` on `resource`? */),
)The framework asks the resolver per request, before the executor (mutations: before the transaction opens). A globally-held scope or admin:full still short-circuits without a resolver call; only the gap falls through. A resolver error fails closed (denies). The denial is the same typed ScopeError a global guard raises.
The permission() guard (in-handler)
For authz that depends on loaded data (row ownership, cross-field rules) — anything the declarative guards: can't express as a pure scope check — gate inside the handler, the same way assertOwnTenant / requireScope do.
Two forms, mirroring scopes.ts:
// Effect form — fails with typed ScopeError on the error channel.
import { permission } from '@voltro/plugin-rbac'
import { Effect } from 'effect'
export default (input: { title: string }, ctx: AppContext) =>
Effect.gen(function* () {
yield* permission(ctx, 'notes:write') // Effect<void, ScopeError>
// ... do the write ...
})// Sync form — throws ScopeError. For async (non-Effect) handlers.
import { assertPermission } from '@voltro/plugin-rbac'
const execute = async (input: { title: string }, ctx: AppContext) => {
assertPermission(ctx, 'notes:write') // throws ScopeError
return ctx.store.insert('notes', { title: input.title })
}- Multiple permissions = ALL required (AND): pass an array —
permission(ctx, ['notes:write', 'admin:bypass'])— or callpermission()twice. - OR semantics:
anyPermission(ctx, ['notes:write', 'notes:admin'])passes when the caller holds at least one. '*'/admin:fullbypass: a caller whose resolved set containsadmin:full(or a role mapped to'*') passes every check.- Pure branch:
can(ctx, 'notes:write')returns a boolean without throwing — for in-handler branching.
These guards fail with protocol's ScopeError (Schema.TaggedError, { required: string, message: string }) — the one denial tag the whole framework uses: declarative guards: raise it too, so a client that matches _tag === 'ScopeError' (or errorTag(err)) recognizes both a descriptor-guard denial and a permission() denial with a single branch. The plugin registers ScopeError via errorSchemas so a permission() rejection decodes typed even on a descriptor with no guards:; declare error: ScopeError (from @voltro/protocol) on the descriptor too if you want it explicit in the procedure's own wire union.
ctx.access — the typed authorization slice
Every handler context carries ctx.access, the cast-free face of the caller's effective scope set (raw ∪ rbac roles). Reach for it instead of poking at ctx.request.subject.scopes:
ctx.access.has('notes:write') // boolean
ctx.access.hasAny(['notes:write', 'notes:admin'])
yield* ctx.access.require('notes:write') // Effect<void, ScopeError>
ctx.access.scopes // the live effective setIt reads the same effective-scope seam the declarative guards: enforce, so ctx.access.has(x) and a guards: [{ scope: x }] entry can never disagree.
Row-level checks
permission() covers the role/scope axis. Row-level rules ("can edit their OWN note") stay ordinary handler logic alongside it:
export default (input: { noteId: string }, ctx: AppContext) =>
Effect.gen(function* () {
yield* permission(ctx, 'notes:write') // role/scope gate
const note = yield* /* load note by id */
if (note.authorId !== ctx.request.subject.id) {
return yield* Effect.fail(new ScopeError({ required: 'notes:write', message: 'not author' }))
}
// ... safe to write ...
})Where roles come from
By default the plugin reads role slugs from subject.metadata.roles (a string[]) — config-only, table-less. A project that assigns roles at runtime supplies its own resolver:
rbacPlugin({
roles: { /* … */ },
// Resolve the caller's role slugs. Tenant-aware: filter by the
// ACTIVE tenant via subject.tenantId.
resolveRoles: async (subject) => {
const rows = await db.userRoles.findAll({
where: and(eq('userId', subject.id), eq('tenantId', subject.tenantId)),
})
return rows.map((r) => r.roleSlug)
},
// Optional extra raw permissions merged in (per-row ACLs, flags).
resolvePermissions: async (subject) => /* string[] */,
})The resolver runs once per request; the result is stashed on a per-request slot so permission() and useCan read a stable resolved set. A resolver failure degrades to the subject's raw scopes — it never silently grants.
Tenant-aware roles
subject.tenantId is the active tenant (a single value, not a map). A tenant-aware resolver returns the roles that apply for subject.tenantId, so a user who is editor in tenant A but has no role in tenant B passes permission() under A and is denied (ScopeError) under B. The optional userRoles table (below) carries (userId, tenantId, roleSlug) for exactly this filter; tenantId: null means a global role across every tenant.
Optional tables
Config-only projects run table-less. Projects that assign roles at runtime opt into the roles + userRoles tables via extendSchema:
rbacPlugin({
roles: { /* … */ },
tables: true, // or { multitenant: true } for a FK to `tenants`
resolveRoles: /* DB resolver */,
})The tables emit on all six SQL dialects (postgres / mysql / mariadb / mssql / sqlite / turso) — permissions is a portable JSON column, never a postgres-only array. With tables: true, userRoles.tenantId is a plain nullable column; with { multitenant: true } it's a nullable foreign key to the tenants table from @voltro/plugin-multitenancy.
Assign / revoke — userRoleStore
You don't have to hand-write the userRoles CRUD or a DB resolver. userRoleStore(dataStore) gives tenant-aware assign / revoke / rolesOf, and dataStoreRoleResolver(store) adapts it into resolveRoles:
import { rbacPlugin, userRoleStore, dataStoreRoleResolver } from '@voltro/plugin-rbac'
const roleStore = userRoleStore(dataStore)
await roleStore.assign('user_123', 'editor', 't1') // idempotent grant
await roleStore.revoke('user_123', 'editor', 't1')
rbacPlugin({
roles: { /* … */ },
tables: { multitenant: true },
resolveRoles: dataStoreRoleResolver(roleStore), // scoped to subject.tenantId
})rolesOf(userId, tenantId) returns the tenant's assignments plus the global (tenantId: null) ones, so a global role grants everywhere.
React side (web)
This plugin ships no client hook. The UI gate lives in
@voltro/client — feed the caller's
resolved scopes to <PermissionProvider> and read them with useCan:
import { PermissionProvider, useCan } from '@voltro/client'
// Feed the caller's resolved scopes once, near the app root:
// <PermissionProvider scopes={resolvedScopes}>…</PermissionProvider>
const Page = () => {
const canEdit = useCan('notes:write')
return (
<div>
<h1>My note</h1>
{canEdit ? <button>Edit</button> : null}
</div>
)
}It sits there rather than here because scopes are a framework concept —
@voltro/protocol owns ScopeError, guards: [{ scope }] and ctx.access, and
rbac is only ONE way to produce them (an app can register its own
setResourceScopeResolver over its own tables and never install this plugin).
The hook CONSUMES scopes, which is generic; this plugin PRODUCES them, which is
rbac-specific. Gating a button must not require an rbac dependency.
What stays here is everything that IS rbac: the roles map, role→scope
compilation, the permission() server guard, the role tables, and
resolveResourceRoles.
useCan is for hiding buttons, NOT authorization — a client can lie about
its scopes. The server-side permission() guard is the real gate.
See also
- Multi-tenancy — tenant scoping IS authorisation
- Authentication — RBAC sits on top of auth; roles compile to the subject's scopes