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

Permissions

Overview

Permission ids are hierarchical strings built from the page tree: a page's fullId, then every component id in the position's path, then the action id when present. A role holds a flat list of granted ids; a platform owner holds the wildcard *. Two functions answer "what can this user do" — GetUserPermissions returns the base set from roles, GetEffectiveUserPermissions runs that set through the registered resolvers. Authorization surfaces use the effective set.

The surface spans three subpaths:

SubpathContains
@antelopejs-private/cms/interfaces/cms/permissionsThe registry, the module-scope marks, and the permission computation
@antelopejs-private/cms/interfaces/cms/permissions-resolverRegistering and running resolvers
@antelopejs-private/cms/interfaces/cms/guardsRoute guards

Register a Permission

Pages, components and actions register their own permissions when the page registers (Pages and Modules). Register a standalone one when a capability has no component behind it:

import { RegisterPermission } from "@antelopejs-private/cms/interfaces/cms/permissions";

RegisterPermission("reports.export", {
  id: "reports.export",
  title: "$my_app.perm.reports_export",
  icon: "i-ph-download",
  description: "$my_app.perm.reports_export_description",
  dependencies: ["reports.view"],
  defaultGranted: false,
});

Permission

FieldTypeDescription
idstringThe permission id. Required.
titlestringLabel shown in the roles editor. Required.
iconstringIcon name.
descriptionstringLonger description.
dependenciesstring[]Other permission ids this one relates to. Metadata carried with the permission.
defaultGrantedbooleanTreat the permission as held by every caller, without a role grant.

RegisterPermission(id, permission) takes the id twice — once as the registry key, once inside the record — so keep them equal.

UnregisterPermission(id) drops a permission from the grantable tree again. It exists for registrations undone while the process keeps running — a page extension whose module stopped, for instance. A permission left behind after its surface is gone stays offered in the roles editor and stays grantable, so anything that registers a permission conditionally should unregister it on the same condition.

Read the Registry

import {
  GetPermission,
  GetPermissions,
} from "@antelopejs-private/cms/interfaces/cms/permissions";

const permission = await GetPermission("reports.export");
const tree = await GetPermissions();

GetPermission(id) resolves to the Permission or undefined. GetPermissions() resolves to a Record<string, PermissionTree> keyed by the first id segment, where

type PermissionTree = {
  data?: Permission;
  children: Record<string, PermissionTree>;
};

Each level keys its children by the next dot-separated segment. data is absent for a level that exists only to hold children.

Both are interface functions and return promises even though the registry is in-process.

Compute a User's Permissions

import {
  GetEffectiveUserPermissions,
  GetUserPermissions,
  HasPermission,
} from "@antelopejs-private/cms/interfaces/cms/permissions";
FunctionSignature
GetUserPermissions(user: { owner?: boolean }, roleIds: string[], roleModel: RoleModel) => Promise<Set<string>>
GetEffectiveUserPermissions(user: { _id: string; owner?: boolean }, tenantId: string, roleIds: string[], roleModel: RoleModel) => Promise<Set<string>>
HasPermission(permissions: Set<string>, permissionId: string) => Promise<boolean>

GetUserPermissions returns Set(["*"]) for a user with owner: true, an empty set when roleIds is empty, and otherwise the union of the permissions arrays of the roles it loads through roleModel. This is the base set: it does not account for resolvers, so do not authorize against it.

GetEffectiveUserPermissions composes that base set with ApplyPermissionsResolvers and is the set every authorization surface uses.

HasPermission resolves in this order:

  1. true when the set holds the wildcard *.
  2. false when the id is module-scoped.
  3. true when the set holds the id, or when the registered permission carries defaultGranted.
  4. false otherwise.

The wildcard is checked first, so a platform owner passes even for a module-scoped id.

import { GetModel } from "@antelopejs/interface-database-decorators";
import {
  RoleModel,
  TenantMemberModel,
} from "@antelopejs-private/cms/interfaces/cms/db";
import {
  GetEffectiveUserPermissions,
  HasPermission,
} from "@antelopejs-private/cms/interfaces/cms/permissions";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

async function canExport(user: User, tenantId: string): Promise<boolean> {
  const member = await GetModel(TenantMemberModel, tenantId).getByUser(user._id);
  const permissions = await GetEffectiveUserPermissions(
    user,
    tenantId,
    member?.roleIds ?? [],
    GetModel(RoleModel, tenantId),
  );
  return HasPermission(permissions, "reports.export");
}

RoleModel and TenantMemberModel come from Tenancy.

Module-Scoped Permissions

Permissions belonging to a module page or category are never added to the grantable permission tree, and a role grant on such an id never grants anything. They are marked instead:

import {
  IsModuleScopedPermission,
  MarkModuleScopedPermission,
  UnmarkModuleScopedPermission,
} from "@antelopejs-private/cms/interfaces/cms/permissions";

MarkModuleScopedPermission("modules.automation.builder");
IsModuleScopedPermission("modules.automation.builder.canvas.add"); // true
UnmarkModuleScopedPermission("modules.automation.builder");

IsModuleScopedPermission(id) walks the id upward one dot-separated segment at a time, so marking a page id also covers every component and action id derived from it. UnmarkModuleScopedPermission(id) removes the mark for that exact id only.

Page registration calls MarkModuleScopedPermission for you whenever isInsideModule holds (Pages and Modules); call it directly only for ids you build outside that path.

Reshape the Effective Set

A permissions resolver receives a caller's current permission set for a tenant and returns the set to use instead.

import { RegisterPermissionsResolver } from "@antelopejs-private/cms/interfaces/cms/permissions-resolver";

export async function construct(): Promise<void> {
  RegisterPermissionsResolver({
    id: "my_app.plan-limits",
    order: 100,
    resolver: async (userId, tenantId, currentPermissions) => {
      const allowed = await loadPlanPermissions(tenantId);
      return new Set([...currentPermissions].filter((p) => allowed.has(p)));
    },
  });
}
type PermissionsResolverFn = (
  userId: string,
  tenantId: string,
  currentPermissions: Set<string>,
) => Promise<Set<string>> | Set<string>;

PermissionsResolverInfo carries id, resolver and order.

Semantics the interface guarantees:

  • Resolvers run in ascending order, each seeing the previous one's result; equal order runs in registration order, which is module load order.
  • Registering an id twice replaces the earlier resolver rather than adding a second one.
  • A resolver that throws aborts the computation and fails the request. It is never skipped.
  • The registration is removed when the registering module unloads; there is no unregister call.
  • Register in construct() so the resolver is active before the API serves requests.

ApplyPermissionsResolvers(userId, tenantId, basePermissions) runs the pipeline and resolves to the effective set. Prefer GetEffectiveUserPermissions, which computes the base set for you and calls it.

A resolver shapes a permission set. It cannot deny a surface that consults no permission set, and it cannot revoke a defaultGranted permission, which HasPermission grants after the resolvers ran. To cut a tenant off from the product entirely, register a tenant access gate instead (Tenancy).

Guard a Route

Three decorators from @antelopejs-private/cms/interfaces/cms/guards gate a route on the caller.

AuthUserWithPermission

AuthUserWithPermission(target, options?) requires an authenticated user holding the exact registered permission of target, which is a Component, a page controller class, or an Action (Components). A child component resolves to its full positional path. options is the same TenantGuardOptions the two tenant guards take, described below.

import { Controller, Get } from "@antelopejs/interface-api";
import { AuthUserWithPermission } from "@antelopejs-private/cms/interfaces/cms/guards";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
import { InvoicesPage } from "../pages/invoices";

export class InvoiceReportController extends Controller("/api/invoices") {
  @Get("/summary")
  async summary(@AuthUserWithPermission(InvoicesPage) _user: User) {
    // reachable only by a caller holding the InvoicesPage permission
  }
}

The guard resolves the target's permission id, loads the caller's membership in the tenant carried by the token, computes their effective permissions and calls HasPermission. It answers 403 when the permission is missing, and also when the token carries no tenant claim — a permission can only be granted inside a tenant. A target that carries no permission id yet passes the authentication check only. The tenant access gate runs whenever the token carries a tenant claim, including for a target with no permission id, and it runs before the permission is resolved — unless options.bypassTenantAccessGate is set, which skips the gate while leaving the permission check in place.

AuthTenantOwner and AuthTenantMember

Both resolve the tenant from the request's JWT tenantId claim and check the caller's membership in it. AuthTenantOwner additionally requires isTenantOwner. Each answers 401 without a token and 403 when the requirement is not met, and each runs the tenant access gate before returning the user.

import { Controller, Post } from "@antelopejs/interface-api";
import {
  AuthTenantMember,
  AuthTenantOwner,
} from "@antelopejs-private/cms/interfaces/cms/guards";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class BillingController extends Controller("/api/billing") {
  @Post("/portal-session")
  async portalSession(
    @AuthTenantOwner({ bypassTenantAccessGate: true }) user: User,
  ) {
    // stays reachable for a tenant a gate denies
  }

  @Post("/usage")
  async usage(@AuthTenantMember() user: User) {
    // any member of the request's tenant
  }
}

TenantGuardOptions has one field, and all three guards accept it:

OptionTypeDescription
bypassTenantAccessGatebooleanSkip the tenant access gate for this route. Reserved for routes that must stay reachable when a gate denies the tenant.

The opt-out has two mirrors elsewhere, for the surfaces a route guard does not cover: MenuOptions.bypassTenantAccessGate keeps a page and its menu entry reachable (Pages and Modules), and TableViewOptions.bypassTenantAccessGate covers a table view's data routes, which belong to a controller rather than to any one page.

A denial from a gate surfaces as a 403 whose body is the gate's code (Tenancy).

Guards as Properties

All three are parameter and property decorators. Declared as a class property, a guard gates every route on that controller:

import { Controller, Post } from "@antelopejs/interface-api";
import { AuthTenantMember } from "@antelopejs-private/cms/interfaces/cms/guards";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class FilesController extends Controller("/api/files") {
  @AuthTenantMember()
  declare user: User;

  @Post("/presign")
  async presign() {
    // the guard already ran, whether or not this handler reads `this.user`
  }
}

A property provider runs on every request to a route of the controller, independently of whether the handler body reads the property. That is what makes the property form a guard rather than a convenience: removing the read does not remove the check.

Next Steps

Tenancy covers the tenant the guards resolve, the roles the permission sets come from, and the access gates.