[data-reveal]{opacity:1!important;transform:none!important}
Tenancy
Interfaces

Tenancy

The DMS serves several tenants from one backend.

Overview

The DMS 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.

For a first implementation, follow Multi-tenant data: register the table in the tenant schema, protect the route, and inject a tenant-bound model. Use this reference for the schema constants and lifecycle contracts. Resolving a tenant id is not an authorization check; getRequestTenantId must run behind authentication.

The tenancy surface spans several subpaths:

SubpathContains
@antelopejs/interface-dms/constantsSchema names and the default tenant
@antelopejs/interface-dms/dbThe Tenant, TenantMember and Role tables and their models
@antelopejs/interface-dms/request-tenantResolving the request's tenant
@antelopejs/interface-dms/tenant-scoped-modelInjecting a tenant-bound model
@antelopejs/interface-dms/tenant-accessTenant access gates
@antelopejs/interface-dms/tenant-ownershipMembership and ownership writes
@antelopejs/interface-dms/tenant-lifecycleProducer admission and permanent closure before deletion
@antelopejs/interface-dms/utils/saas-modeDeclaring SaaS mode
@antelopejs/interface-dms/data-controllersThe built-in members and roles data controllers

Constants

import {
  CORE_SCHEMA_NAME,
  DEFAULT_TENANT_ID,
  DEFAULT_TENANT_NAME,
  TENANT_SCHEMA_NAME,
} from "@antelopejs/interface-dms/constants";
ConstantValueMeaning
CORE_SCHEMA_NAME"dms-core"Schema for tables with a single instance across the whole backend.
TENANT_SCHEMA_NAME"dms-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/interface-dms/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/interface-dms/db, along with their table-name constants tenantsTableName, tenantMembersTableName and ROLES_TABLE_NAME.

Tenant

Table tenants in dms-core — one row per tenant, shared across the backend.

FieldTypeNotes
_idstringTenant id. This is the instance id every per-tenant model handle takes.
namestringDisplay name.
createdAtDateCreation time, indexed.
updatedAtDateUpdate 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 dms-tenant — one row per member of the tenant the handle is bound to.

FieldTypeNotes
_idstringMembership id.
userIdstringIndexed, related to the User table of the Auth domain.
roleIdsstring[]Related to Role, many. The roles whose grants make up the member's base permission set.
isTenantOwnerbooleanWhether the member owns the tenant.
joinedAtDateCreation time, indexed.
invitedBystring | nullIndexed, related to User.

TenantMemberModel extends BasicDataModel(TenantMember, tenantMembersTableName):

MethodReturns
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 dms-tenant — the tenant's roles.

FieldTypeNotes
_idstringRole id, as referenced by TenantMember.roleIds.
createdAtDateCreation time, indexed.
updatedAtDateUpdate time, indexed.
namestringIndexed.
permissionsstring[]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/interface-dms/auth";
import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
import type { User } from "@antelopejs/interface-dms/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 @antelopejs/interface-dms/auth, 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/interface-dms/guards";
import { RoleModel } from "@antelopejs/interface-dms/db";
import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
import type { User } from "@antelopejs/interface-dms/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/interface-dms/db";
import { TenantScopedModel } from "@antelopejs/interface-dms/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/interface-dms/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 its code is 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.
  • userId is undefined for an unauthenticated request.

Two functions evaluate the gates:

FunctionBehavior
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/interface-dms/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/interface-dms/tenant-ownership";
import { UserModel } from "@antelopejs/interface-dms/auth/db";

await applyTenantOwnership(GetModel(UserModel), userId, tenantId, {
  roleIds: ["role-editor"],
  isTenantOwner: false,
});

TenantMembershipPatch is the fourth argument:

FieldTypeDescription
roleIdsstring[]Roles the member holds after the write. Replaces the previous list.
isTenantOwnerbooleanOwnership flag after the write.
invitedBystring | nullRecorded 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:

FunctionSignature
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.

SaaS Mode

By default, the owners of the default tenant are the platform owners. In SaaS mode the two are independent: tenant owners are customers, and platform ownership is managed on its own. The SaaS module declares that mode itself with RegisterSaasMode; the DMS never infers it from the modules it finds loaded.

import { RegisterSaasMode } from "@antelopejs/interface-dms/utils/saas-mode";

export async function construct(): Promise<void> {
  RegisterSaasMode();
}

While SaaS mode is on:

  • the default tenant's owner flag is no longer mirrored onto User.owner, by applyTenantOwnership or by the two helpers above;
  • removing members from the default tenant no longer refuses to remove the last platform owner.

The registration is removed when the registering module unloads, and the mode stays on while at least one registration is active. isSaasMode() resolves to the current state, false until a module registers. Both behaviours read it on every request, so it does not matter whether the SaaS module registers before or after the DMS starts.

Tenant Lifecycle Admission

Membership and invite producers coordinate with tenant deletion through @antelopejs/interface-dms/tenant-lifecycle. This is a write-lifecycle contract, not an authorization check or a transaction: authorize the caller separately.

runTenantLifecycleOperation

runTenantLifecycleOperation<T>(tenantId: string, work: () => Promise<T>): Promise<T> admits one invocation before calling work, then returns its result after releasing that invocation's admission. Closed admission rejects without running work. Built-in membership and invite entry points already participate; custom producers must use this entry point and await every effect, with no detached writer.

A successful callback releases its admission. A deliberate HTTP 4xx refusal also attempts release and rethrows the original refusal; other failures retain admission because their effects may be indeterminate. Retrying is a new invocation and never retires the failed one's identity. There is no automatic expiry or reconciliation API.

closeTenantLifecycleAdmission

closeTenantLifecycleAdmission(tenantId: string): Promise<void> permanently closes admission, then checks that no admitted operation remains. It rejects if work is active or indeterminate, or if the database cannot confirm the transition. Rejection can leave admission closed: it never authorizes deletion or reopening.

Call it before any destructive tenant phase, including deletion hooks. Do not delete business data when closure rejects. Preserve the tenant_lifecycle and invite_resolutions safety rows after deletion; dropping the tenant schema or recreating those rows can reopen admission. Mixed-version writers and raw model writes bypassing admission prevent a safe rollout. These table names describe a preservation obligation, not supported raw-model APIs.

The backend-services guide explains where this fits in a module's deletion flow.

Built-in Data Controllers

The interface ships the two data controllers the built-in settings screens are built on. The DMS module registers both itself, so their routes exist as soon as it is loaded: reference them, do not register them again.

ExportImport fromTableBase path
memberSettingDataAPI@antelopejs/interface-dms/data-controllersTenantMember/api/tables/members
roleSettingDataAPI@antelopejs/interface-dms/data-controllers/rolesRole/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/interface-dms/data-controllers/roles";
import { DefaultDataTypes } from "@antelopejs/interface-dms/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 plain relation onto roleSettingDataAPI (the DMS members page draws it with its own role picker, through the edit form's component slot), 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.