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 exactly this default for you, reading
_voltro_rebac_tuples. 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.
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.
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.
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.