Authorization (ReBAC)
Relationship-based access control — declare per-resource policies (which relations grant which actions), decide with can()/assertCan() (fail-closed, typed AccessDenied), hide forbidden rows from reads, and reactively drop rows from open subscriptions the moment a grant is revoked.
Authorization is relationship-based (ReBAC): access follows from
relationships between a subject and a resource (owner, editor, viewer, a
team membership) rather than a flat role. You declare a per-resource policy, the
engine decides with can(...), and — because the framework is reactive — a
revoked grant removes the now-forbidden rows from every open subscription
live, with no refresh.
Relationship tuples
A tuple says subject S has relation R on <type>:<id>. They're plain rows
(the framework's _voltro_rebac_tuples table, or your own relation rows):
// alice is the owner of todo:42 ; the acme team can view it
{ subjectId: 'alice', relation: 'owner', resourceType: 'todo', resourceId: '42' }
{ subjectId: 'acme', relation: 'viewer', resourceType: 'todo', resourceId: '42' }Declare a policy
defineResourcePolicy maps each action to the relations that grant it (ANY-of),
with optional relation implies (a transitive closure — owner ⇒ editor ⇒ viewer). Registered at import; the capability map exposes the graph.
import { defineResourcePolicy } from '@voltro/runtime'
export const todoPolicy = defineResourcePolicy({
resourceType: 'todo',
actions: {
view: ['viewer', 'editor', 'owner'],
edit: ['editor', 'owner'],
delete: ['owner'],
},
implies: { owner: ['editor'], editor: ['viewer'] },
})Enforce it declaratively — guards:
can / assertCan above are the imperative form: you load the tuples and make
the decision inside the handler. That works, and it is what you reach for when
the check needs data you have already loaded.
For the ordinary case — "may this caller perform ACTION on the row this input names?" — declare it on the descriptor instead:
export const todoUpdate = defineMutation({
name: 'todos.update',
input: Schema.Struct({ id: Schema.String, title: Schema.String }),
output: Schema.Void,
guards: [{ action: 'edit', resourceType: 'todo', resource: (input) => input.id }],
})The framework resolves it BEFORE the executor runs — for a mutation, before the
transaction opens — and fails with a typed ScopeError naming
<resourceType>:<action>.
Why the declarative form is not just shorter: an in-handler check is one an author can forget, and a forgotten check is a silent hole rather than an error. The same is true of the older pattern of hand-maintaining a map from rpc tag to policy rule and installing it as an interceptor — that map is fail-open by omission: add an endpoint, forget the entry, and nothing anywhere tells you. A guard on the descriptor cannot be forgotten for an rpc that exists, because it is part of the rpc.
Scope guards and relationship guards live in the same array and ALL must pass:
guards: [
{ scope: 'todos:write' }, // may you edit todos at all
{ action: 'edit', resourceType: 'todo', resource: (i) => i.id }, // may you edit THIS one
]A guard answers may you call this. It cannot answer which rows may you see — a list has no single resource to name. For visibility that follows from a relationship ("tickets on teams I hold a role on"), declare a row filter instead; it AND-merges a subject-derived predicate into every read, so it narrows and never grants.
Activate it: register a tuple source
A relationship guard needs to read the caller's relations. That comes from the registered tuple source:
// app.config.ts or a *.startup.ts
import { setTupleSource, loadResourceTuples } from '@voltro/runtime'
setTupleSource((req) =>
loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId))voltro dev / voltro serve register this default for you, reading
_voltro_rebac_tuples — but only if you registered nothing. Register your
own when your relationships already live in your own tables: a teamMembers row
is a relation; you should not have to copy it into a framework table to authorize
against it. Yours wins, wherever you register it (app.config.ts or a
*.startup.tsx), and the boot says so:
policy guards: using the app-registered tuple source
That line is worth knowing, because its absence is the diagnosis when your own source is not being consulted.
Every unanswerable case denies. No tuple source registered, no policy for
that resourceType, an input that doesn't identify a resource, a tuple source
that throws — each is a denial, not a pass. An authorization question nobody can
answer is a refusal; treating it as a pass is how a policy layer ends up
enforcing nothing while looking like it does.
A guard naming an unregistered resourceType refuses the BOOT. Denying is
correct per call and useless as a deployment outcome: the app comes up green and
every guarded procedure is down, with a log line per refused call as the only
sign. A deployment put a number on it — 39 procedures, team settings through role
administration. So the two facts are compared once, after the startups have run,
and a missing registration names the type and the procedures that demanded it.
The commonest cause is a typo: the resourceType in a guard and the one in
defineResourcePolicy are two strings, and nothing but that check compares them.
The source sees the whole subject
req.subject is the caller, not just req.subjectId. That matters whenever a
CREDENTIAL is narrower than the person holding it — an API key above all:
setTupleSource(async (req) => {
// A key minted for one team must not act on another. `req.subjectId` is the
// OWNING USER, so a source reading only memberships passes an owner who
// belongs to both teams.
const boundTeam = req.subject.metadata?.teamId
if (boundTeam !== undefined && boundTeam !== req.resourceId) return []
return loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId)
})Narrow it yourself: Subject is a union whose system and anonymous members carry
no metadata. Without this a declared guard could not express the binding, so an
app had to keep a hand-written check in the executor beside it — and a guard that
must always run paired with a hand-written check is not a declaration.
Guards are re-checked on every subscription delivery
A subscription is a long-lived grant. Its guards — scope and relationship alike — are re-evaluated before each delivery, so revoking a relation mid-session ends the stream with the typed error instead of continuing to push rows.
The same holds for a *.stream.ts: its guards are checked when the stream is
opened — before the executor runs — and again before each element. The
subscribe-time check comes first for a reason that is not symmetry: a stream's
executor is where the work happens, so a gate placed after it refuses the result
of something that has already been done and paid for. A denial ends the stream
with the typed error rather than dropping elements, because a skipped element is
indistinguishable from "nothing to send".
Every primitive that accepts guards: enforces them: queries, mutations,
actions, events and streams.
Every procedure decides — guards: or openAccess:
A procedure that declares neither is refused at boot. guards: used to
default to "allowed", so a discovered *.query.ts / *.mutation.ts /
*.action.ts / *.stream.ts with no guard was callable by any authenticated
session — the door defaulted open, and nothing said so.
The same rule covers events: a *.event.ts declaration is a wire surface
too, and one that declares neither guards: nor openAccess: was silently
subscribable by anyone who could open the socket. defineEvent takes the same
two answers — see Events.
There are exactly two answers, and they are not the same claim:
export const invoiceList = defineQuery({
name: 'invoices.list',
guards: [{ scope: 'invoices:read' }], // the caller must hold a scope
…
})
export const pricing = defineQuery({
name: 'pricing.current',
openAccess: 'public pricing page — reads no caller data', // anyone may call it, and why
…
})openAccess takes a reason, not a boolean. That is the point of it: the
reason is what a reviewer reads later, and it is what makes "we decided this is
open" distinguishable from "nobody looked". Without such a marker, the only
way to satisfy a default-deny gate is to add a guard — so every genuinely open
endpoint grows a scope every caller already holds. That rubber stamp reads as
protection and enforces nothing, which is a worse state than the hole it
replaces.
A procedure that only other server code calls wants neither: mark it
internal: true and it leaves the wire entirely (no client-group entry, no
route). openAccess on an internal procedure is refused — there is no wire
surface to make a decision about.
The gate
voltro dev and voltro serve run the same check at boot, and voltro doctor
runs it as a preflight (non-zero exit; accessDecisions in --json). The
refusal names every offending procedure with its file, because the fix is one
pass over the whole list:
[access] 3 wire-exposed procedures or events declare no access decision, and
this app runs with `security.defaultDeny`:
invoices.list (query)
src/api/invoices.query.ts
…
voltro doctor is the fastest way to get the list without a failed boot.
Turning it off
One field, in app.config.ts, for the whole app:
export default defineApiConfig({
security: { defaultDeny: false },
})There is deliberately no environment variable for this. The only direction
anyone reaches for is off, and an env var is how a security default becomes
permanently off in one CI job with no diff to review. voltro doctor keeps
listing the undecided procedures while it is off, marked advisory.
What it does NOT cover — and what covers the rest
The boot gate reads your app's own discovered procedures and events. The procedures a plugin declares are the plugin author's decision and are not judged at boot — adopting this does not turn into a bug report against a plugin you installed.
They are not unpoliced, though: the same security.defaultDeny is also enforced
per request in the dispatch spine, as defense in depth. A descriptor that
reaches the wire with no access decision — a plugin route, a hand-bound
descriptor — is refused with the same typed ScopeError before the transaction
opens or any external I/O runs. Every first-party plugin route declares its own
decision (a scope where a real authority exists — e.g. billing:manage,
storage:browse — or openAccess with the reason on the routes that are
self-scoped or anonymous-capable by design; each plugin's page lists them). A
third-party plugin that declares neither on a route will see that route refused
per-request under default-deny — the fix is one field on the route, exactly as
for your own procedures.
Decide — can / assertCan
can(subject, action, resource, { policy, tuples }) is the decision; assertCan
throws the typed AccessDenied. It fails closed: admin:full scope is the
only bypass, an anonymous subject is denied, a cross-tenant resource is denied,
an unknown action is denied — otherwise allow iff the subject's effective
relations intersect the action's grant set.
import { assertCan, loadResourceTuples, AccessDenied } from '@voltro/runtime'
import { todoPolicy } from '../policies/todo.policy'
// in a mutation's *.server.ts
const tuples = await loadResourceTuples(ctx.store, ctx.request.subject.id, 'todo', input.id)
assertCan(
ctx.request.subject,
'edit',
{ type: 'todo', id: input.id, tenantId: ctx.request.subject.tenantId },
{ policy: todoPolicy, tuples },
) // throws AccessDenied on a denyDeclare error: AccessDenied on the descriptor so the denial surfaces to the
client typed + pattern-matchable, never a bare 500. For per-rpc enforcement
without a hand-written guard, buildRebacInterceptor runs the same can() check
in the rpc pipeline; buildRebacReadFilter / visibleRows hide forbidden rows
from a read instead of failing it.
Live revocation
Because reads are reactive, authorization is too. revokedIds(before, after)
is the core: the ids a subject could see before a permission-changing write but
not after. The reactive layer pushes that delta to every open subscription, so
the moment alice's grant on todo:42 is revoked, the row disappears from her
screen — no refetch, no refresh. (Enforcement is phased first; revocation is
the headline that builds on it.)
Client
useResourceCan / useResourceCans resolve a subject's permission reactively
(fail-closed), so the UI hides an action the moment it's revoked:
import { useResourceCan } from '@voltro/client'
const canEdit = useResourceCan('app', 'todos.can', { action: 'edit', resourceType: 'todo', resourceId: id })
// canEdit.allowed: boolean (false until the first verdict); canEdit.pending: boolean.Full API — including the batch useResourceCans for per-row gating — in
usePermissions.
Capability map
rebacPolicyGraph() returns every resource type, its actions, the relations each
grants, and the implication edges — the policy graph a dashboard or an AI agent
reads to reason about authority without grepping the code.