Documentation
Multi-tenancy — ZigBase
Account-scoped collections — enabling tenancy, tenant_field, accounts/memberships/invitations, roles, and the @request.account rule macros.
ZigBase has built-in account-scoped multi-tenancy: mark a collection as tenant-owned and every read, write, and realtime delivery of it is automatically narrowed to the request’s active account — you do not hand-write account = @request.account.id on every rule, and a client can never read or write across tenants. This guide covers enabling it, the built-in accounts/memberships/ invitations collections, how the active account is resolved, the @request.account rule macros, and the explicit escape hatch for cross-tenant tooling.
zigbase.App(.{
.tenancy = .{
.enabled = true,
.resolver = .header, // read the active account from X-Account-Id / the zb_account cookie
.auth_collection = "users", // the auth collection whose records are members
.roles = .{ "viewer", "editor", "admin", "owner" }, // optional; this is the default ladder
},
.collections = .{
.projects = .{
.fields = .{
.{ .name = "title", .type = .text },
.{ .name = "account", .type = .text }, // holds the owning account id
},
.tenant_field = "account", // <- makes `projects` tenant-owned
.rules = .{ .list = "@public", .view = "@public" },
},
},
});
What tenancy gives you
Every read, write, and realtime delivery of a tenant-owned collection is bound to the request’s active account, fail-closed — no matching account context means no visibility into that collection’s rows, full stop. You do not write account = @request.account.id on every rule by hand; the tenant scope is composed into the guard stack automatically. A client can never read or write across tenants, even through a @public rule, an expand, or a realtime subscription.
Enable it
Turn tenancy on at the top level of App(.{ ... }), then mark each owned collection’s owning-account column with .tenant_field:
zigbase.App(.{
.tenancy = .{
.enabled = true,
.resolver = .header, // read the active account from X-Account-Id / the zb_account cookie
.auth_collection = "users", // the auth collection whose records are members
.roles = .{ "viewer", "editor", "admin", "owner" }, // optional; this is the default ladder
},
.collections = .{
.projects = .{
.fields = .{
.{ .name = "title", .type = .text },
.{ .name = "account", .type = .text }, // holds the owning account id
},
.tenant_field = "account", // <- makes `projects` tenant-owned
.rules = .{ .list = "@public", .view = "@public" },
},
},
});
.tenant_field names the column on projects that holds the owning account id — that’s what makes projects tenant-owned. Collections without .tenant_field are untouched by tenancy.
Accounts, memberships, invitations
Enabling tenancy provisions three built-in system collections (visible in _collections, system = 1):
_accounts— one row per tenant._memberships— the principal↔account edge, carrying aroleandstatus._invitations— pending invites (the invite/accept/remove lifecycle ships separately from the tables themselves).
Roles form a total order, defaulting to viewer < editor < admin < owner, configurable via .tenancy.roles.
For browsers, POST /api/accounts/:id/activate verifies membership and sets a signed, HttpOnly zb_account cookie, so a SPA selects an account once instead of sending X-Account-Id on every call. API clients can skip this and just send the header.
Selecting the active account
On each request the active account is resolved from the X-Account-Id header (or the signed zb_account cookie) and verified against an active _memberships row for the authenticated principal — one indexed lookup, cached on the request. No membership, an invalid account id, or an inactive membership all resolve to no account context, and tenant-owned data is invisible — resolution fails closed, never open.
Rules with @request.account
A successful resolution fills three rule macros:
@request.account.id— the active account’s id.@request.account.role— the principal’s role within that account.@request.account.ids— the list-valued set of account ids the principal is an active member of (usable as theinoperator’s source).
These are illustrative — most tenant-owned collections don’t need to reference them directly, since .tenant_field already scopes reads and writes. A rule that does want to reference the active account explicitly might look like:
viewRule = "@request.account.id != \"\" && account = @request.account.id"
Adapt the shape to your collection; treat this as a starting point, not a fixed template.
Escaping the scope
Superusers bypass tenancy entirely, consistent with the rest of the access-rule engine. For admin tooling that must legitimately span accounts — an ops dashboard, a maintenance job — zigbase.crossTenant(rctx) returns a context with the override enabled. It is the explicit, never-silent way to widen scope; nothing crosses tenant boundaries implicitly.