# RBAC

> Roles + permissions + the permission() handler guard. Roles compile to scopes.



---

<!-- source: en/plugins/rbac.md -->
## 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

```ts
// 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.

```ts
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.

```ts
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'] }]` (default `mode: '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.

```ts
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:

```ts
// 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`:

```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 ...
  })
```

```ts
// 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 call `permission()` twice.
- **OR semantics:** `anyPermission(ctx, ['notes:write', 'notes:admin'])` passes when the caller holds at least one.
- **`'*'` / `admin:full` bypass:** a caller whose resolved set contains `admin: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`:

```ts
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 set
```

It 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:

```ts
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:

```ts
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`:

```ts
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`:

```ts
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`](/docs/ui/client-utilities/use-can) — feed the caller's
resolved scopes to `<PermissionProvider>` and read them with `useCan`:

```tsx
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](/docs/multi-tenancy/overview) — tenant scoping IS authorisation
- [Authentication](/docs/authentication/overview) — RBAC sits on top of auth; roles compile to the subject's scopes
