Stone.jsDocs
Paradigm

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

terminalbash
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.

app/Application.tsdeclarativeimperative
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.

app/abilities.tsts
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

CapabilityTypeDescription
can('delete', 'Task')RBACAllow an action on a subject type.
can('update', 'Task', { ownerId })ABACAllow only when the subject instance matches the conditions.
can('update', 'Task', ['title'])field-levelRestrict an action to specific fields.
can('manage', subject)action alias'manage' matches every action; define your own aliases with createAliasResolver.
cannot('delete', 'Task', {...})exceptionCarve 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.

app/Tasks.tsdeclarativeimperative
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')] }]
])
app/Tasks.tsts
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.

app/AdminController.tsts
@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:

app/policies/PostPolicy.tsts
@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.

app/pages/TaskRow.tsxtsx
{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.

Stone.js

Your app exists in every runtime. Until you run it.

An open-source project by Stone Foundation
Created by Mr. Stone (Evens Pierre)