Documentation
Relationship abilities — ZigBase
Per-collection, per-action authorization by the principal's relationship to the row — declaring abilities, ctx.can, and the abilities endpoint.
Access rules authorize a record by evaluating its own columns. Abilities go further: they authorize a CRUD action by the principal’s relationship to the row — “you may edit a project if you are an editor (or higher) of the account it belongs to” — without hand-writing that membership join in every rule. This guide covers declaring abilities, how they compose with rules and tenancy, checking them from code, and the introspection endpoint.
Rules vs abilities
A rule expression evaluates the row’s own columns (status = "published", owner = @request.auth.id). An ability instead authorizes by the principal’s relationship to the row — their membership and role in the account the row belongs to. Abilities are composed alongside rules, not instead of them: both must pass.
Declare an ability
Abilities are declared at the top level of App(.{ … }), keyed by collection name, with a per-action relationship rule:
const App = zigbase.App(.{
.tenancy = .{ .enabled = true, .auth_collection = "users",
.roles = .{ "viewer", "editor", "admin", "owner" } },
.collections = .{
.projects = .{
.fields = .{
.{ .name = "title", .type = .text },
.{ .name = "account", .type = .relation, .target = "accounts" }, // owning account
},
.rules = .{ .list = "@public", .view = "@public" },
},
},
// A row of `projects` is authorized when the principal holds a membership (role ≥ floor) of the
// account named by the `account` relation field.
.abilities = .{
.projects = .{
.view = .{ .relationship = .{ .via = "account" } }, // any active member
.update = .{ .relationship = .{ .via = "account", .min_role = .editor } },
.delete = .{ .relationship = .{ .via = "account", .min_role = .admin } },
.create = .{ .relationship = .{ .via = "account", .min_role = .editor } },
},
},
});
.via must name a relation field on the collection — the field whose column holds the owning account id. .min_role filters through the configured role ladder (.tenancy.roles); omitting it means any active member qualifies. list reuses the view ability.
How they compose
Each ability compiles to a bound IN predicate over the principal’s qualifying membership account-ids, AND-ed into the same guard stack as the access rule and the tenant scope, on every chokepoint: WHERE (filter) AND (rule) AND (ability) AND (tenant_field = ?) AND (ttl). That includes view/create/update/delete, expand, realtime delivery, and the bulk list endpoint — an ability forces a per-row check even when the access rule alone would allow everything, so a .rules.list = "@public" collection with a view ability returns the ability-narrowed set (HTTP 200), not every row and not a 400.
Fail closed. No qualifying membership resolves to the constant-false predicate, denying the row — never SQLite’s invalid IN (). A locked rule (null/"") still denies first. Account ids are always bound parameters, never interpolated; superusers bypass abilities entirely.
Comptime validation. An ability naming an unknown collection, a .via that isn’t a relation field, or a .min_role outside .tenancy.roles is a @compileError. .abilities also requires .tenancy.enabled = true — abilities authorize by account membership, which only resolves under tenancy — so configuring abilities with tenancy disabled is a @compileError rather than a silent deny-all at runtime.
Check from code
From a custom route, try ctx.can(.update, "projects", id) authorizes a specific record through the same policy (rule + ability + tenant scope) the REST chokepoints use — reach for it instead of re-implementing the check by hand.
To introspect what the current principal may do with a record, GET /api/collections/:col/records/:id/abilities returns a JSON object of booleans, e.g. {"view": true, "update": false, "delete": false}. The endpoint itself requires view access (404 otherwise), so it never leaks a record’s existence — "view" is therefore always true on a 200 response.
With tenancy
Abilities resolve through account membership, which only exists once tenancy is enabled — that’s why .abilities requires .tenancy.enabled = true. Superusers bypass abilities entirely, the same as they bypass tenancy and rules. A collection with no .abilities entry composes a null predicate, so its decisions and compiled SQL are byte-identical to the pre-abilities engine — adding abilities elsewhere in your app never changes an unrelated collection’s behavior.