API · RBAC
Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, declarative descriptor guards (statically checked by voltro check), resource-scoped guards, and the in-handler permission()/can() guards. admin:full bypasses. Config-only, zero infra.
Roles → scopes → guards. @voltro/plugin-rbac compiles a role map, and an rpc interceptor resolves each caller's roles into scopes published to the framework's effective-scope seam. From there both authorization forms see them. The admin:full scope is a blanket bypass. Config-only, zero infra. Template id: api-rbac.
Scaffold
voltro create-project acme --api=api-rbacRoles as config
The role map is the app's declared scope vocabulary, not just a lookup table: rbacPlugin publishes the union of it, and voltro check fails on any descriptor guard requiring a scope that appears nowhere in it.
// authz.ts — split out of app.config.ts so tests build the real plugin
export const roles = {
viewer: ['notes:read'],
editor: ['notes:read', 'notes:write'],
owner: ['notes:read', 'notes:write', 'notes:delete', 'teams:rename'],
admin: ['*'], // wildcard → admin:full blanket bypass
}// app.config.ts
import { rbacPlugin } from '@voltro/plugin-rbac'
import { demoRolesForTenant, roles, rolesOnTeam } from './authz'
rbacPlugin({
roles,
// PRODUCTION: the default resolver reads subject.metadata.roles (set by your
// auth strategy), or do a DB lookup. The shipped DEMO maps tenant → role so
// you can try each via the x-tenant header with zero auth setup.
resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
// PER-RESOURCE roles — see "Resource-scoped guards" below.
resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource),
})Two guard forms — prefer the declarative one
// notes.create.mutation.ts — DECLARATIVE, on the descriptor
export const createNote = defineMutation({
name: 'notes.create',
target: { table: 'notes', op: 'insert' },
guards: [{ scope: 'notes:write' }],
input: Schema.Struct({ title: Schema.NonEmptyString, body: Schema.String }),
output: Schema.Struct({ id: Schema.String, title: Schema.String }),
})The framework enforces this in the dispatch spine — before the executor, and before a mutation's transaction opens — so an unauthorized call never touches the DB. Declaring guards: also merges ScopeError into the wire error union automatically, so no error: field is needed and the client still decodes the denial typed. Reads take guards too, re-checked on every delivery, so a revoked role stops a live subscription.
The reason to prefer it is static checkability: a declarative guard lands in the capability manifest, so voltro check catches a scope no role grants. An in-handler call is invisible to that check.
// notes.delete.mutation.server.ts — IN-HANDLER, for what needs the loaded row
export default (input, ctx) => Effect.gen(function* () {
const store = yield* EffectStore
const rows = yield* Effect.promise(() =>
ctx.store.query(database.notes.where(eq('id', input.id)).limit(1).descriptor))
const note = rows[0]
if (note === undefined) return yield* Effect.fail(new NoteNotFound({ id: input.id }))
// Only decidable AFTER the row is loaded — no descriptor guard can express it.
const hard = note['archived'] === true || can(ctx, 'notes:purge')
if (hard) { yield* store.delete('notes', input.id); return { id: input.id, mode: 'hard' } }
yield* store.update('notes', input.id, { archived: true })
return { id: input.id, mode: 'soft' }
})Rule of thumb: put in guards: everything decidable from the subject and the input; reach for permission() / can() only for what needs loaded data. Companions: anyPermission(ctx, [...]) (OR), assertPermission (sync throw), can(ctx, scope) (boolean, branches instead of refusing).
Resource-scoped guards
guards: [{ scope, resource }] makes a grant mean "owner of this team" instead of "owner globally".
// teams.rename.mutation.ts
guards: [{ scope: 'teams:rename', resource: (input: { teamId: string }) => input.teamId }],The resource extractor pulls the id out of the decoded input; rbac's resolveResourceRoles returns the roles the caller holds on that id, compiled through the same role map. A globally held scope (or admin:full) satisfies the guard without the resolver ever being called — only the gap falls through. That fall-through is fail-closed: a resolver that throws or rejects denies, it does not degrade to the global answer.
Failure postures, deliberately different
| Resolver | On throw / reject |
|---|---|
resolveRoles / resolvePermissions |
logs, degrades to the subject's own scopes — never grants, but an api key with valid scopes still works |
resolveResourceRoles |
denies (fail-closed) |
Both hold for a synchronous throw as well as a rejected promise.
Static check
voltro check --offline # no running app needed — usable as a CI gateA guard requiring notes:wirte when the roles grant notes:write is not merely misconfigured: that procedure is permanently, silently uncallable by everyone. voltro check reports it as rbac/unknown-scope and exits non-zero.
The registry stays dormant when a custom resolvePermissions is configured — that resolver merges scopes from outside the role map by design, so publishing the map alone would flag correct code.
Try it
# admin tenant → admin:full → passes:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: acme' \
-d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
# → { ok:true, … }
# readers tenant → viewer → notes:write missing → typed ScopeError:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: readers' \
-d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
# → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }Testing authorization
The shipped tests/authz.test.ts drives the real plugin: makeTestContext({ plugins: [rbacPlugin({ roles, … })] }) composes the actual interceptor chain, and invoke enforces the descriptor's guards with the same function the serve pipeline calls.
const ctx = makeTestContext({ subject, store: mockStore({ notes: [] }), plugins: [plugin()] })
await expect(invoke(createNote, createNoteHandler, { title: 'x', body: '' }, ctx))
.rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:write' })Do not hand-set subject.scopes to the value you expect. That tests the guard while assuming the resolution which produces it — and the resolution is the half that actually breaks (a renamed role, a throwing resolver, a scope no role grants). Those all pass a pre-stamped test and fail a real one.
On the web side
import { PermissionProvider, useCan } from '@voltro/client'
<PermissionProvider scopes={session.scopes}>…</PermissionProvider>
const canWrite = useCan('notes:write')
{canWrite && <NewNoteButton />} // hide affordances the user can't useThe UI gate ships in @voltro/client, not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. useCan is a UI affordance only — the server guard is the enforcement.
Persisted roles
Set tables: true to store assignments in roles / userRoles tables and resolve them from the DB (multitenant-aware). The shipped template stays config-only.
Anti-patterns
- Returning scopes from
resolveRoles. It returns role SLUGS (['editor']); therolesmap turns those into scopes. Returning scopes directly bypasses the map. - Re-checking a descriptor guard inside its own executor. Two copies of one rule, and the copy
voltro checkreads is the descriptor's. - Reaching for
permission()whenguards:would do. It works, but it opts that procedure out of the static check. - Scopes ≠ entitlements. RBAC answers "may you call this"; billing entitlements (see
api-saas) answer "do you have quota left". A procedure can need both.