Skip to content
Warlock.js v4.2.11

Per-tenant roles

In a multi-tenant SaaS, a user is admin in their own organization and viewer in one they were invited to. The ejected user_roles table scopes each assignment to a tenant.

npx warlock add access already ejects the DB-backed resolver and the role tables — that’s all you need for per-tenant roles. To resolve the active organization so checks don’t have to pass it, add resolveTenant(user) to the resolver. It receives the user, so derive the tenant from there — safer than trusting client-supplied request input:

src/app/access/services/access-resolver.ts
public resolveTenant(user: Auth): string | undefined {
return user.get("organization_id");
}

Seed the catalog as Role rows (admins can edit these at runtime):

import { Role } from "app/access/models/role";
await Role.create({ name: "admin", permissions: ["*"] });
await Role.create({ name: "member", permissions: ["orders.*", "members.view"] });
await Role.create({ name: "viewer", permissions: ["orders.view"] });

Assign through UserRole — each call auto-flushes that user’s cached set for the tenant, so the next check is current with no manual access.flush:

import { UserRole } from "app/access/models/user-role";
await UserRole.assign(user, "admin", "org-acme"); // admin in Acme
await UserRole.assign(user, "viewer", "org-globex"); // viewer in Globex

With resolveTenant implemented, an ordinary check resolves against the caller’s active organization:

// inside a request for org-acme → uses the admin grant
await can(user, "orders.delete"); // true
// the same user, inside a request for org-globex → uses the viewer grant
await can(user, "orders.delete"); // false

Need to check against a specific tenant explicitly (a background job, a cross-org admin screen)? Pass it:

await can(user, "orders.delete", { tenant: "org-acme" });

For defense in depth, add a policy that confirms the record belongs to the active tenant — so even a misconfigured role can’t reach across organizations:

src/app/orders/policies/index.ts
definePolicy("orders.update", (user, order, ctx) =>
order.get("organization_id") === ctx.tenant,
);