Files
nocobase[bot] 372b4039ef chore: merge release 2026-10-12.1 into v3-develop [skip ci]
current app-template-default version: 1.0.0-beta.63
2026-10-12 01:07:21 +00:00
..

@nocobase/app-plugin-authorization

NocoBase application integration for authorization: authenticated identities, Permission Sets, pages, settings, composite resources, database field, record and relation policies, and the /api/authorization HTTP surface. It ships no pages: an application builds its permission management pages on that surface. It builds on @nocobase/authorization, whose terms and contracts apply unchanged. For configuration without code, read the user guide; for implementing a feature, read the development Skill.

Terminology

Term Meaning
Resource ref { type, id }: the target of a check or a grant. Every grant states its type.
Resource type The only unit of judgement, never displayed and untitled: items, an authorize function and the recordAccess capability. This plugin registers page, settings and database.collection (with recordAccess); the core registers the reserved composite type itself.
Item One grantable thing of a catalog type, { id, title, actions }; an unregistered (type, id, action) is denied. A record type has no items and validates only the action.
Page Record type page, action access. The server validates no page id; the client fills the pages.page subsection, which this plugin registers through authz.ui, and its resource groups from the route tree.
Settings item Type settings: one administration surface, { id, title, actions }, registered with authz.settings.add and placed with authz.ui.place. Ids must be registered.
Collection Type database.collection: a collection opted into the permission model with authz.database.collections.add.
Composite resource Type composite: a resource whose actions expand into a set of underlying grants, each optionally bound to a named data scope, registered with authz.compositeResources.define. Business operations are defined as composite resources, and the workspace shows them in its Business section.
Data scope A named slot on a composite action that a grant or rule fills with a record selection. It targets the one collection its grant actions address.
Record selection all, records with ids, or recordAccess with a key and params.
Record access A named way to select records; recordAccess.recordsIOwn and its siblings are the built-in ones.
Section A top-level heading of a permission management page, { name, title, order }: pages, business, administration and any a plugin adds through authz.ui.sections.add. Display only.
Subsection A left-side entry under a section, { name, title, parent, order? }, such as authorization or automation; authz.ui.place lists a resource in one. Display only.
Resource group A right-side heading resources are listed under, { name, title, parent?, order? }, nested to any depth and registered with authz.ui.groups.add. Display only.
Authorization context The request's authz Hono variable: authorize, can, require, snapshot for the signed-in identity.

Layers

 storage                          judgement                                    use
 ──────────────────────────       ───────────────────────────────────────      ──────────────────────────────────
 permission-set tables ─grants─▶ Permission Sets (Grant Provider)             server: c.get('authz').require(...)
                                  └▶ composite expansion ┐                             authz.database.policyFor(...)
 rule tables ─constraints──────────────────────────────▶ resource type                 authz.database.authorizeRepository(...)
                                    page · settings · database.collection     client: useCan(...), route `authz`
                                    · composite · plugin types                HTTP:   /api/authorization/*

 display (authz.ui, never read by a check): sections ─▶ subsections ─▶ groups ─▶ resources (pages from the client route tree)

Entry points

Import Contents
@nocobase/app-plugin-authorization The same as ./server.
@nocobase/app-plugin-authorization/server Server plugin, createAppAuthorization, authorizationToken, database, page and settings APIs.
@nocobase/app-plugin-authorization/server/extension HTTP helpers for plugins that add authorization settings surfaces, such as the rule plugins.
@nocobase/app-plugin-authorization/client Client plugin, AuthorizationClient, useCan and related hooks.
@nocobase/app-plugin-authorization/client/plugin The client plugin factory alone.
@nocobase/app-plugin-authorization/client/react-providers The React provider contribution.
@nocobase/app-plugin-authorization/package.json The package manifest.

Install and configure

Register the default exports of ./client and ./server in the application's plugin composition roots, after authentication, and apply the application's migrations and seeds. Resolve authorizationToken wherever the service is needed; there is one instance per application and no other token.

import { authorizationToken } from '@nocobase/app-plugin-authorization/server';
const authz = app.container.resolve(authorizationToken);

createAppAuthorization(options) builds the instance from the plugin tuple [permissionSetsPlugin, databasePlugin, pagesPlugin, settingsPlugin, uiPlugin, ...config.plugins]; every extension is installed through its plugin's authorizationApi, so AppAuthorization is Authorization & { permissionSets, database, pages, settings, ui }, where Authorization already carries the built-in composites, plus whatever the configured plugins add. config.permissionSets names the rootSet (default root, unrestricted, assignable to users) and the defaultSet (default member, held by every signed-in user through the authenticated:* subject).

import type { AuthorizationConfig } from '@nocobase/app-plugin-authorization/server';
import { defaultAccess } from '@nocobase/app-plugin-authz-default-access/server';
import { sharingRules } from '@nocobase/app-plugin-authz-sharing-rules/server';
import { restrictionRules } from '@nocobase/app-plugin-authz-restriction-rules/server';

export default {
  permissionSets: { rootSet: 'root', defaultSet: 'member' },
  plugins: [defaultAccess(), sharingRules(), restrictionRules()],
} satisfies AuthorizationConfig;

Register what can be granted

All registration runs in a provider's boot, before the first request.

Settings item

authz.ui.sections.add({
  name: 'automation',
  title: { key: 'nav.automation', ns: '@nocobase/app-plugin-scheduler' },
  parent: 'administration',
});
authz.settings.add({
  id: 'scheduler.schedules',
  title: { key: 'authorization.title', ns: '@nocobase/app-plugin-scheduler' },
  actions: [
    {
      name: 'read',
      title: {
        key: 'authorization.read',
        ns: '@nocobase/app-plugin-scheduler',
      },
    },
  ],
});
authz.ui.place(
  { type: 'settings', id: 'scheduler.schedules' },
  { section: 'automation' },
);
const grant = authz.settings.grant('scheduler.schedules', ['read']);

A settings item may declare any action names. settings.grant refuses an id or action nobody registered. Where a permission management page lists it is a separate authz.ui.place; an unplaced item is listed under the "Other" subsection of administration, the settings type's default section, and startup logs a warning. Check a settings action on the server with requireSettings from ./server/extension or with context.require({ resource: { type: 'settings', id: 'scheduler.schedules' }, action: 'read' }).

Collection

authz.database.collections.add({
  name: 'quotes',
  title: 'Quotes',
  actions: ['read', 'create', 'update', 'delete'],
});

Registration is the opt-in: an unregistered collection is outside the permission model and is denied even to an unrestricted identity. Fields, primary key and relations are read from the database at check time. actions defaults to the four CRUD actions. Re-adding a collection with the same actions is a no-op, including when two modules register it with a different title or description: the first registration is kept and startup logs a warning, which authz.database.collections.warnings() also lists. Re-adding it with different actions throws, naming the collection and both action lists.

Composite resource with data scopes

Builder form, with typed fields and record access options:

import { defineCompositeResource } from '@nocobase/authorization/core';
import {
  defineDatabasePermission,
  recordAccess,
} from '@nocobase/app-plugin-authorization/server';

const quoteData = defineDatabasePermission((permission) =>
  permission
    .collection<Quote>('quotes')
    .title('Quotes')
    .options(recordAccess.recordsIOwn, recordAccess.allRecords)
    .default(recordAccess.recordsIOwn)
    .read(['id', 'projectId', 'amount', 'status'])
    .update(['status']),
);
export const quotes = defineCompositeResource('sales.quotes', (resource) =>
  resource
    .title('Quotes')
    .action('view', (action) => action.title('View').grant('quotes', quoteData))
    .action('submit', (action) =>
      action
        .title('Submit')
        .grant('quotes', quoteData)
        .grant('projects', projectData),
    ),
);

const reference = authz.compositeResources.define(quotes);
authz.ui.sections.add({ name: 'sales', title: 'Sales', parent: 'business' });
authz.ui.place(reference, { section: 'sales' });

Object form, the same data a builder produces:

authz.compositeResources.define({
  name: 'sales.reports',
  title: 'Reports',
  actions: [
    {
      name: 'export',
      title: 'Export',
      dataScopes: [
        {
          key: 'orders',
          title: 'Orders',
          options: ['recordsIOwn', 'allRecords'],
        },
      ],
      grants: [
        {
          resource: { type: 'database.collection', id: 'orders' },
          actions: [
            {
              action: 'read',
              policy: { type: 'database', fields: '*' },
              scopeKey: 'orders',
            },
          ],
        },
      ],
    },
  ],
});

A composite action composes exactly the grants its definition lists, on any resource type except another composite. A data scope binds to the one collection its grant actions address; database.collection declares recordAccess, which a data scope's target type needs. .grant(key, permission, { title? }) binds a database permission to a data scope named key. Read fields govern output and create or update fields govern input; delete has none. On create or update, '*' means every field a write may name, which leaves out the fields the database or Repository assigns — auto-increment, generated and optimistic-lock version columns — as writableFields from @nocobase/db decides them. .options(...references) limits the record access a grant may choose and .default(reference) sets the value used when a grant chooses nothing.

Custom resource type

import { grantBacked } from '@nocobase/authorization/core';

authz.resourceTypes.add({
  type: 'pm.issue',
  actions: ['read', 'close'],
  authorize: grantBacked({
    also: (request) => ownsIssue(request.principal.id, request.resource.id),
  }),
});

This is a record type: it declares its actions and no items, so any record id passes validation and only an undeclared action is denied; the type's authorize judges each record. Grants use id: '*' to mean every record. grantBacked() is the default judgement and permits when a grant without a policy matches. Users (user), notification and page are record types; settings, composite and database.collection are catalog types, which register items and deny any unregistered item or action.

Workspace placement: authz.ui

authz.ui.sections.add({ name: 'sales', title: 'Sales', parent: 'business' });
authz.ui.groups.add({ name: 'ledgers', title: 'Ledgers' });
authz.ui.place(reference, { section: 'sales', group: 'ledgers' });
authz.ui.defaultSection('report', 'administration');

authz.ui decides where a permission management page lists each resource and is never read by a check: top-level sections, each with its subsections, and each subsection's resources under their groups. The plugin ships no such page; the options routes return this layout for an application's own. This plugin registers the sections pages (order 0), business (100) and administration (200); its authorization subsection; and the default sections composite → business and settings → administration. pagesPlugin, which depends on ui, registers the pages.page subsection (order 0, titled sections.page in this plugin's namespace); an application's page fills it from its own route tree, and the options mark it with recordType: { type: 'page', actions }. Resource types have no titles, so every heading such a page shows comes from authz.ui.

Member Behavior
sections.add({ name, title, order }) A top-level section; order is required.
sections.add({ name, title, parent, order?, extend? }) A subsection, one level under a top-level section. Without extend, a deep-equal re-add is a no-op and anything else throws. With extend: true, as a client settings group is extended, the add does nothing when the subsection exists and creates it otherwise; the owner's later add replaces the title and order and throws only if the parents differ. extend on a top-level section throws.
groups.add({ name, title, parent?, order? }) A right-side group, nested to any depth.
place(ref, { section, group?, order? }) Lists a resource, { type, id } or a CompositeResourceReference, in a subsection and optionally a group. order positions it within the subsection; unordered resources follow in registration order. Placing in a top-level section throws; placing the same resource elsewhere throws.
defaultSection(type, section) Unplaced resources of type are listed under <section>.other. Types with no default section and no placement are not displayed; database.collection and user are such types.

A placement may name a subsection, group or resource that registers later. Startup validation runs in the provider's start hook, after every plugin's boot: it reports a placement whose subsection or group is unknown, a placement of an unregistered resource, and any composite data scope whose target type lacks recordAccess, throwing in development and logging a warning when NODE_ENV is production. It also warns about each unplaced composite or settings item, which is then listed under its default section's "Other". A plugin that extends a subsection another owns, as the scheduler extends automation, yields to the owner's title whichever boots first. The Pages entry is filled on the client from the route tree, with navigation groups as resource groups in menu order.

Record access

import { defineRecordAccess } from '@nocobase/authorization/core';
import { condition } from '@nocobase/app-plugin-authorization/server';

const prepared = authz.recordAccess.define(
  defineRecordAccess('sales.prepared', (access) =>
    access
      .title({ key: 'recordAccess.prepared', ns: 'my-plugin' })
      .collections('quotes')
      .resolver(({ principal }) =>
        condition('preparedById', '$eq', principal.id),
      ),
  ),
);

A resolver returns a DatabaseScope (true, false or a filter node on the collection's own columns) or a FilterAst. The built-ins are the exported constants recordAccess.allRecords, recordAccess.recordsIOwn (ownerId), recordAccess.recordsICreated (createdById) and recordAccess.customFilter (params.filter); the owner and creator ones accept params.field.

Grant access

Permission set

import { definePermissionSet } from '@nocobase/authorization/permission-sets';

await authz.permissionSets.create(
  definePermissionSet('sales-engineer')
    .title('Sales engineer')
    .grant(authz.pages.grant('sales.quotes'))
    .grant(authz.settings.grant('scheduler.schedules', ['read']))
    .grant(
      quotes.reference().grant({
        view: { quotes: 'allRecords' },
        submit: { quotes: 'recordsIOwn' },
      }),
    )
    .build(),
);
await authz.permissionSets.assign({
  permissionSet: 'sales-engineer',
  subject: { type: 'user', id: userId },
});

authz.pages.grant(id) builds a page access grant and registers nothing. A page grant does not open data, and a composite grant does not open a page.

The Permission Set routes reject a composite grant its definition does not accept. A grant stored some other way — a seed, or a definition that changed after the grant was saved — is skipped for that grant alone: it permits nothing, a direct check of its action denies with INVALID_GRANT, the person's other grants keep working, and the application logs a warning naming the source, resource, action and problem. At startup the plugin also scans every stored Permission Set against the current composite definitions and names each set, resource, action and problem; like the workspace check, this throws in development and warns in production.

Rules

import { selection } from '@nocobase/authorization/core';
import { defineDefaultAccessRule } from '@nocobase/authorization/default-access';
import { defineSharingRule } from '@nocobase/authorization/sharing-rules';
import { defineRestrictionRule } from '@nocobase/authorization/restriction-rules';

const target = quotes.reference();
await authz.defaultAccess.create(
  defineDefaultAccessRule('quotes-baseline', target)
    .scope('view', 'quotes', selection.recordAccess('sales.prepared'))
    .build(),
);
await authz.sharingRules.create(
  defineSharingRule('handover', target)
    .subjects({ type: 'user', id: 'alice' })
    .scope('submit', 'quotes', selection.records(['quote-7']))
    .build(),
);
await authz.restrictionRules.create(
  defineRestrictionRule('own-only', target)
    .subjects({ type: 'team', id: 'interns' })
    .scope('submit', 'quotes', selection.recordAccess('recordsIOwn'))
    .build(),
);

Each rule API exists only when its plugin is configured; see the default access, sharing rules and restriction rules READMEs.

Check access on the server

Every route installs authentication, then authz.middleware(), which sets the authz variable to the request's AuthorizationContext. A denied require throws AuthorizationDeniedError, which the application answers as 403 PERMISSION_DENIED with reason AUTHORIZATION_DENIED and domain authorization in the standard error body, so a route needs no onError for it.

router.use('*', authentication.required(), authz.middleware());
router.post('/quotes/:id/submit', async (c) => {
  const context = c.get('authz');
  await context.require({
    resource: { type: 'settings', id: 'scheduler.schedules' },
    action: 'read',
  });
  const decision = await context.authorize({
    resource: { type: 'composite', id: 'sales.quotes' },
    action: 'submit',
  });
  const quotesPolicy = decision.conditions?.database?.quotes;
  const projectsPolicy = decision.conditions?.database?.projects;
  if (decision.effect === 'deny' || !quotesPolicy || !projectsPolicy)
    throw new AuthorizationDeniedError(decision);
  const quote = await database.transaction(async (connection) => {
    const project = await connection
      .repository('projects')
      .withPolicy(projectsPolicy)
      .findOne({ filter: { id: projectId } });
    const { record } = await connection
      .repository('quotes')
      .withPolicy(quotesPolicy)
      .updateOne({
        filter: { id: c.req.param('id') },
        values: { status: 'submitted' },
      });
    return record;
  });
  return c.json({ data: quote });
});

A composite decision's conditions are CompositeResourceConditions with database: one RepositoryPolicy per composed collection, built from that action's grants only. require rejects conditional decisions, and can counts them as false; neither enforces rows.

A policy is false only when nothing grants the action, and a Repository refuses to run under it. When a grant exists but its data scope selects no records, such as a record access that answers false for a user in no department, the decision stays conditional with the EMPTY_RECORD_ACCESS reason and a scope that matches no rows: reads return an empty result, and updates and deletes affect nothing. A grant whose data scopes configure no selection at all is still denied with NO_RECORD_ACCESS. Create reads no record scope, so a create grant is unaffected.

For a collection-oriented endpoint, fold the four CRUD decisions into one policy, optionally restricted to one composite action's branch:

const policy = await authz.database.policyFor('quotes', c.get('authz'), {
  resource: 'sales.quotes',
  action: 'submit',
});
await database
  .repository('quotes')
  .withPolicy(policy)
  .updateOne({ filter: { id }, values: input });

For generated Repository routes, bind each endpoint to one composite action:

router.use(
  '/quotes/*',
  authz.database.authorizeRepository({
    repository: 'quotes',
    resource: quotes.reference(),
    actions: { findMany: 'view', updateOne: 'submit' },
  }),
);

Scoped credentials

A request may arrive with a scoped credential, such as an API key limited to a scope: a step another plugin adds to the identity pipeline (authz.use) sets request.keyScope, and the request's AuthorizationContext carries it as identity.keyScope. authorize, can and require deny anything outside the scope with the KEY_SCOPE reason before reading a grant, a superuser included, and snapshot() (GET /api/authz/permissions) lists only what the scope covers. A composite action is checked against the scope as requested; the grants it expands into are not.

Authentication refuses scoped credentials on every route that does not opt in with auth.required({ scopedKeys: true }). Opt in only where each operation is authorized through authz, or where the route narrows by identity.keyScope itself. This plugin's own /api/authz routes opt in.

permissionSets.getEffective(identity) answers the sets an identity holds, not what it may do. Code that derives permissions from those sets rather than asking can must intersect the result with identity.keyScope (keyScope.allows(resource, action), and keyScope.objects(business) for record-level selections), or a scoped key reaches everything its holder may.

Check access on the client

import { useCan } from '@nocobase/app-plugin-authorization/client';

const { can, isPending, error, retry } = useCan({
  resource: { type: 'composite', id: 'sales.quotes' },
  action: 'submit',
});

useCan(check, { enabled? }) answers from the session's snapshot and is false while pending or failed; it re-checks after sign-in changes and permission invalidation. Outside React, resolve authorizationClientToken and call client.can(check). Never keep a client or snapshot across sessions.

Route authz

defineRoutes([
  {
    name: 'sales.quotes',
    path: '/quotes',
    authz: { resource: { type: 'page', id: 'sales.quotes' }, action: 'access' },
    componentLoader,
  },
  {
    name: 'schedules',
    path: '/schedules',
    authz: {
      resource: { type: 'settings', id: 'scheduler.schedules' },
      action: 'read',
    },
    componentLoader,
  },
]);

Declare authz on every entry client route: there is no page:<route.name> inference. A nested page that omits it inherits its nearest ancestor page's value; an entry page that omits it defaults to 'unrestricted' on protected App routes, which useCan('unrestricted') and client.can('unrestricted') pass only for an unrestricted snapshot such as root's, and to 'skip' on guest and optional routes. Unrestricted-only routes are never offered as page grants. A permission management page lists these pages in the pages.page subsection, under their navigation groups as resource groups, from the client route tree sorted by navigation.order the way the menu sorts them.

HTTP API

Every path is under /api/authorization and requires a signed-in user. Settings checks use { resource: { type: 'settings', id }, action }, and run before the request is validated, so a caller without the permission gets 403 whatever the request holds. Successful responses wrap results in { data }; a paged list adds meta: { page, pageSize, total }; creation answers 201 and deletion 204 with no body. Every input is validated: an invalid or unknown field answers 400 INVALID_ARGUMENT with reason INVALID_INPUT and a fieldViolations entry for each problem. Failures use the standard error body, { error: { code, status, reason, domain, message, requestId } }; branch on error.reason, never on message.

Every route below, and every route a rule plugin adds under /api/authorization, is described with its parameters, request and response schemas and error statuses in the application's API document, served at /api/swagger/docs (JSON at /api/swagger) to a signed-in user or a valid API key. They are listed under the Authorization tag with operation ids such as authorizationCreatePermissionSet.

Method and path Required permission Request Response data
GET /permissions Signed in AuthorizationSnapshot
GET /permissionSets/options settings:authorization.permission-sets read AuthorizationOptions
GET /permissionSets/subjects/:type settings:authorization.permission-sets read query q?, page, pageSize SubjectOption[], with meta
POST /permissionSets/subjects/:type/resolve settings:authorization.permission-sets read { ids: string[] } SubjectOption[]
GET /permissionSets settings:authorization.permission-sets read query subjectType?, subjectId? PermissionSet[] with protection and unrestricted, with meta: { total }
POST /permissionSets settings:authorization.permission-sets create { key, title?, grants } PermissionSet
GET /permissionSets/:key settings:authorization.permission-sets read PermissionSet
PATCH /permissionSets/:key settings:authorization.permission-sets update any of { key, title, grants } PermissionSet
DELETE /permissionSets/:key settings:authorization.permission-sets delete none
GET /permissionSets/:key/assignments settings:authorization.permission-sets read PermissionSetAssignment[], with meta: { total }
POST /permissionSets/:key/assignments settings:authorization.permission-sets assign { id?, subject: { type, id } } PermissionSetAssignment
DELETE /permissionSets/:key/assignments/:assignmentId settings:authorization.permission-sets assign none
GET /inspector/options settings:authorization.inspector inspect AuthorizationOptions
GET /inspector/subjects/:type settings:authorization.inspector inspect query q?, page, pageSize SubjectOption[], with meta
POST /inspector/subjects/:type/resolve settings:authorization.inspector inspect { ids: string[] } SubjectOption[]
POST /inspector/decide settings:authorization.inspector inspect { subject, resource, action } AuthorizationDecision, with checks for a composite
POST /inspector/batchDecide settings:authorization.inspector inspect { subject, checks: [{ resource, action }] } [{ resource, action, decision }]
GET /inspector/configuredAccess settings:authorization.inspector inspect query subjectType, subjectId { unrestricted, types, resources, identity, sets }
GET /<rule> settings:authorization.<settings> read rules, with meta: { total }
POST /<rule> settings:authorization.<settings> create a complete rule the rule
PATCH /<rule>/:key settings:authorization.<settings> update the fields that change the rule
DELETE /<rule>/:key settings:authorization.<settings> delete none
GET /<rule>/options settings:authorization.<settings> read AuthorizationOptions
GET /<rule>/subjects/:type settings:authorization.<settings> read query q?, page, pageSize SubjectOption[], with meta
POST /<rule>/subjects/:type/resolve settings:authorization.<settings> read { ids: string[] } SubjectOption[]
GET /<rule>/records/:collection settings:authorization.<settings> read query page, pageSize [{ id, label, description? }], with meta

<rule> is each of defaultAccess, sharingRules and restrictionRules, with the settings items authorization.default-access, authorization.sharing-rules and authorization.restriction-rules, present only when that plugin is configured. Options, subjects and records stay per plugin, each gated by that plugin's own settings item. A subject directory may enforce further read checks of its own. GET /permissionSets with subjectType and subjectId lists only the sets that subject holds, including the default set; give both or neither. The inspector evaluates one subject: a user includes the authenticated audience and resolved memberships, while inspecting a team describes that team's grants alone. configuredAccess also answers identity: { subjects }, the subjects a request for that principal would carry, and sets: [{ key, title?, sources }], where sources lists the assignments, to the principal itself or to one of those subjects, that bring each effective set; the default set has none. A PATCH changes only the fields it names; title: null clears a title.

POST /permissionSets, and a PATCH that replaces grants, check every database.collection create and update grant against the Collection's current metadata: each field it lists must exist and be one a write may set, and each relation it lists, at any depth, must exist, with through only on a belongsToMany. Anything else would be accepted and then fail every write the grant allows, so it is refused with INVALID_AUTHORIZATION_INPUT and one fieldViolations entry per offending member. '*' always passes; read and delete grants, grants on '*', and Collections the database does not hold are not checked. A grant that stopped fitting later, because a field was dropped or a seed wrote it directly, is reported by the startup scan of stored Permission Sets, which throws in development and warns in production, as it does for a composite grant that no longer expands; the scan also checks the write fields each stored composite grant's definition composes.

Permission Sets, their assignments and each rule list are bounded configuration lists: they answer every entry at once with meta: { total }. Subject and record pagination accepts page >= 1 (default 1) and 1 <= pageSize <= 100 (default 20), and q searches subjects; records are ordered by their id and answer 404 COLLECTION_NOT_FOUND for a name the database holds no Collection for; batchDecide takes 1 to 100 checks and resolve up to 100 ids.

Reason Status When
AUTHORIZATION_DENIED 403 PERMISSION_DENIED The settings check, or another require, denied the request
INVALID_INPUT (domain app) 400 INVALID_ARGUMENT The request does not match the route's schema
INVALID_AUTHORIZATION_INPUT 400 INVALID_ARGUMENT A grant, rule, title or selection names something the registered model does not accept; a rule check names the offending field, such as actions.0.scopeKey, in fieldViolations, and so does a database.collection create or update grant naming a field that does not exist or that a write cannot set, or a relation that does not exist, such as grants.0.actions.1.policy.fields.2
PERMISSION_SET_SUBJECT_NOT_ALLOWED 400 INVALID_ARGUMENT The set may not be assigned to that subject type
PROTECTED_PERMISSION_SET 400 FAILED_PRECONDITION The set is owned by code and its protection does not allow the change
LAST_ASSIGNMENT 400 FAILED_PRECONDITION The change would remove the last active assignment of a set that must keep one
PERMISSION_SET_NOT_FOUND 404 NOT_FOUND No set has the key in the path
ASSIGNMENT_NOT_FOUND 404 NOT_FOUND The set has no assignment with the id in the path
UNKNOWN_SUBJECT_TYPE 404 NOT_FOUND The subject type in the path has no collection selection
RULE_NOT_FOUND 404 NOT_FOUND No rule of that plugin has the key in the path
COLLECTION_NOT_FOUND 404 NOT_FOUND The database holds no Collection named in a records/:collection path
PERMISSION_SET_CONFLICT 409 ALREADY_EXISTS A set with that key already exists
RULE_ALREADY_EXISTS 409 ALREADY_EXISTS Another rule of that plugin already has the key a create or rename asks for; metadata.key names it
DEFAULT_ACCESS_CONFLICT 409 ALREADY_EXISTS The resource already has a default-access rule; metadata.existing names it

Every reason except INVALID_INPUT has the domain authorization. A rule key may not be options, subjects or records, the fixed segments beside /<rule>/:key, and a sharing or restriction rule may not list a subject twice; both are refused as INVALID_INPUT naming the field (key, or the repeated subjects.<index>).

AuthorizationOptions is { sections: [{ name, title, order, subsections: [{ name, title, recordType?, resources: [{ type, id, title, description?, group?, actions: [{ name, title }], dataScopes? }] }] }], resourceGroups?, subjectTypes, recordAccess, collections }. A subsection with recordType ({ type: 'page', actions }) lists no resources: the client supplies them. dataScopes maps a business action to its data scopes; subsections without resources are omitted, and rule options list only composites with data scopes, with every title as sent by the server, either a string or { key, ns }.

Settings action names are semantic. Permission sets declare read, create, update, delete and assign; the inspector declares inspect; each rule plugin declares read, create, update and delete.

Subjects and transactions

authz.subjects.add(type, { resolveFor?, filterActive, administration? }) declares an inherited subject type such as teams; it returns a function that removes it. administration is { title, selection } where selection is { type: 'fixed', id } or { type: 'collection', list(query, context), resolve(ids, context) }, answering { items: [{ id, title, description? }], total } and items respectively. title and description may be plain text or a { key, ns } translation descriptor, which the workspace renders in the viewer's language.

A permission-set assignment change to a user refreshes that user's clients; a change to any other subject, authenticated included, refreshes every client, because only the subject's owner knows which users it reaches. For a user removal, bind authz.permissionSets.withTransaction(connection).assertSubjectRemovable(subject) to the same transaction as the mutation and call notifyAssignmentsChanged(subject) on that bound API; it publishes once the transaction commits.

@nocobase/app-plugin-authorization/server

The root entry @nocobase/app-plugin-authorization exports exactly the same names.

Exports

Export Kind Signature Purpose
default plugin defineServerPlugin(...) The server plugin to register.
createAppAuthorization function createAppAuthorization(options: CreateAppAuthorizationOptions): AppAuthorization Builds the application's instance.
CreateAppAuthorizationOptions type { database?, connection?, config?, onUserPermissionsChanged?, onAuthenticatedPermissionsChanged?, onInvalidGrant? } Options of createAppAuthorization.
AuthorizationConfig type { permissionSets?: AppPermissionSetsConfig; plugins?: readonly AuthorizationPlugin[] } The application's authorization configuration.
AppPermissionSetsConfig type { rootSet?: string; defaultSet?: string } Names of the unrestricted and default sets.
authorizationToken const ServiceToken<AppAuthorization> The only service token.
AppAuthorization type Authorization & { permissionSets, database, pages, settings, composites, ui } The registered instance.
AuthorizationProvider class ServiceProvider Registers the instance; other providers order themselves after it.
databasePlugin function databasePlugin(database?: DatabaseManager): DatabasePlugin Registers database.collection and authz.database.
DatabasePlugin type AuthorizationPlugin<{ database: DatabaseApi }, DatabaseConnection> What databasePlugin returns.
DatabaseApi type collections.add(definition), policyFor(collection, context, operation?), authorizeRepository(options) authz.database.
DatabaseCollectionDefinition type { name: string; title: AuthorizationTitle; description?; actions?: readonly string[] } What collections.add takes.
AuthorizeRepositoryOptions type { repository: string; resource: CompositeResourceReference; actions: Record<method, action> } Binds Repository methods to composite actions.
pagesPlugin function pagesPlugin(): PagesPlugin Registers the page record type, its pages.page subsection and authz.pages; depends on ui.
PagesPlugin type AuthorizationPlugin<{ pages: PagesApi }, unknown, UiAuthorizationApi> The plugin pagesPlugin returns.
PagesApi type grant(id: string): PermissionGrant authz.pages; builds a page access grant.
settingsPlugin function settingsPlugin(): SettingsPlugin Registers settings and authz.settings.
SettingsPlugin type AuthorizationPlugin<{ settings: SettingsApi }> What settingsPlugin returns.
SettingsApi type add(item: SettingsItemDefinition), grant(id, actions): PermissionGrant authz.settings.
SettingsItemDefinition type { id, title, actions: readonly { name; title? }[] } A settings item; authz.ui.place lists it.
defineDatabasePermission function defineDatabasePermission(configure): DatabasePermissionBuilder Builds a bindable collection permission.
DatabasePermissionDefinitionBuilder class collection<Row>(name): DatabasePermissionBuilder<Row> The builder defineDatabasePermission passes in.
DatabasePermissionBuilder class title, options, default, read, create, update, delete, build, bind(key, { title? }) A collection permission; implements BindableCompositeResourcePermission.
ReadPermissionBuilder class fields(...), allFields(), recordAccess(...), relation(name, configure), build() Read fields and relation reads.
WritePermissionBuilder class fields(...), allFields(), recordAccess(...), relation(name, configure), through(configure), build() Create and update fields and relation writes.
RelationPermissionBuilder class recordAccess, create, update, upsert, connect, set, disconnect, delete, build Operations on one relation.
PermissionUpsertBuilder class create(configure), update(configure), build() Both branches of a relation upsert.
ThroughPermissionBuilder class through(configure), build() Junction fields of connect and set.
PermissionFieldsBuilder class fields(...), allFields(), build() A field list inside through.
recordAccess const { allRecords, recordsIOwn, recordsICreated, customFilter }, each a RecordAccessReference The built-in record access.
RecordOwnerParams type { field?: string } Params of recordsIOwn and recordsICreated.
CustomFilterParams type { filter: DatabaseScope } Params of customFilter.
DatabaseScope type boolean | FilterNode What a record access resolver returns.
condition function condition(field, operator, value?): FilterNode A condition on one of the collection's own columns.
anyScope function anyScope(scopes: readonly DatabaseScope[]): DatabaseScope The union of scopes.
scopeAst function scopeAst(collection, scope: FilterNode): FilterAst Attaches a scope to its collection.
DatabaseAuthorizationConditions type { type: 'database'; collection; action; scope; fields; relations?; fieldAccess?; allFields? } Conditions of a collection decision.
DatabaseAuthorizationParams type { operation?: { resource; action }; fields?: { input?, output?, filter?, sort?, group? } } Params of a collection check.
AuthorizationCollection type { name, fields, writableFields, relations?, primaryKey, generatedPrimaryKey } Collection metadata read from the database.
SubjectAdministration type { title; selection } The administration of a subject type.
SubjectOption type { id; title; description? } One subject in a picker.
SubjectSelectionContext type { authz: AuthorizationContext } What directory callbacks receive.
AUTHORIZATION_NAMESPACE const '@nocobase/app-plugin-authorization' The plugin's translation namespace.
DatabaseAuthorizationApi type { database: DatabaseApi } What databasePlugin adds to authz.
PagesAuthorizationApi type { pages: PagesApi } What pagesPlugin adds to authz.
SettingsAuthorizationApi type { settings: SettingsApi } What settingsPlugin adds to authz.
uiPlugin function uiPlugin(): UiPlugin Registers authz.ui with the built-in sections, the authorization subsection and the default sections.
UiPlugin type AuthorizationPlugin<UiAuthorizationApi> The plugin uiPlugin returns.
UiAuthorizationApi type { ui: AuthorizationUiApi } What the plugin adds to the instance and to every setup context.
AuthorizationUiApi type sections, groups, place(target, placement), defaultSection(type, section), defaultSectionOf(type), placementOf(ref), validate({ resourceTypes, composites? }) Where a permission management page lists each resource.
AuthorizationUiSections type add(definition), has(name), isSubsection(name), get(name), other(parent), tree() ui.sections.
AuthorizationUiGroups type add(group), has(name), get(name), list() ui.groups.
AuthorizationUiSection type { name, title, order?, parent? } A section, or a subsection when parent is set.
AuthorizationUiSectionDefinition type AuthorizationUiSection & { extend?: boolean } What ui.sections.add takes.
AuthorizationUiSectionNode type AuthorizationUiSection & { order: number; subsections } A top-level section with its subsections.
AuthorizationUiGroup type { name, title, parent?, order? } A right-side group.
AuthorizationUiPlacement type { section: string; group?: string } Where one resource is listed.
AuthorizationUiTarget type ResourceRef | CompositeResourceReference What ui.place takes.
AuthorizationUiReport type { errors: readonly string[]; warnings: readonly string[] } What ui.validate returns.
reportAuthorizationUi function reportAuthorizationUi(report, { production, warn }): void Logs warnings; throws errors in development and logs them in production.
AuthorizationUiReportOptions type { production: boolean; warn(message): void } How reportAuthorizationUi reports.
AUTHORIZATION_SETTINGS_SECTION const 'authorization' The subsection the authorization settings items are placed in.
grantBacked function Re-exported from @nocobase/authorization/core The default judgement of a resource type.
Authorization type Re-exported from @nocobase/authorization/core The authorization instance.
AuthorizationContext type Re-exported from @nocobase/authorization/core The request's authz variable.
AuthorizationEnv type Re-exported from @nocobase/authorization/core Hono environment carrying authz.
AuthorizationPlugin type Re-exported from @nocobase/authorization/core What a configured plugin is.
PermissionSetsApi type Re-exported from @nocobase/authorization/permission-sets authz.permissionSets.

@nocobase/app-plugin-authorization/server/extension

Helpers for plugins that add an authorization settings surface. A rule plugin registers its handler with authz.routes.add('/<rule>', createRouteHandler(router)), where <rule> is a camelCase path segment such as /sharingRules, and serves its options, subjects and records through createRuleSupportRoutes. The router declares its routes at their full path below the dispatcher, starting with that segment, such as /sharingRules/options. Declare every route of the router with describeRoute() from @nocobase/app-server/router, tagged AUTHORIZATION_API_TAGS, check the settings item with settingsAccess before it, validate each input with apiValidator and the exported schemas after it, and throw ApiError for a failure of your own. Routes registered through authz.routes.add with createRouteHandler are documented automatically: the dispatcher under /api/authorization forwards requests at runtime, where the application's API document cannot see them, so at boot this plugin calls documentAuthorizationRoutes(apiDocs, authz.routes), which registers each router with the application's ApiDocsService through addApiRouter(). Their routes are then documented at /api/authorization/..., listed in Swagger UI at /api/swagger/docs, and checked by findUndeclaredApiRoutes(app) and pnpm openapi:check like any route mounted on /api. A handler that is a plain function rather than a createRouteHandler router cannot be described; it still answers, but the plugin logs a warning and registers it through addUndeclaredApiRoute(), so findUndeclaredApiRoutes(app) reports it. A rule plugin's test checks its routes without starting an application: const docs = new ApiDocsService(); documentAuthorizationRoutes(docs, authz.routes);, then docs.attach({ api: new Hono(), describe: () => ({ info: { title: 'Test', version: '1.0.0' } }) }) to give the service an empty /api router, and then expect(findUndeclaredApiRoutes(docs)).toEqual([]) and the operationIds in await docs.getDocument().

import {
  AUTHORIZATION_API_TAGS,
  createRouteHandler,
  createRuleSupportRoutes,
  createSettingsRouter,
  settingsAccess,
  SubjectRuleSchema,
  TotalMetaSchema,
} from '@nocobase/app-plugin-authorization/server/extension';
import {
  apiErrorResponse,
  describeRoute,
  listResponse,
} from '@nocobase/app-server/router';

const settings = 'authorization.sharing-rules';
const SharingRule = SubjectRuleSchema.meta({ ref: 'AuthorizationSharingRule' });
const router = createSettingsRouter();
router.route(
  '/',
  createRuleSupportRoutes(authz, {
    path: '/sharingRules',
    settings,
    name: 'SharingRule',
  }),
);
router.get(
  '/sharingRules',
  settingsAccess(settings, 'read'),
  describeRoute({
    tags: AUTHORIZATION_API_TAGS,
    summary: 'List sharing rules',
    operationId: 'authorizationListSharingRules',
    responses: {
      200: listResponse(SharingRule, TotalMetaSchema),
      // No input, so no 400.
      401: apiErrorResponse(401),
      403: apiErrorResponse(403),
      500: apiErrorResponse(500),
    },
  }),
  async (c) => {
    const rules = await authz.sharingRules.list();
    return c.json({ data: rules, meta: { total: rules.length } });
  },
);
authz.routes.add('/sharingRules', createRouteHandler(router));

Exports

Export Kind Signature Purpose
createSettingsRouter function createSettingsRouter(translate?): Hono<SettingsRouterEnv> A router that answers the domain errors translate and then toAuthorizationApiError turn into an ApiError in the standard error body, and hands the rest to apiErrorHandler.
toAuthorizationApiError function toAuthorizationApiError(error): ApiError | undefined The standard error for a Permission Set error or a library TypeError (INVALID_AUTHORIZATION_INPUT), or undefined.
AUTHORIZATION_ERROR_DOMAIN const 'authorization' The domain of every error the authorization surfaces report.
settingsAccess function settingsAccess(id, action): MiddlewareHandler<SettingsRouterEnv> requireSettings as middleware, registered before a route's validators.
DataScopeRuleBody, DataScopeRulePatchBody, SubjectRuleBody, SubjectRulePatchBody, RuleParams, ReferenceInput, TitleInput, RecordSelectionInput, RuleActionInput, RuleKeyInput, SubjectsInput const zod schemas Rule request bodies for apiValidator(): a create body, and a patch body whose fields are all optional.
RESERVED_RULE_KEYS const readonly ['options', 'subjects', 'records'] The fixed route segments beside /<rule>/:key, which RuleKeyInput refuses as a rule key.
AuthorizationInputError class new AuthorizationInputError(message, field) A TypeError naming the offending request field; toAuthorizationApiError reports it as a field violation of INVALID_AUTHORIZATION_INPUT.
assertRuleKeyAvailable, rethrowRuleConflict, ruleAlreadyExists function assertRuleKeyAvailable(get, key, current?), rethrowRuleConflict(key), ruleAlreadyExists(key) Refuse a create or rename to a key another rule uses, before the write and as a .catch for the unique index, with 409 RULE_ALREADY_EXISTS.
SettingsRouterEnv type { Bindings: { authorization: AuthorizationContext } } The router's environment.
createRouteHandler function createRouteHandler(router): AuthorizationRouteHandler Adapts a router to authz.routes.add; the router behind it is documented in the application's API document.
documentAuthorizationRoutes function documentAuthorizationRoutes(apiDocs, authz.routes, onWarning?): () => void Registers every authz.routes handler with the application's API documentation: a createRouteHandler router through addApiRouter(), so its routes are documented and checked, and any other handler through addUndeclaredApiRoute(), so it is reported as undeclared. The plugin calls it at boot; a test calls it with new ApiDocsService(), attaches an empty /api router with docs.attach(), and asserts findUndeclaredApiRoutes(docs) is empty. Returns a function that removes the registrations.
AUTHORIZATION_API_TAGS const ['Authorization'] The tags every authorization settings route declares.
ReferenceSchema, TitleSchema, OptionTextSchema, RecordSelectionSchema, RuleActionSchema, DataScopeRuleSchema, SubjectRuleSchema, AuthorizationOptionsSchema, SubjectOptionSchema, RecordOptionSchema, PageMetaSchema, TotalMetaSchema const zod schemas Response schemas for the API document; a rule plugin gives DataScopeRuleSchema or SubjectRuleSchema a ref of its own with .meta().
requireSettings function requireSettings(authorization, id, action): Promise<void> The one settings check: settings:<id> <action>, with the full settings id.
createRuleSupportRoutes function createRuleSupportRoutes(authz, { path, settings, name? }): Hono<SettingsRouterEnv> <path>/options, <path>/subjects/... and <path>/records/:collection, gated by settings:<settings> read. name, such as SharingRule, names their operation ids (authorizationListSharingRuleOptions); it defaults to path in PascalCase.
parse const parse.object, parse.string, parse.strings, parse.title, parse.resource, parse.subjects, parse.selection, parse.ruleActions, parse.rule Request body parsers that throw TypeError.
validateDataScopeRule function validateDataScopeRule(authz, rule): void Checks a rule's actions, data scopes and record access against the registered model.
DatabaseConnectionHandle class new DatabaseConnectionHandle(owner, connection?); set(connection), resolve() Late-bound connection for a store created before setup.
DatabaseConnectionSource type () => DatabaseConnection What a handle resolves a connection from.
describeCollection function describeCollection(connection, name): Promise<AuthorizationCollection | undefined> Reads a collection's metadata.
DataScopeRuleInput type { resource: ResourceRef; actions: readonly RuleAction[] } What validateDataScopeRule checks.
AuthorizationExtensionHost type { ui, resourceTypes, recordAccess, subjects, composites, database } The part of authz the support routes read.

@nocobase/app-plugin-authorization/client

Exports

Export Kind Signature Purpose
default plugin defineClientPlugin(...) The client plugin to register.
AuthorizationClient class can(check), snapshot(), revision(), invalidate(), onInvalidated(listener), permission-set and inspector calls Session-scoped access to /api/authorization.
authorizationClientToken const ServiceToken<AuthorizationClient> Resolves the client outside React.
useAuthorizationClient function useAuthorizationClient(): AuthorizationClient The client in a component.
useAuthorizationRevision function useAuthorizationRevision(): number Changes whenever permissions are invalidated.
useCan function useCan(check | undefined, { enabled? }): UseCanResult Whether the session may perform a check.
UseCanOptions type { enabled?: boolean } Options of useCan.
UseCanResult type { can; isPending; error; retry() } Result of useCan.
AuthorizationCheck type { resource: { type; id }; action } One check.
AuthorizationSnapshot type { unrestricted; permissions: [{ resource, actions }] } What GET /permissions answers.
PermissionSet type { key; title?; grants; protection?; unrestricted? } A set as the server lists it.
PermissionSetInput type { key; title?; grants } A complete set for create and update.
PermissionSetAssignment type { id; subject; permissionSet } An assignment.
PermissionAssignmentInput type { subject: { type; id } } Body of an assignment.
AuthorizationOptionsResponse type { sections, resourceGroups?, subjectTypes, recordAccess, collections } What an options route answers.
AuthorizationEffect type 'permit' | 'conditional' | 'deny' A decision's effect.
AuthorizationReason type { code; message; plugin? } Why a decision was made.
AuthorizationPermission type { resource; actions } One entry of a snapshot.
AuthorizationRecordOption type { id; label; description? } One record in a records route.
AuthorizationSubject type { type; id } A subject.
ConfiguredAccess type { unrestricted; types; resources; identity?; sets? } What inspectConfigured answers.
ConfiguredPermissionSet type { key; title?; sources: AuthorizationSubject[] } One effective set and what brings it.
PermissionGrant type { resource: { type; id }; actions: PermissionGrantAction[] } One grant of a set.
PermissionGrantAction type { action; policy? } One action of a grant.
PermissionSetProtection type { owner; allow: PermissionSetWriteOperation[]; assignableTo? } Who may change a protected set.
PermissionSetWriteOperation type 'create' | 'update' | 'delete' | 'assign' | 'revoke' A write a protection allows.
ResourceRef type { type; id } The target of a check or grant.
SubjectPage type { items: SubjectOption[]; total } One page of a subject search.
SubjectOption type { id; title; description?; manage? } One subject in a picker.
AuthorizationDecision type { effect; conditions?; reasons; checks? } What the inspector answers.
AuthorizationInspectInput type { subject; resource; action } Body of POST /inspector/decide.
AuthorizationInspection type { resource; action; decision } One batch or composite check.
LocalizedText type string | { key; ns } A title as the server sends it.
AuthorizationClient method HTTP
can(check), snapshot() GET /permissions, cached until invalidate()
revision(), invalidate(), onInvalidated(listener) none
listPermissionSets(), getPermissionSet(key) GET /permissionSets, GET /permissionSets/:key
createPermissionSet(input), updatePermissionSet(key, input), deletePermissionSet(key) POST, PATCH, DELETE /permissionSets[/:key]
getEffective(subject) GET /permissionSets?subjectType=&subjectId=
listAssignments(key), assign(key, input), revoke(key, id) /permissionSets/:key/assignments[/:assignmentId]
loadOptions(path), listSubjects(path, type, query), resolveSubjects(path, type, ids), listRecords(path, collection) /<path>/options, /<path>/subjects/..., /<path>/records/:collection (the first page of 100); path is a surface such as permissionSets, and listSubjects sends query.search as q and answers { items, total }
inspect(input), inspectBatch(subject, checks), inspectConfigured(subject) POST /inspector/decide, POST /inspector/batchDecide, GET /inspector/configuredAccess

@nocobase/app-plugin-authorization/client/plugin

Exports

Export Kind Signature Purpose
default plugin AppClientPluginFactory<AuthorizationClientOptions> The client plugin factory.
AuthorizationClientOptions type {} The plugin takes no options.

@nocobase/app-plugin-authorization/client/react-providers

Exports

Export Kind Signature Purpose
default const readonly AppClientReactProviderDefinition[] The provider that supplies the session's client.
reactProviders const readonly AppClientReactProviderDefinition[] The same value, by name.

@nocobase/app-plugin-authorization/package.json

The package manifest, for tools that read its version.