@spinajs/authz-orm
v3.0.26
Published
ORM resources, closure table and list helpers for SpinaJS authorization
Readme
@spinajs/authz-orm
The ORM adapter of SpinaJS authorization. It declares which models are access resources, keeps their ancestors in a closure table, filters lists with one helper, adds per-row access to list responses, and holds the decision audit and scope attribute stores.
It never rewrites a query. Single objects are checked before access; lists ask for filtering explicitly.
Install
import '@spinajs/authz-orm'; // registers the adapter, the write middleware and the list compilersMigrations: AuthzResourceAncestor_2026_10_14_00_00_00 (authz_resource_ancestor),
AuthzScopeAttribute_2026_10_02_00_00_00 (authz_scope_attribute), AuthzAudit_2026_10_01_00_00_00
(authz_audit). The ancestor and scope-attribute tables live on the connection of the authz stores, the
ORM alias authz (db.Aliases.authz, which this package's config maps to default), which must be the database
of the resource tables: the list filter joins them with authz_assignment (and, for @Parent,
authz_resource_ancestor), and a delete forgets the row's scopes through the same driver. The domain boot
refuses every @AccessResource model on another connection (AuthzConfigError naming each model and its
connection), with or without @Parent. A write of such a model, and authz:ancestors-verify or
authz:ancestors-rebuild for its type, fail the same way before they touch a row.
Declaring resources
import { AccessResource, Owner, Parent, Relation, Self, registerAccessResource } from '@spinajs/authz-orm';
@AccessResource('task')
export class Task extends ModelBase {
@Parent('project') public project_id: number;
@Parent('sprint') public sprint_id: number; // a second parent: both chains are ancestors
@Owner() public owner_id: string; // relation 'owner'
@Relation('assignee') public assignee_id: string; // relation 'assignee'
}
@AccessResource('folder')
export class Folder extends ModelBase {
@Parent('folder', { nullable: true }) public parent_id: number; // self-reference: any depth
@Parent('project') public project_id: number;
}
// a model your package does not own
registerAccessResource(DbConfig, 'configuration');
// the initiator can be any principal type: the owner holds when type and id both match
// named column relations: a column name, or { column, typeColumn }; one call carries all options
registerAccessResource(JobModel, 'job', {
owner: { column: 'InitiatorId', typeColumn: 'InitiatorType' },
relations: { reviewer: { column: 'ReviewerId', typeColumn: 'ReviewerType' } },
});@Parent(type)names the parent's resource type; the column holds the parent's id. Int and uuid keys share one table (ids are stored as text).@Owner()is read from the row whenever it is needed; it is never copied, so it cannot go stale. Without options it matchesuserprincipals only;@Owner({ typeColumn: 'owner_type' })compares the principal's type with that column too, for rows owned by any principal type.@Relation(name, { typeColumn? })declares a column relation likeowner, for exampleTask.assignee_id. Grant it with{ permission: 'task:read', relation: 'assignee' }; it works for single checks and forlist: truepermissions (.accessible()rendersrelation.column). WithouttypeColumnthe column holdsuserids.ownerandselfcannot be declared with it. A relation that lives in no column is a role assigned on the resource. See column relations.@Self()marks the column that equals the principal id (self, foruserprincipals);@spinajs/usersuses its own resolver foruser.- Each
@AccessResourcetype gets the permissions<type>:read,<type>:create,<type>:updateand<type>:delete. - Register every model before the authz domain boots (at import or in a
Bootstrapper).
Single objects
const task = await authz().runAs(SYSTEM, 'load task', () => Task.where('id', id).first());
await authz().assert(me, 'task:update', task);Routes keep @Access('task:update', { resource: fromModel(Task, 'task') }). The adapter answers
ancestors(ref) with one indexed read, load(ref) by primary key as SYSTEM, and owner from the
@Owner column. checkAll (and POST /user/access/checks) loads a page of rows with one query per
type (loadMany, ancestorsMany).
A create is checked against the parent; a move (writing a @Parent column) needs a check of
the new parent. The ORM checks neither.
Lists
authz: { permissions: { 'task:read': { list: true } } } // a child domain: authz.domains.<name>.permissions
const tasks = await Task.where('status', 'open').accessible('task:read');
await Task.update({ archived: true }).where('done', true).accessible('task:update');
const teamTasks = await Task.query().within({ type: 'team', id: teamId }).accessible('task:read');Permission names are compared exactly: @AccessResource('task') registers task:read, never
Task:read.
.accessible(permission)asksauthz().accessPlan(principal, permission, type)and adds oneEXISTSfilter: a grant on the row, a grant on any ancestor, the owner column, minus deny roles, inside the principal's ceiling. It works on SQLite, PostgreSQL, MySQL and MSSQL, with no recursion.{ env }as the second argument passes request facts to ABAC.- A plan with no permit at all throws
AccessDenied(a 403, never an empty list). With{ onNone: 'empty' }it matches no row instead: a select returns[], an update or delete touches nothing. Use it where "no access" is a normal answer, such as a child list inpopulate(). A permission withoutlist: truethrowsAuthzConfigError. - Boot rule 12 refuses a
list: truepermission when anything that can grant it on the type needs the row: an RBAC grant with a relation other thanownerorselfor limited to ids byon, an RBACcap,restrictorthresholdrule, an ABAC policy that is scoped or readsresource.*orscope.*(other than$global), a type with no@AccessResourcemodel, or a route other thandeny-overrides.AuthzAdminrefuses the same at write time (not_list_filterable). - The caller's where stays one group:
(where) AND (plan) AND (within…). .within(ref | refs)keeps rows that are one of the refs or lie below one in the resource tree (any scope type: a team, a project, a folder;{ type }alone means any resource of that type,$globalkeeps every row). The refs of one call are OR-ed, chained calls are AND-ed,[]keeps nothing. It narrows a list and authorizes nothing: a select still needs.accessible(),.unscoped(reason)or SYSTEM. It works on select, update and delete builders..unscoped(reason)runs without a filter and writes anunscopedaudit entry (authz.audit.unscoped), with the audit and config the domain's service booted with. Before that boot they are read from the configuration and picked the way the host picks them.
Plan terms and PlanTermCompiler
An access plan is a list of opaque terms ({ op, args }) from each model's listPlan. The adapter
applies each term with the PlanTermCompiler registered for its op, each in a where group of its own,
and combines the groups as (permit OR …) AND NOT (deny OR …) AND within. A term with no compiler, or
a compiler that adds no condition, fails the query with AuthzConfigError.
| op | Registered by | Adds |
|---|---|---|
| relation.owner | authz-orm (RelationOwnerTerm) | the @Owner() column as text (and its typeColumn) is one of args.principals |
| relation.self | authz-orm (RelationSelfTerm) | the @Self() column as text is one of the user principals in args.principals |
| relation.column | authz-orm (RelationColumnTerm) | the column of the @Relation named in args.relation (and its typeColumn) holds one of args.principals |
| rbac.assignment | authz-rbac (RbacAssignmentTerm) | EXISTS over authz_assignment at the row, at an ancestor (authz_resource_ancestor) or at $global |
A model that adds list support registers a compiler for each op its listPlan returns. The contract
lives in @spinajs/authz-orm/plan, an entry point that loads nothing else of the adapter:
import { PlanTermCompiler } from '@spinajs/authz-orm/plan';
import type { PlanRow } from '@spinajs/authz-orm/plan';
import { Injectable, Singleton } from '@spinajs/di';
import type { IWhereBuilder } from '@spinajs/orm';
@Singleton()
@Injectable(PlanTermCompiler)
export class TenantTerm extends PlanTermCompiler {
readonly op = 'myapp.tenant'; // listPlan returns { op: 'myapp.tenant', args: { tenant: '42' } }
apply(args: unknown, where: IWhereBuilder<unknown>, _row: PlanRow): void {
where.where('tenant_id', (args as { tenant: string }).tenant);
}
}where is a group of the resource query, so a column name there is the row's. PlanRow gives the
type, id (the id column cast to text: where.whereIn(row.id, ids) on the group, outer(row.id) in a
sub-query), the columns of a relation (owner, self or an @Relation name), the ancestorTable, now() (the database's current
instant: where('expires_at', '>', row.now())) and from(table, alias), a query on the row's connection
that whereExist() correlates with the group it is called on. A term that matches no row adds
where(false). The condition must keep exactly the rows a check of each one would allow: boot rule 12
guarantees that for the RBAC and ABAC plans, a custom model guarantees it for its own terms.
Expiry in a list is the database's: rbac.assignment compares expires_at with row.now(), while a
check uses AuthzClock. See the authz README for what a clock skew does.
Per-row access
const rows = await Task.query().accessible('task:read').withAccess(['task:update', 'task:delete']);
// rows[i].$access = { 'task:update': true, 'task:delete': false }Each permission is one boolean column built from the same filter (selectCondition()), so a page with its access is one
query. Each permission must be list: true. $access is serialized with the row; rename it with
authz.orm.accessField. A client may seed its cache from it: it equals what
POST /user/access/checks answers.
The runtime guard
A multi-row select on an @AccessResource model must call .accessible(), .unscoped(reason), or run
in runAs(SYSTEM, reason). A select on exactly one primary-key value (@FromModel, findOrFail(id))
is a single-object load: the route's @Access or your assert covers it, and the guard leaves it alone.
| env | without one of them |
|---|---|
| test | AuthzConfigError, so CI fails |
| development | a warning once per call site |
| production, any other, unset | nothing |
The guard only reads the builder. A populate() of an @AccessResource type needs the helper like any
other select. Add @spinajs/eslint-plugin-authz (@spinajs/authz/require-accessible) to catch the
same at edit time.
Nested data
populate(), relations, joins and sub-queries are not filtered. Authorize the aggregate root: the
comments of a task you may read are part of it. A child type that is its own resource is loaded with
the helper; the populate callback runs with the relation query as this:
Project.where('id', id).populate('tasks', function () { this.accessible('task:read', { onNone: 'empty' }); });Without onNone: 'empty', a caller who may read the project but no task gets AccessDenied from
the populate, which fails the whole load.
Keeping the ancestor table right
| Write through the ORM | What happens |
|---|---|
| insert of a model with @Parent | its rows: the parent's rows one level deeper, plus the parent |
| update of a @Parent column | the subtree's links above the row are replaced by the new chain |
| delete | its rows and its descendants' links above it go; the scope is forgotten |
A @Parent update or a delete and its table writes run in one transaction: the caller's, or one the
middleware opens. Where neither is possible the write is refused with AuthzConfigError.
Deleting a resource also deletes the assignments (the RBAC write handlers recompute the team
memberships they fed),
links, rules, policies and scope attributes at its scope and bumps authz_version: with
DbAuthzAdminBackend in the same transaction, with any other admin backend after the commit (best
effort, logged on failure). After the commit it publishes the change and writes one audit entry as
SYSTEM (resource-deleted). An id reused later inherits nothing.
Bulk moves: reparent(Model, ids, newParentId, column?) writes the @Parent column (the model's only
one unless column names it) in one transaction and resolves with the rows changed. Writes outside
the ORM (raw SQL, imports) are not tracked: run the CLI afterwards.
| Command | Does |
|---|---|
| authz:ancestors-verify [type] | recomputes from the @Parent columns, prints missing, extra and wrong-depth rows, exit code 1 on any |
| authz:ancestors-rebuild [type] | rewrites the table from the @Parent columns, one transaction per type |
Database audit
DbDecisionAudit (from @spinajs/authz-orm/audit) writes one authz_audit row per audit record:
id (uuid), at, kind, principal (type:id), permission, resource (resourceKey),
reason, trace (JSON; for an admin record the command). Longer values are cut to the column
widths. A failed write is logged (authz-audit) and the record dropped; it never fails a check.
- Import
@spinajs/authz-orm/auditbefore the ORM boots. It registers theAuthzAuditEntrymodel and the migrationAuthzAudit_2026_10_01_00_00_00, both on the connection namedauthz-audit. - Map that name:
db.Aliases: { 'authz-audit': 'default' }, or configure a connection of that name. Without one the migration is skipped with a warning and every record fails. - Register and select it:
DI.register(DbDecisionAudit).as(DecisionAudit)(or in a domain's container), withauthz.audit.service: 'DbDecisionAudit'(orauthz.domains.<name>.audit.service).
authz:audit-unattended prints the resource types of the rows with the reason unattended (queried
without a principal), with a count, the most frequent first. It reads authz_audit on the
authz-audit connection and exits 1 when that connection or the table is missing.
The import does not register it as the DecisionAudit: without authz.audit.service the host takes
the last one registered, so loading the module would silently replace the log audit.
at is the instant of the decision, stored in UTC; through the model it reads back as a luxon DateTime in UTC.
Scope attributes
DbScopeAttributeStore keeps authz_scope_attribute, written through AuthzAdmin
(scope.setAttributes, scope.replaceAttributes). new DbScopeAttributeStore() takes no options:
it reads and writes on the alias authz, and a write runs only inside the AuthzAdmin transaction on
that connection (AuthzConfigError otherwise). It is not built by DI, so resolve the Orm before its
first read. Register the one instance under both ScopeAttributeStore and WritableScopeAttributeStore.
Every write first updates the single row of authz_scope_attribute_version; its row lock queues
concurrent writers of the same database.
Config
| Key | Default | Meaning |
|---|---|---|
| authz.orm.domain | 'root' | the domain whose service plans lists and whose stores a delete cleans up |
| authz.orm.accessField | '$access' | the per-row access key of .withAccess() |
From 3.0.0-rc.1
@ScopeColumn is @Parent; @OrmScope relations become role assignments on the resource;
relationScope, authz.orm.allowJit and the native compiler exports are gone; selects call
.accessible(). Step by step:
the migration guide, section 12.
