Skip to Content
DocsPluginsMulti-tenancy

Multi-tenancy

@quickben/plugin-tenancy keeps the data of each organization separate. Users belong to organizations through memberships. Each membership has a role, and roles grant capabilities.

pnpm add @quickben/plugin-tenancy

Setup

quickben.config.ts
import { defineConfig, devAuth } from "@quickben/core"; import { memoryAdapter } from "@quickben/db-memory"; import { tenancy } from "@quickben/plugin-tenancy"; import { posts, tags, users } from "./collections"; export default defineConfig({ db: memoryAdapter(), collections: [users, tags, posts], modules: [{ key: "blog", label: "Blog", collections: ["posts", "tags"] }], plugins: [tenancy({ scope: ["posts", "tags"] })], auth: devAuth(), });

posts and tags now have an organization field. Every read, update and delete is limited to the active organization. Creates get its id.

types.ts
import type { InferDoc, InferRegistry } from "@quickben/core"; import type config from "./quickben.config"; type Post = InferDoc<InferRegistry<typeof config>["posts"]>; export const organizationOf = (post: Post): string => post.organization;

The field is typed. It is optional in create input because the plugin fills it.

List tenancy() after the plugins that add scoped collections.

What it adds

AdditionFields or behavior
organizationsname, slug (unique), status (active, suspended), modules, metadata
membershipsorganization, user, email, role, status (invited, active, disabled), modules, invitedBy, invitedAt, joinedAt
organization fieldOn each collection in scope: relation, required, indexed
ctx.organizationIdOptional key of the request context

An invitation is a membership with status invited. The creator of an organization becomes its owner.

Options

OptionDefaultPurpose
scoperequiredSlugs of tenant-scoped collections.
rolesowner, admin, member, viewerCapabilities per role, highest rank first.
ownerRoleownerEvery organization keeps one active member with this role.
defaultRolememberRole of new memberships.
isPlatformAdminuser.roles includes platform-adminPlatform staff.
canCreateOrganizationany signed-in userWho may create organizations.
defaultModules"all"Modules of a new organization.
headerx-quickben-organizationHeader with the active organization id.
cookiequickben-organizationCookie with the active organization id.

Roles

RoleCapabilities
ownerread, write, members:manage, organization:manage, organization:delete
adminread, write, members:manage, organization:manage
memberread, write
viewerread

Roles are data. Replace them with your own:

import { defaultRoles, tenancy } from "@quickben/plugin-tenancy"; export const plugin = tenancy({ scope: ["posts"], roles: { boss: [...defaultRoles.owner, "billing"], staff: ["read", "write"] }, ownerRole: "boss", defaultRole: "staff", });

Active organization

OrderSource
1ctx.organizationId
2The header
3The cookie
4The user’s only active membership

The user must be an active member and the organization must be active. Otherwise the call throws OrganizationError (403) with a reason:

reasonWhen
noneThe user has no active membership.
ambiguousSeveral memberships and none selected.
not-memberThe selected organization has no active membership for the user.
unknownThe selected organization does not exist.
suspendedThe organization is suspended.

Without a user the error is UnauthorizedError.

import { OrganizationError, resolveTenant } from "@quickben/plugin-tenancy"; try { const tenant = await resolveTenant(qb, { user, req }); console.log(tenant.organizationId, tenant.role, tenant.capabilities); } catch (error) { if (error instanceof OrganizationError && error.reason === "ambiguous") { // Show an organization picker. } }

Platform staff (isPlatformAdmin) skip the membership and status checks. With nothing selected they see every organization. With an organization selected they act inside it with every capability.

Scoped collections

OperationRule
readYour rule AND { organization: { eq: active } }. Needs read.
createorganization is set from the active organization. Another value is rejected. Needs write.
updateYour rule AND the organization filter. A document cannot move. Needs write.
deleteYour rule AND the organization filter. Needs write.

Anonymous users are denied. If the collection belongs to a module, the user also needs the module.

  • Relation fields can only point to documents the user can read, so a document cannot link to another organization’s documents.
  • Documents of other organizations are NOT_FOUND, also for update and delete.
  • Platform staff move a document by updating organization with no organization selected.
  • In a suspended organization, scoped collections throw OrganizationError (suspended) for members. They can still read the organization and its memberships, but not manage members or edit it.

Modules

Organizations enable modules from config.modules in organization.modules. Only platform staff change that list. A membership grants a subset in membership.modules; an empty list grants every module of the organization.

import { defineCollection, fields as f } from "@quickben/core"; import { canUseModule, moduleAccess } from "@quickben/plugin-tenancy"; export const reports = defineCollection({ slug: "reports", fields: { name: f.text({ required: true }) }, access: moduleAccess("reports", { read: () => true }), }); export const showReports = await canUseModule(qb, { user }, "reports");

Use moduleAccess for collections outside scope.

Guard rails

RuleError
Membership changes need members:manage in the organization.ForbiddenError
The last active owner cannot be demoted, disabled or removed.TenancyRuleError last-owner
Owners and admins cannot change their own role or status, or remove themselves.TenancyRuleError own-role, own-membership
Nobody grants or manages a role ranked above their own.TenancyRuleError higher-role
A membership keeps its organization, and its user once set.ValidationError
Granted modules must be enabled for the organization.ValidationError
One membership per user or email per organization.ConflictError

Platform staff and overrideAccess skip the role rules. The last-owner rule always applies.

TenancyRuleError extends ForbiddenError; rule says which rule failed.

Invitations by email need a verified address. Set emailVerified on the user in your auth adapter: with emailVerified: false, acceptInvitation throws ForbiddenError, and listMyInvitations and membership reads ignore the email. Invitations by user id do not check it. When the field is missing, the email is trusted, so return only verified addresses in email.

Helpers

Each helper takes (qb, ctx, …) and calls the local API, so access rules apply.

HelperPurpose
resolveTenantActive organization, membership, role, capabilities, modules.
hasCapabilityCheck one capability in the active organization.
canUseModuleCheck one module in the active organization.
listMyOrganizationsThe user’s active memberships with organizations, by name.
listMyInvitationsPending invitations for the user’s id or email.
inviteMemberCreate an invited membership.
acceptInvitationMake an invitation active for the current user.
updateMembershipChange role, status or modules.
removeMemberDelete a membership.
switchOrganizationCheck access and return the cookie to set.
import { inviteMember, switchOrganization } from "@quickben/plugin-tenancy"; const ctx = { user, req }; await inviteMember(qb, ctx, { email: "ana@example.com", role: "viewer" }); const { cookie } = await switchOrganization(qb, ctx, "org-id"); // In a server action: (await cookies()).set(cookie.name, cookie.value, cookie.options)

The cookie is HttpOnly, SameSite=Lax and Path=/. It is Secure when the request is https (x-forwarded-proto, else the request URL), so it also works on http://localhost. organizationCookie(qb, id, req) without req always sets Secure. isSecureRequest(req) is the check.

Admin UI

app/admin/[[...segments]]/page.tsx
import { createAdminPage } from "@quickben/admin"; import { tenancyAdmin } from "@quickben/plugin-tenancy/admin"; import config from "../../../quickben.config"; export default createAdminPage(config, { extensions: [tenancyAdmin(config)] });
PartWhat it does
Sidebar switcherPick the active organization. Platform staff can pick any, or all.
/admin/membersList members, invite, change role, status and modules, remove.
/admin/organizationRename the organization. Platform staff set status and modules. Create an organization.

tenancyAdmin(config, { basePath, group }): pass the same basePath as createAdminPage when it is not /admin. group is the sidebar group (default Organization).

All changes go through the helpers above, so the guard rails apply. Actions redirect with a code (?toast=member-updated, ?error=last-owner). The pages map codes to fixed messages and ignore unknown codes, so the URL never puts text on the page. The playground runs this with QUICKBEN_TENANCY=1.

Last updated on