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_tuplesbut 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 deny

Declare 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.