Tenancy
Overview
The CMS serves several tenants from one backend. Tenant-owned tables live in a per-tenant schema, and a model handle is bound to one tenant when you obtain it: GetModel(TenantMemberModel, tenantId) reads and writes that tenant's rows only. No query filters rows by tenant, and no table carries a tenantId column — the isolation is in the handle.
The tenancy surface spans several subpaths:
| Subpath | Contains |
|---|---|
@antelopejs-private/cms/interfaces/cms/constants | Schema names and the default tenant |
@antelopejs-private/cms/interfaces/cms/db | The Tenant, TenantMember and Role tables and their models |
@antelopejs-private/cms/interfaces/cms/request-tenant | Resolving the request's tenant |
@antelopejs-private/cms/interfaces/cms/tenant-scoped-model | Injecting a tenant-bound model |
@antelopejs-private/cms/interfaces/cms/tenant-access | Tenant access gates |
@antelopejs-private/cms/interfaces/cms/tenant-ownership | Membership and ownership writes |
@antelopejs-private/cms/interfaces/cms/data-controllers | The built-in members and roles data controllers |
Constants
import {
CORE_SCHEMA_NAME,
DEFAULT_TENANT_ID,
DEFAULT_TENANT_NAME,
TENANT_SCHEMA_NAME,
} from "@antelopejs-private/cms/interfaces/cms/constants";
| Constant | Value | Meaning |
|---|---|---|
CORE_SCHEMA_NAME | "cms-core" | Schema for tables with a single instance across the whole backend. |
TENANT_SCHEMA_NAME | "cms-tenant" | Schema instantiated once per tenant. |
DEFAULT_TENANT_ID | "default" | The tenant an unauthenticated request resolves to, and the only tenant a single-tenant project uses. |
DEFAULT_TENANT_NAME | "Default" | Display name of that tenant. |
Register your own table in TENANT_SCHEMA_NAME to make it per-tenant, or in CORE_SCHEMA_NAME to keep one instance of it:
import {
Field,
Index,
RegisterTable,
Table,
} from "@antelopejs/interface-database-decorators";
import { TENANT_SCHEMA_NAME } from "@antelopejs-private/cms/interfaces/cms/constants";
@RegisterTable("invoices", TENANT_SCHEMA_NAME)
export class Invoice extends Table {
@Field("string")
declare _id: string;
@Index()
@Field("string")
declare reference: string;
}
The Tenancy Tables
All three tables and their models are exported from @antelopejs-private/cms/interfaces/cms/db, along with their table-name constants tenantsTableName, tenantMembersTableName and ROLES_TABLE_NAME.
Tenant
Table tenants in cms-core — one row per tenant, shared across the backend.
| Field | Type | Notes |
|---|---|---|
_id | string | Tenant id. This is the instance id every per-tenant model handle takes. |
name | string | Display name. |
createdAt | Date | Creation time, indexed. |
updatedAt | Date | Update time, indexed. |
TenantModel extends BasicDataModel(Tenant, tenantsTableName) and adds getMany(ids: string[]): Promise<Tenant[]>, which returns [] for an empty id list.
Being a core-schema table, TenantModel is obtained without an instance id: GetModel(TenantModel).
TenantMember
Table tenant_members in cms-tenant — one row per member of the tenant the handle is bound to.
| Field | Type | Notes |
|---|---|---|
_id | string | Membership id. |
userId | string | Indexed, related to the cms-auth User table. |
roleIds | string[] | Related to Role, many. The roles whose grants make up the member's base permission set. |
isTenantOwner | boolean | Whether the member owns the tenant. |
joinedAt | Date | Creation time, indexed. |
invitedBy | string | null | Indexed, related to User. |
TenantMemberModel extends BasicDataModel(TenantMember, tenantMembersTableName):
| Method | Returns |
|---|---|
getByUser(userId) | Promise<TenantMember | undefined> |
existsByUser(userId) | Promise<boolean> |
listAll() | Promise<TenantMember[]> |
listOwners() | Promise<TenantMember[]> — members with isTenantOwner set |
listByUserWithTenantIds(userId) | Promise<Array<{ tenantId: string; member: TenantMember }>> |
getOldestByUser(userId) | Promise<{ tenantId: string; member: TenantMember } | undefined> — earliest joinedAt |
countOwnersExcluding(excludedUserIds) | Promise<number> |
The two methods that return a tenantId alongside the row report which tenant each row came from, which is what a lookup that spans instances needs.
Role
Table roles in cms-tenant — the tenant's roles.
| Field | Type | Notes |
|---|---|---|
_id | string | Role id, as referenced by TenantMember.roleIds. |
createdAt | Date | Creation time, indexed. |
updatedAt | Date | Update time, indexed. |
name | string | Indexed. |
permissions | string[] | Granted permission ids (Permissions). |
RoleModel extends BasicDataModel(Role, ROLES_TABLE_NAME) and adds getByName(name): Promise<Role | undefined>.
Resolve the Request's Tenant
getRequestTenantId(ctx) reads the tenantId claim of the request's bearer token and returns DEFAULT_TENANT_ID when there is no token or no claim.
import {
Context,
Controller,
Get,
type RequestContext,
} from "@antelopejs/interface-api";
import { AuthUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import { getRequestTenantId } from "@antelopejs-private/cms/interfaces/cms/request-tenant";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
export class WorkspaceController extends Controller("/api/workspace") {
@Get("/current")
async current(@AuthUser() _user: User, @Context() ctx: RequestContext) {
return { tenantId: getRequestTenantId(ctx) };
}
}
The returned id is decoded, not verified: the function does not check the token's signature. It is safe behind a decorator that already authenticated the request — the guards from Permissions, AuthUser from the cms-auth interface, or the auth a PageController installs. Do not authorize on the id in a route that no such decorator gates.
Scope a Model to the Request's Tenant
TenantScopedModel(ModelClass) is a parameter and property decorator that injects GetModel(ModelClass, getRequestTenantId(ctx)).
import { Controller, Get } from "@antelopejs/interface-api";
import { AuthTenantMember } from "@antelopejs-private/cms/interfaces/cms/guards";
import { RoleModel } from "@antelopejs-private/cms/interfaces/cms/db";
import { TenantScopedModel } from "@antelopejs-private/cms/interfaces/cms/tenant-scoped-model";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
export class RoleController extends Controller("/api/roles") {
@Get("/list")
async list(
@AuthTenantMember() _user: User,
@TenantScopedModel(RoleModel) roles: RoleModel,
) {
return roles.getAll();
}
}
As a property decorator it fills a data controller's @ModelReference() field:
import { ModelReference } from "@antelopejs/interface-data-api/metadata";
import { RoleModel } from "@antelopejs-private/cms/interfaces/cms/db";
import { TenantScopedModel } from "@antelopejs-private/cms/interfaces/cms/tenant-scoped-model";
// inside a DataController subclass
@ModelReference()
@TenantScopedModel(RoleModel)
declare model: RoleModel;
Only use it for a model whose table lives in TENANT_SCHEMA_NAME or another schema with one instance per tenant. For a core-schema table, take the model without an instance id.
Tenant Access Gates
A gate is a binary, tenant-level check, evaluated at every tenant authorization surface. It answers whether the tenant may use the product at all, independently of any permission set.
import { RegisterTenantAccessGate } from "@antelopejs-private/cms/interfaces/cms/tenant-access";
export async function construct(): Promise<void> {
RegisterTenantAccessGate({
id: "my_app.subscription-status",
order: 100,
gate: async (userId, tenantId) => {
if (await tenantIsSuspended(tenantId)) {
return { allowed: false, code: "my_app.errors.tenant_suspended" };
}
return { allowed: true };
},
});
}
type TenantAccessResult = { allowed: true } | { allowed: false; code: string };
type TenantAccessGateFn = (
userId: string | undefined,
tenantId: string,
) => Promise<TenantAccessResult> | TenantAccessResult;
TenantAccessGateInfo carries id, gate and order.
Semantics the interface guarantees:
- Gates run in ascending
order, ties in registration order; the first denial wins and itscodeis what reaches the client. - Registering an id twice replaces the earlier gate rather than adding a second one.
- A gate that throws fails the request.
- The registration is removed when the registering module unloads.
- Register in
construct()so the gate is active before the API serves requests. userIdisundefinedfor an unauthenticated request.
Two functions evaluate the gates:
| Function | Behavior |
|---|---|
CheckTenantAccess(userId, tenantId) | Resolves to the first denial, or { allowed: true }. |
AssertTenantAccess(userId, tenantId) | Calls CheckTenantAccess and throws a 403 whose body is the denying gate's code. |
import {
AssertTenantAccess,
CheckTenantAccess,
} from "@antelopejs-private/cms/interfaces/cms/tenant-access";
const access = await CheckTenantAccess(user._id, tenantId);
if (!access.allowed) {
// access.code is a machine-readable key
}
await AssertTenantAccess(user._id, tenantId); // or let it throw
AuthTenantOwner and AuthTenantMember call AssertTenantAccess unless the route opts out with bypassTenantAccessGate (Permissions).
Use a gate to deny a tenant outright; use a permissions resolver to shape what its users may do.
Memberships and Ownership
applyTenantOwnership(userModel, userId, tenantId, patch) upserts a membership row for the user in the tenant.
import { GetModel } from "@antelopejs/interface-database-decorators";
import { applyTenantOwnership } from "@antelopejs-private/cms/interfaces/cms/tenant-ownership";
import { UserModel } from "@antelopejs-private/cms/interfaces/cms-auth/db";
await applyTenantOwnership(GetModel(UserModel), userId, tenantId, {
roleIds: ["role-editor"],
isTenantOwner: false,
});
TenantMembershipPatch is the fourth argument:
| Field | Type | Description |
|---|---|---|
roleIds | string[] | Roles the member holds after the write. Replaces the previous list. |
isTenantOwner | boolean | Ownership flag after the write. |
invitedBy | string | null | Recorded on insert. Ignored when the membership row already exists. |
When the user is not yet a member, applyTenantOwnership fires Hook.MEMBER_BEING_ADDED before the write and Hook.MEMBER_ADDED after it. For an existing member it updates roleIds and isTenantOwner and fires neither (Hooks).
Two helpers keep the legacy platform-owner flag on the user row aligned with tenant ownership:
| Function | Signature |
|---|---|
syncPlatformOwnerOnTenantOwnerChange | (userModel, userId, tenantId, nextIsTenantOwner: boolean) => Promise<void> |
clearPlatformOwnerOnMemberRemoval | (userModel, userId, tenantId, removedMemberWasTenantOwner: boolean) => Promise<void> |
Both mirror the value onto User.owner, and both are no-ops outside the default tenant and when the backend runs in SaaS mode — where User.owner means platform ownership rather than tenant ownership. clearPlatformOwnerOnMemberRemoval additionally does nothing when the removed member was not a tenant owner. Call them after a write that changes ownership by a path other than applyTenantOwnership, which already mirrors on its own.
Built-in Data Controllers
The interface ships the two data controllers the built-in settings screens are built on. The CMS module registers both itself, so their routes exist as soon as it is loaded: reference them, do not register them again.
| Export | Import from | Table | Base path |
|---|---|---|---|
memberSettingDataAPI | @antelopejs-private/cms/interfaces/cms/data-controllers | TenantMember | /api/tables/members |
roleSettingDataAPI | @antelopejs-private/cms/interfaces/cms/data-controllers/roles | Role | /api/tables/roles |
The /data-controllers barrel re-exports the members controller only; the roles controller is imported from its own path.
import { roleSettingDataAPI } from "@antelopejs-private/cms/interfaces/cms/data-controllers/roles";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-base/data-types/default-types";
// a role picker on one of your own form fields
const reviewerRolesField = {
id: "reviewerRoleIds",
label: "Reviewer roles",
type: new DefaultDataTypes.RelationType({
multiple: true,
dataApiController: roleSettingDataAPI,
keyMapping: { label: "name", value: "_id" },
}),
};
Both bind their model with TenantScopedModel, so they read and write the request's tenant. memberSettingDataAPI exposes the member's name and email joined from the User table, the member's roles as a relation onto roleSettingDataAPI, isTenantOwner, joinedAt, isValidated and a computed connected field; roleSettingDataAPI exposes name, permissions, createdAt and updatedAt. Reference either one as the relation target of your own columns when you need a member or role picker, or feed one to a TableView to render it.
Next Steps
Invites covers bringing a user into a tenant that has no membership row for them yet.