NestJS
Not on npm yet
npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
apps/shop-api in this repository is the Nest service that authors Baize’s
matrix, enforces it, and publishes it to the three frontends. Every fence
below is a region of that app and runs under nx test @evanion/shop-api.
There is no @evanion/nestjs-acl: the app calls @evanion/acl directly.
The concept. A Nest request passes a guard before the row is fetched and a service after it, so one write decides twice.
What you get. A guard that refuses a subject holding no game.declare at
all, and a service that decides on Brass: Birmingham.
Why you want it. A guard asking an object-dependent rule with no instance
gets unevaluable back, which is not a refusal.
How the library gets you there. readsObject reports which permissions need
the row, which is what puts each decision where the data is.
What apps/shop-api wires up
apps/shop-api runs four pieces, and every fence on this page is a region of
one of them:
| Piece | Where it lives | What it does |
|---|---|---|
| the matrix | built at boot behind an injection token | holds every rule the service enforces |
| the subject | AsyncLocalStorage | carries the request’s actor to every caller |
AclGuard | a global guard | refuses what it can refuse before the read |
GamesService | the service layer | decides game.declare on the loaded title |
A CanActivate guard runs before the controller method and holds the subject
with no row. The service runs after the read and holds both.
access.readsObject(key, action) tells the guard which permissions it can
finish, so the guard refuses those and passes the others on. canFields and
pickAllowedFields then close the write in the service.
Baize grants game.declare to an operator whose own shop lists the title. A
guard called with no title answers unevaluable, and an app reading that as a
grant lets an operator in Gothenburg edit a Stockholm listing.
Two seats, and only one of them decides
A seat is a place in the request pipeline where an app can call can, and a
Nest write passes two of them: the guard, before the row is fetched, and the
service, after. A guard holds the subject and no row, so it can ask whether this
subject holds game.declare at all and cannot ask whether it holds it on Brass:
Birmingham.
An object-dependent rule called with no instance comes back unevaluable,
naming the paths it could not read.
The service after the read is the enforcement point
The row-level decision belongs in the service, after the read. A guard that passed is a guard that could not decide, and an app that reads that as a grant leaves an object-dependent permission unenforced. Keep both seats.
Building Baize’s matrix once at boot
ShopSubject carries id, roles and shop. Game, OrderDraft,
TelemetryEvent and Stock are the four row shapes the four keys decide over,
SHOP_SCHEMA states their field types for the document, AVAILABILITIES is the
catalogue’s four availability tokens, and SHOP_MATRIX_VERSION is the string a
frontend compares its copy against.
const SHOP_POLICY = policy<ShopSubject, ShopObjects, ShopVerbs>({
version: SHOP_MATRIX_VERSION,
schema: SHOP_SCHEMA,
})
.for('game', (p) =>
p
.allow('read', p.always)
.visibility('public')
// The operator grant and the manager grant are separate rules, so a
// reader strikes one out without touching the other.
.allow(
'declare',
p.contains('subject.roles', 'operator'),
p.eq('object.shop', 'subject.shop'),
)
.allow(
'declare',
p.contains('subject.roles', 'manager'),
p.eq('object.shop', 'subject.shop'),
)
.fields({
fields: ['availability'],
availability: { targets: [...AVAILABILITIES] },
})
.visibility('public')
.allow(
'reprice',
p.contains('subject.roles', 'manager'),
p.eq('object.shop', 'subject.shop'),
)
.fields({
fields: ['availability', 'price'],
availability: { targets: [...AVAILABILITIES] },
})
.visibility('public'),
)
.for('order', (p) =>
p
.allow('create', p.contains('subject.roles', 'customer'))
.visibility('public'),
)
.for('telemetry', (p) =>
p
.allow('read', p.contains('subject.roles', 'manager'))
.visibility('public'),
)
// No marking, so `serialize(access, 'reduced')` drops it: the browsers never
// ask about stock, and a contract carrying a rule nobody evaluates is a
// larger surface for nothing.
.for('inventory', (p) => p.allow('read', p.always));Six permissions, each carrying visibility except inventory.read, which
shop-api keeps to itself. game.declare lets an operator set availability;
game.reprice adds price and belongs to a manager. Both read the ownership
axis object.shop === subject.shop.
SHOP_MATRIX is the document that builder produced and ShopObjects maps each
key to its row shape. hydratePolicy adopts it, because this process wrote it:
export function buildShopAccess(): Access<ShopSubject, ShopObjects> {
return hydratePolicy<ShopSubject, ShopObjects>(SHOP_MATRIX);
}hydratePolicy validates, deep-clones and deep-freezes the document. An
unknown field, or two permissions sharing a key, stops the boot.
The module that binds the matrix
AclModule.forRoot() returns a global: true DynamicModule with a useValue
provider under a package-namespaced string token. ACL_ACCESS is that token,
AclGuard is the guard two sections below, and PolicyController is the
endpoint at the end of this page:
@Module({})
export class AclModule {
/**
* Builds the matrix once and returns the module, registered `global: true`.
*
* Global for the reason `CorrelationModule.forRoot()` states about its own
* registration: `AclGuard`, `GamesService`, `InventoryClient` and
* `SubjectMiddleware` all resolve `ACL_ACCESS` or `SubjectService` from the
* root injector, and without it every one of those modules would import this
* one.
*
* `AclGuard` is bound under `APP_GUARD`, so it runs on every route in the
* application and the `@Requires` metadata decides which routes it actually
* gates. A route carrying no metadata is waved through by the guard itself.
*
* Call it once. A second `forRoot()` registers a second `ACL_ACCESS`
* provider under the same token and the last import wins.
*/
static forRoot(): DynamicModule {
const accessProvider: Provider = {
provide: ACL_ACCESS,
// hydratePolicy runs here and nowhere else, so the process holds one
// frozen document and a construction failure stops the boot.
useValue: buildShopAccess(),
};
return {
global: true,
module: AclModule,
controllers: [PolicyController],
providers: [
accessProvider,
SubjectService,
{ provide: APP_GUARD, useClass: AclGuard },
],
exports: [accessProvider, SubjectService],
};
}
}The token is a namespaced string:
export const = '@evanion/shop-api:ACL_ACCESS';The provider sits in a global module, where a bare 'ACL_ACCESS' collides in
silence. A symbol is identity-based, so two copies of the package mint two
tokens and Nest fails to resolve one of them. APP_GUARD binds the guard to
every route, and the @Requires metadata below decides which routes it gates.
Resolving the subject in a Nest request
SubjectService holds the request’s subject in an AsyncLocalStorage
singleton. Scope.REQUEST propagates upward through the injection graph:
every dependent of a request-scoped provider becomes request-scoped, is rebuilt
per request, and never receives onModuleInit. GamesService and
InventoryClient both depend on the subject, so request scope on it would
re-scope the application.
@Injectable()
export class SubjectService {
private readonly storage = new AsyncLocalStorage<ShopSubject>();
/**
* Runs `callback` with `subject` as the actor. Everything it awaits,
* schedules or calls reads that subject, and overlapping requests stay
* isolated.
*
* `SubjectMiddleware` does this per request. Call it directly for work no
* request drives, such as a queue consumer or a seeding script.
*/
run<T>(subject: ShopSubject, callback: () => T): T {
return this.storage.run(subject, callback);
}
/**
* The subject of the surrounding context, or `undefined` outside one.
*
* It invents no subject. A caller that needs one refuses when this answers
* `undefined`, because a decision made against a subject nobody stated is a
* decision made against nothing.
*/
current(): ShopSubject | undefined {
return this.storage.getStore();
}
}The middleware that fills it types its arguments against node:http, so one
class works under either adapter. SHOP_SUBJECT_HEADER is X-Shop-Subject,
subjectFrom parses it, and ANONYMOUS_SUBJECT is a shopper who works for no
shop:
@Injectable()
export class SubjectMiddleware implements NestMiddleware {
constructor(private readonly subjects: SubjectService) {}
use(
req: IncomingMessage,
_res: ServerResponse,
next: (error?: unknown) => void,
): void {
const header = singleValue(req.headers[SHOP_SUBJECT_HEADER.toLowerCase()]);
this.subjects.run(subjectFrom(header), next);
}
}This demo reads a header, and a deployment must not
Baize has no session layer, so a caller states who it is and shop-api
believes it. can authorizes the bag it is handed and has no channel to ask
where the bag came from, so a subject assembled from a header, a query
parameter or a posted field is the confused deputy. In production this
middleware sits behind whatever verifies the session and reads the verified
actor. Nothing else in the app changes, because every caller reads
SubjectService.current().
The guard refuses what it can refuse
@Requires writes the permission onto the handler under the namespaced metadata
key REQUIRES_PERMISSION, and the guard reads it back. RequiredPermission
types key against ShopObjects, so a route naming a kind the matrix does not
carry fails to compile:
export const Requires = (key: RequiredPermission['key'], action: string) =>
SetMetadata<string, RequiredPermission>(REQUIRES_PERMISSION, { key, action });@Injectable()
export class AclGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly subjects: SubjectService,
@Inject(ACL_ACCESS)
private readonly access: Access<ShopSubject, ShopObjects>,
) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<
RequiredPermission | undefined
>(REQUIRES_PERMISSION, [context.getHandler(), context.getClass()]);
if (!required) return true;
const subject = this.subjects.current();
// SubjectMiddleware runs on every route and falls back to the anonymous
// subject, so this is reached only where the middleware did not run, which
// is a request nothing resolved an actor for.
if (!subject) throw new UnauthorizedException();
const decision = this.access.can(subject, required.key, required.action);
if (decision.allowed) return true;
// `unevaluable` means the rules read fields of a row this seat does not
// hold. The service decides it after the read, so the request continues.
if (this.access.readsObject(required.key, required.action)) return true;
throw new ForbiddenException(decision.reason);
}
}readsObject answers true when any allow rule or any deny rule of that
permission reads an object.* path, on either operand. order.create reads the
subject’s roles alone, so a customer-less caller is refused here and never
reaches OrdersController. game.declare reads the title’s shop, so the guard
passes it on for GamesService to finish.
The guard does not catch a @Requires naming an action the document has no
permission for: key is typed and action stays a string. On an Access built
open, can throws UnknownPermissionError, which Nest renders as a 500.
The service decides on the row
GamesService holds SubjectService, the catalogue and ACL_ACCESS.
GameDeclaration is the request body, findByUrn loads the title, and the
action is chosen from the body because the two write actions carry different
field allow-lists:
declare(urn: string, proposed: GameDeclaration): Game {
const game = this.findByUrn(urn);
const subject = this.subjects.current();
if (!subject) throw new UnauthorizedException();
const action = 'price' in proposed ? 'reprice' : 'declare';
const decision = this.access.canFields(
subject,
'game',
action,
game,
'write',
proposed,
);
if (!decision.action.allowed) {
throw new ForbiddenException(decision.action.reason);
}
// pickAllowedFields keeps the keys the decision marked `allowed` and
// nothing else, so a denied key and an unevaluable key are both withheld.
const writable = pickAllowedFields(decision, proposed);
if (Object.keys(writable).length === 0) {
throw new BadRequestException('No field of this declaration is writable');
}
this.declared.set(urn, { ...this.declared.get(urn), ...writable });
return this.findByUrn(urn);
}decision.action.allowed is checked first, so the refusal is a
ForbiddenException and never an exception thrown out of pickAllowedFields. A
hand-written read of the field map on !== 'denied' writes the unevaluable
fields too. A Nest @Body() is the mass-assignment shape, because a caller puts
whatever it likes in it.
A whitelisting ValidationPipe answers a different question: the pipe says the
key is a legal field of this endpoint, and the decision says this subject may set
it on this row. Keep both.
Publishing the matrix to the frontends
PolicyController answers GET /api/policy with the reduced document:
@Controller('policy')
export class PolicyController {
constructor(
@Inject(ACL_ACCESS)
private readonly access: Access<ShopSubject, ShopObjects>,
) {}
@Get()
read(): PolicyDocument {
const matrix = serialize(this.access, 'reduced');
return { version: matrix.version, matrix };
}
}serialize(access, 'reduced') emits the permissions marked
visibility: 'public', each kept whole, and drops inventory.read. The three
frontends fetch this once and evaluate it locally, so no decision any of them
makes waits on a network call. Each of them decides again anyway, and so does
this service on the next request it receives.
Testing it
apps/shop-api stubs the request context with useValue on the class token,
the idiom the app already uses elsewhere:
const module = await Test.createTestingModule({
controllers: [GamesController],
providers: [
GamesService,
{ provide: ACL_ACCESS, useValue: buildShopAccess() },
{ provide: SubjectService, useValue: { current: () => subject } },
],
}).compile();The matrix is the real one, because a stubbed decision only asserts that the
controller called something. acl.guard.spec.ts covers the refusal seat,
games.service.spec.ts the row seat, and orders.e2e.spec.ts boots the real
AppModule over a real port.
Where to go next
- Express — the same boundary with no guard seat
- Why deny, then allow, then deny? — where the guard’s two branches come from
- Decisions — the
unevaluablestatereadsObjectexists for - Security contract — complete mediation, and why the guard is not it