Auth & permissions
The DMS ships a built-in authentication and RBAC layer. Authentication answers "who is calling"; permissions answer "may they see or do this". The login, two-factor, and recovery flows themselves are the previous chapter (Built-in authentication) — this one covers gating your routes and pages with them, and the permission model underneath.
Authenticate routes
Apply auth with parameter decorators on controller route methods. Each one resolves and injects the user, or rejects the request when its requirement isn't met:
import { Controller, Get } from "@antelopejs/interface-api";
import { AuthUserWithPermission } from "@antelopejs-private/cms/interfaces/cms/guards";
import { AuthUser, IfAuthUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import { User } from "@antelopejs-private/cms/interfaces/cms-auth/db"; // the User type lives in cms-auth/db
export class ThingController extends Controller("/api/things") {
@Get("") async list(@AuthUser() user: User) {
/* requires login, else 401 */
}
@Get("/public") async maybe(@IfAuthUser() user: User | undefined) {
/* optional; undefined if anonymous */
}
@Get("/admin") async admin(@AuthUserWithPermission(SomePageController) user: User) {
/* requires permission, else 403 */
}
}
The decorators split across two sources along the authentication/authorization line — the cms-auth interface answers "who is calling", cms/guards layers "may they" checks on top:
| Decorator | From | Requires |
|---|---|---|
@AuthUser() | cms-auth | A logged-in user (validates the account); 401 otherwise. |
@IfAuthUser() | cms-auth | Optional — injects User | undefined; anonymous is allowed. |
@AuthRawUser() | cms-auth | The user without the extra validation @AuthUser does. |
@AuthOwnerOnly() | cms-auth | A platform owner (not merely a tenant owner). |
@AuthUserWithPermission(target[, options]) | cms/guards | A user and the permission tied to target (a page/component, or an Action); 403 otherwise. |
@AuthTenantOwner() / @AuthTenantMember() | cms/guards | Tenant ownership / membership. |
The three cms/guards decorators also run the tenant access gate before answering: when a registered gate denies the tenant, the request fails with a 403 carrying the gate's code. All three accept a { bypassTenantAccessGate: true } option for the few recovery routes that must stay reachable when the tenant is denied (billing, payment).
Authenticate pages
A page's auth comes from its PageController options (see Pages & components):
publicAccess: true— no auth required.authOnly: true— require login, no permission check.- (default) — require login and the page's auto-derived permission.
permission: <Partial<Permission> | Action>— attach a specific permission/action.noComponentPermissions: true— don't derive per-component permissions for this page.bypassTenantAccessGate: true— keep the page reachable while a gate denies the tenant, permission checks unchanged (see Tenant access gate).
Routes inherit their controller's auth. A route declared under a Controller(...) that a page or DataController wraps is already gated to logged-in callers — don't re-add a login check. Add finer checks (ownership, a specific permission) only when the route needs more than "is logged in". For example, the DataController/TableView (Data controller) list/get/edit/export routes are protected by the parent controller; they are not open endpoints.
Permissions & RBAC
Permissions are hierarchical and mostly auto-derived from the page → component → action tree. A component's permission id is the page's fullId — its category chain plus the page id — followed by the component key, with nested children and actions appended. So a form component on the page whose fullId is pages.form.form-grouped registers pages.form.form-grouped.form, and a delete action on it registers pages.form.form-grouped.form.delete.
Every synchronously declared .child(id, component) position registers its own permission, including layout containers such as grid rows and stack items. Grants are exact: granting a parent makes the branch reachable but does not grant any child automatically, so a role needs the full chain of component ids it should render. The Roles form selects descendants when you check a parent, but existing stored roles remain flat permission lists.
They register with the page — when its @RegisterPage decorator runs — unless the page sets noComponentPermissions (or authOnly). Users hold roles; a role holds a flat list of granted permission ids; a platform owner holds * (everything).
Register a standalone permission
When a module needs its own permission (for example a "view" vs "export" split), register it — usually in construct:
import { RegisterPermission } from "@antelopejs-private/cms/interfaces/cms/permissions";
RegisterPermission("reports.export", {
id: "reports.export",
title: "$my_app.perm.reports_export", // i18n key — add to every locale file
icon: "i-ph-download",
defaultGranted: false,
dependencies: ["reports.view"], // declarative metadata only — granting a permission neither requires nor grants its dependencies
});
A Permission carries an id and a title, optional icon, description, and dependencies, and a defaultGranted flag. Then gate with @AuthUserWithPermission(...) on routes, or via the page/component permission option. GetPermission(id) and GetPermissions() read the registry.
How the effective set is computed
A user's permissions are resolved in two steps:
- The base set comes from roles: the union of the granted ids of the user's roles in the current tenant — or
Set("*")for a platform owner. - That set runs through the registered permission resolvers, which may add or remove entries. The result is the effective set.
Every permission surface consults the effective set — @AuthUserWithPermission, the permission list the frontend reads, page and menu visibility, table-view actions, search — so the dashboard never shows an entry its routes would refuse.
Check a permission imperatively
To test a permission outside the decorators, resolve the caller's effective set, then check the id against it:
import {
GetEffectiveUserPermissions,
HasPermission,
} from "@antelopejs-private/cms/interfaces/cms/permissions";
// base set (roles / owner wildcard) passed through the permission resolvers
const permissions = await GetEffectiveUserPermissions(user, tenantId, roleIds, roleModel);
// true on the "*" wildcard, a direct grant, or a defaultGranted permission
if (await HasPermission(permissions, "reports.export")) {
// …
}
GetEffectiveUserPermissions is exactly what the built-in surfaces use, so an imperative check with it always agrees with the decorators. The lower-level GetUserPermissions(user, roleIds, roleModel) still exists but returns the base set only, before resolvers — do not authorize against it directly.
Permission resolvers
A permissions resolver is the hook a module registers to reshape the effective set: it receives the caller's current permission set for a tenant and returns a new one, adding or removing entries. This is how a module changes what users can do without touching their roles — cms-saas registers one to intersect each user's permissions with their tenant's plan (SaaS mode).
import { RegisterPermissionsResolver } from "@antelopejs-private/cms/interfaces/cms/permissions-resolver";
RegisterPermissionsResolver({
id: "my_app.trial-limits",
order: 100, // ascending — later resolvers see this one's result
resolver: async (userId, tenantId, currentPermissions) => {
// return a new Set to add or remove effective permissions for this user/tenant
return currentPermissions;
},
});
Resolver semantics to rely on:
- Resolvers run in ascending
order, each receiving the previous one's result; equalorderruns in registration order. - A resolver that throws fails the request (fail-closed) — it is never silently skipped.
- The registration is removed automatically when the registering module unloads; there is no manual unregister call.
- Register in your module's
construct()so the resolver is active before the API starts serving requests.
@AuthUser-only routes, tenant membership guards) and it cannot revoke a permission registered with defaultGranted, which HasPermission grants after the resolvers run. To cut a tenant off from the product entirely, use a tenant access gate instead of returning an empty set.Tenant access gate
A tenant access gate is a binary, tenant-level access check: it decides whether a tenant may use the product at all, independently of what any permission set says. Every tenant authorization surface enforces it — @AuthUserWithPermission, @AuthTenantOwner/@AuthTenantMember, table-view data routes, page and menu visibility, realtime page access — so one registration cuts a tenant off everywhere at once. cms-saas registers one to lock workspaces with a blocking subscription status (SaaS mode).
import { RegisterTenantAccessGate } from "@antelopejs-private/cms/interfaces/cms/tenant-access";
RegisterTenantAccessGate({
id: "my_app.subscription-status",
order: 100, // ascending — the first denial wins
gate: async (userId, tenantId) => {
if (await tenantIsBlocked(tenantId)) {
// the code becomes the 403 body — a machine-readable i18n key the
// frontend can match on
return { allowed: false, code: "my_app.errors.tenant_blocked" };
}
return { allowed: true };
},
});
Gates follow the same lifecycle rules as resolvers: ascending order with the first denial winning, fail-closed on throw, automatic unregistration on module unload, and registration in construct(). userId is undefined for unauthenticated requests.
A denial is enforced in two modes. Route guards answer a hard 403 whose body is the gate's code. The site layout, permission list, and realtime surfaces degrade softly instead — the user keeps an authenticated session, publicAccess/authOnly pages stay reachable, and everything permission-gated reports no access — so a dedicated recovery screen can still render. Routes that must stay reachable for a denied tenant opt out explicitly:
@Post("/portal-session")
async createPortalSession(
@AuthTenantOwner({ bypassTenantAccessGate: true }) user: User,
) {
// the recovery path a blocked tenant uses to pay
}
A page opts out with the mirror option in its PageController options:
@RegisterPage()
export class BillingPage extends PageController("billing", {
displayName: "Billing",
category: workspaceSettings,
bypassTenantAccessGate: true, // the recovery screen of a blocked tenant
}) {}
The flag covers the page's visibility (site layout and menu), its layout route, and the permissions the client keeps for that surface — the permission list of a denied tenant is narrowed to the flagged pages and their components instead of being emptied. One deliberate exception: a caller holding the * wildcard keeps it whole, because the platform owner is usually the one who has to use the recovery page; the wildcard is still only handed out when at least one surface carries the flag, and every route keeps enforcing the gate regardless. Permission checks still apply in full, unlike authOnly, which would drop them. A flagged category passes the flag to its pages. The data routes feeding the page are not covered: a data API is not owned by a single page, so it opts out through its own guard option, as above — or, for table-view data routes, through the mirror bypassTenantAccessGate option in TableViewOptions, which keeps the table readable (view, list, select) and leaves every write, plus export, gated (Tables).
To test the gate imperatively, CheckTenantAccess(userId, tenantId) returns the first denial (or { allowed: true }), and AssertTenantAccess(userId, tenantId) throws the corresponding 403.
Built-in authentication
The complete auth system the DMS ships — sessions and token rotation, invite-based signup, login with tenant selection, two-factor, email validation, and password recovery.
Multi-tenant data
Scope models to a tenant, query across tenants, manage memberships, and invite users into a tenant.