Extensions
Authorization
Authorization asks the second question: what may the caller do. @stone-js/authzanswers it with rules that run on both sides, so the server guard and the UI that hides a button are the same logic, and can never disagree.
#Install
npm i @stone-js/authz#Enabling it
Enabled the way every Stone.js module is, with its decorator or with its blueprint. Either one registers the authorization provider and the kernel middleware that builds the caller's abilities for each request.
import { Authz } from '@stone-js/authz'
import { StoneApp } from '@stone-js/core'
@Authz()
@StoneApp({ name: 'my-app' })
export class Application {}import { defineStoneApp } from '@stone-js/core'
import { authzBlueprint } from '@stone-js/authz'
export const Application = defineStoneApp({ name: 'my-app' }, [authzBlueprint])#Declare abilities once
The principle
When the rule that guards an action lives in one place and the rule that shows its button lives in another, they drift, and the UI eventually offers what the API refuses. Declare the rule once and evaluate it in both places.
In Stone.js
defineAbility declares what a user can do, combining role checks (RBAC) and attribute checks (ABAC). authorize(action, subject) enforces it as route middleware; the same ability object answers can(...) in the UI.
Under the hood it is CASL, so you get RBAC (action + subject type), ABAC (conditions on a subject instance) and field-level rules from one declaration, and the same ability runs on the server and in the browser.
import { defineAbility } from '@stone-js/authz'
export const abilityFor = (user) => defineAbility((can, cannot) => {
can('read', 'Task') // RBAC: anyone reads
can('update', 'Task', { ownerId: user.id }) // ABAC: only your own
can('update', 'Task', ['title', 'done']) // field-level: only these fields
if (user.role === 'admin') can('manage', 'Task') // 'manage' = every action
cannot('delete', 'Task', { locked: true }) // an explicit exception
})#RBAC, ABAC, fields, aliases
| Capability | Type | Description |
|---|---|---|
| can('delete', 'Task') | RBAC | Allow an action on a subject type. |
| can('update', 'Task', { ownerId }) | ABAC | Allow only when the subject instance matches the conditions. |
| can('update', 'Task', ['title']) | field-level | Restrict an action to specific fields. |
| can('manage', subject) | action alias | 'manage' matches every action; define your own aliases with createAliasResolver. |
| cannot('delete', 'Task', {...}) | exception | Carve out a denial that overrides a broader allow. |
#Enforce on the API
authorize(action, subject, field?) guards a route and throws a ForbiddenError (403) when the ability says no. For checks inside a handler, inject the Authorizer: it exposes can/cannot and an authorize that throws.
import { EventHandler, Delete } from '@stone-js/router'
import { authorize } from '@stone-js/authz'
@EventHandler('/tasks')
export class TaskController {
@Delete('/:id', { middleware: [authorize('delete', 'Task')] })
remove (event) { return this.tasks.remove(event.get('id')) }
}import { defineEventHandler, defineRoutes } from '@stone-js/router'
import { authorize } from '@stone-js/authz'
export const routes = defineRoutes([
[defineEventHandler(TaskController, 'remove'),
{ path: '/tasks/:id', method: 'DELETE', middleware: [authorize('delete', 'Task')] }]
])update (event: IncomingHttpEvent) {
const task = this.tasks.find(event.get('id'))
// Check against a concrete instance (ABAC) with subject() typing.
this.authorizer.authorize(event.get('user'), 'update', subject('Task', task))
return this.tasks.update(task, event.get('body'))
}#Group policies, composed
A gate on a group holds for every child, on top of the child's own. Declare the parent's policy on the handler and the child's on the route: the request passes both, parent first, because the group encloses its routes, exactly the order group middleware runs in.
@EventHandler('/', { authz: 'policy.parent' })
export class AdminController {
@Get('/name', { authz: 'platform.operate' })
name () { … } // enforced: policy.parent, then platform.operate
}authz also takes an array outright, so a route composes its own chain: authz: ['policy.parent', 'platform.operate']. A chain is a conjunction: the first gate that says no answers, names itself, and the later gates never run, since a child policy has no business running for a caller the group already refused.
#A policy reads its own event, and holds its own services
A policy answers what this caller may do to this record, so it usually needs both the event and whatever loads the record. It says which event it reads, and the container builds it like any other class:
@Policy('post.update')
export class PostPolicy implements IPolicy<IncomingHttpEvent> {
constructor ({ posts }) { this.posts = posts } // resolved, like any service
async authorize (event: IncomingHttpEvent): Promise<boolean> {
const post = await this.posts.find(event.get('id'))
return post.authorId === event.getUser<Actor>()?.id
}
}The type parameter is what makes implements usable at all: without it, narrowing the parameter is rejected under a strict configuration and the clause has to be dropped. It defaults to the agnostic IncomingEvent, so a policy that reads nothing platform-specific writes nothing. IAuthorizer<User> does the same for a custom authorizer that reasons about your own user type.
#Reuse in the UI
Because the ability is a value, the frontend asks the same question before rendering a control, so the UI never offers an action the API would reject, down to individual fields.
{ability.can('delete', task) && <button onClick={remove}>Delete</button>}
<input disabled={ability.cannot('update', task, 'title')} />One rule set. It guards the route, the field, and the button. They can never disagree.