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

Multi-tenant data

Scope models to a tenant, query across tenants, manage memberships, and invite users into a tenant.

The DMS is multi-tenant by default: one backend serves isolated tenants (workspaces/orgs), each with its own data. This is a deliberate design choice — building a multi-tenant SaaS is the case the DMS is meant to make easy, so tenancy is wired into auth, data, and the dashboard from the start instead of bolted on later. Auth & permissions covers the tenant auth decorators; here the subject is how data gets scoped to a tenant, how memberships change, and the invites interface that brings users into a tenant.

How the request's tenant is resolved

Every authenticated request carries its tenant in the session token: getRequestTenantId(ctx) decodes the bearer JWT (it does not re-verify it) and returns its tenantId claim. An anonymous request has no token, so it resolves to the built-in default tenant — the one bootstrapped at first run, where a single-tenant project keeps all of its data.

src/data-api/invoices.ts
import { getRequestTenantId } from "@antelopejs-private/cms/interfaces/cms/request-tenant";
getRequestTenantId trusts an unverified claim. It is only safe behind an auth decorator (@AuthUser, @AuthTenantMember, a PageController) that has already verified the token's signature. Never authorize on the tenant id in a route that isn't auth-gated — the claim is forgeable otherwise.

Scope a model to the tenant

The @TenantScopedModel decorator injects a model already bound to the request's tenant. It works in both places you bind a model: as a parameter decorator on a route handler, and as a property decorator on a DataController's model field.

In a route:

src/routes/billing.ts
import { Controller, Get } from "@antelopejs/interface-api";
import { AuthTenantMember } from "@antelopejs-private/cms/interfaces/cms/guards";
import { TenantScopedModel } from "@antelopejs-private/cms/interfaces/cms/tenant-scoped-model";
import { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class BillingController extends Controller("/api/billing") {
  @Get("/status")
  async status(
    @AuthTenantMember() _user: User,
    @TenantScopedModel(SubscriptionModel) subs: SubscriptionModel,
  ) {
    return subs.findOne(); // reads only this tenant's data
  }
}

In a DataController (Data controller), decorate the field marked with @ModelReference:

src/data-api/invoices.ts
@ModelReference()
@TenantScopedModel(InvoiceModel)
declare model: InvoiceModel;

@TenantScopedModel(Model) is shorthand for the general binding @Model(Model, (ctx) => getRequestTenantId(ctx)) — both resolve to GetModel(Model, <tenantId>). Reach for @Model directly when you need an instance other than the request's tenant: a static instance id, or a callback that resolves it some other way. A plain @Model(Model) (no second argument) is not tenant-scoped — use it only for registry-global tables like TenantModel itself.

Query across all tenants — CROSS_INSTANCE

To read one table across every tenant (a platform-owner view), pass CROSS_INSTANCE instead of a tenant id:

src/routes/admin.ts
import { CROSS_INSTANCE } from "@antelopejs/interface-database";

const members = GetModel(TenantMemberModel, CROSS_INSTANCE);
const rows = await members.listByUserWithTenantIds(user._id);
// each row already carries its tenant: { tenantId, member }
Cross-instance queries only make sense on a per-tenant schema, where rows come from many instances. Prefer model methods that already pair each row with its tenant — listByUserWithTenantIds returns { tenantId, member }.

Grant or change tenant membership

applyTenantOwnership(userModel, userId, tenantId, patch) upserts a tenant membership. It fires Hook.MEMBER_BEING_ADDED then Hook.MEMBER_ADDED — but only when the member is new:

src/routes/members.ts
import { applyTenantOwnership } from "@antelopejs-private/cms/interfaces/cms/tenant-ownership";

await applyTenantOwnership(this.userModel, user._id, tenantId, {
  roleIds: [],
  isTenantOwner: false,
});

Invites

inviteUserToTenant handles both adding an existing user to a tenant and inviting a new one by email, through a single call.

src/routes/team.ts
import { inviteUserToTenant } from "@antelopejs-private/cms/interfaces/cms/invites";

const result = await inviteUserToTenant({
  tenantId,
  email,
  roleIds: [],
  asTenantOwner: false,
  sendEmail: true, // opt-in — off by default
});

if (result.kind === "added") {
  // existing user → added directly (member hooks fired), result.userId
} else {
  // new email → invite row created, result.inviteId + result.token
}

The flow branches on whether a user with that email already exists:

  • Existing userapplyTenantOwnership(...) runs directly (no token); returns { kind: "added", userId }.
  • New email → a 7-day invite is created; if sendEmail is set, the admin-invite email dispatches fire-and-forget; returns { kind: "invited", inviteId, token }.

Optional firstname / lastname are stored on the invite and prefill the name field of the signup page through the invite link (the Members invite form requires them when skipEmailValidation is set).

Creating an invite replaces any existing invite for the same email (firing INVITE_DELETED), then fires INVITE_BEING_CREATED and INVITE_CREATED. Subscribe to those hooks (Backend services) to react.

inviteUserToTenant does not throw on duplicates — it's deliberately side-effect-light. Do context-specific pre-checks in the caller (for example, return 409 when the user is already a member).

The SaaS module (cms-saas)

Everything above — tenant isolation, scoped models, invites, roles, the platform owner — is the foundation a SaaS needs, not the commercial layer itself. The optional cms-saas module adds that layer on top: public registration, plans, subscriptions, and billing, run from the same admin you ship your customers. SaaS mode covers it.