PermDock
Adapters

NestJS

permdock/nest provides a module, a guard and a Protect decorator so Nest controllers get a request-scoped PermDock on the Express or Fastify platform.

Requires: @nestjs/common 12, @nestjs/core 12 and @nestjs/platform-express 12

Purpose

Nest structures authorization as guards and decorators, so permdock/nest maps the server kernel onto that shape: a module that registers the factory, a guard registered as APP_GUARD that builds the request-scoped instance and evaluates route metadata, a Protect decorator that attaches the permission (and optional loader) to a handler, and a parameter decorator to inject the instance. The kernel only needs the underlying Node request converted once. Outside HTTP (GraphQL, WebSocket gateways, microservices) a route with Protect metadata is denied unless the request option maps the ExecutionContext to a request; a route without metadata passes through.

API

// permdock.module.ts
import { Module } from "@nestjs/common";
import { createPermDock } from "permdock/nest";
import { policy } from "./policy";

export const { PermDockModule, Protect, InjectPermDock } = createPermDock(
  policy,
  {
    subject: (request) => request.user ?? null,
  },
);

@Module({
  imports: [PermDockModule.forRoot({ guard: "global" })],
})
export class AppModule {}

// posts.controller.ts
@Controller("posts")
export class PostsController {
  constructor(private readonly posts: PostsService) {}

  @Delete(":id")
  @Protect(permissions.post.delete, (request) => loadPost(request.params.id))
  async remove(@InjectPermDock() permdock: PermDock, @Param("id") id: string) {
    await this.posts.remove(id);
  }

  @Get()
  async list(@InjectPermDock() permdock: PermDock) {
    return permdock.filter(permissions.post.read, await this.posts.all());
  }
}
ExportRole
PermDockModuleRegisters the kernel factory as a provider so guards and services can inject it. Also registers PermDockExceptionFilter as APP_FILTER. PermDockModule.forRoot({ guard: 'global' }) also registers PermDockGuard as APP_GUARD.
PermDockGuardCanActivate implementation. Builds the request-scoped instance once per request (stored on the request), reads Protect metadata from the handler and class, runs loadData, decides, and throws before the handler. Routes without Protect metadata pass through but still get an instance. Register it with forRoot({ guard: 'global' }), or as APP_GUARD with useExisting.
Protect(permission, loadData?, options?)Method or class decorator setting metadata for the guard. Multiple decorators on class and method are all enforced; rules that pass the same loadData function call it once per request. Protect(null, loadData?, { oauthScopes }) needs a principal and, from a delegated token, one of the OAuth scopes, but no permission. options is ProtectOptions ({ trusted: true } for a loader that returns a row the server loaded).
InjectPermDock()Parameter decorator injecting the request-scoped instance into handlers.
PermDockExceptionFilterFilter that renders PermDock exceptions (denied, approval required, revoked, validation, invalid signature) as application/problem+json with WWW-Authenticate when the kernel sets it.
permdockHandler(options?)Controller class for the AuthZEN-shaped decision endpoint. options.path is the controller path (default api/permdock) and may hold route params, for example ':org/permdock/access/v1/evaluations' with a tenant that reads req.params.org.
decorateMethod(cls, key, ...decorators)Applies method decorators in the order given without decorator syntax, for code limited to erasable TypeScript: decorateMethod(PostsController, 'update', Patch(':id'), Protect(permissions.post.update)).
openapiKernel hook contract for @nestjs/swagger.
request option(context: ExecutionContext) => request | undefined. Maps a non-HTTP context (for example a GraphQL context.req or a WebSocket handshake) to the request the kernel reads. Without it, protected non-HTTP routes are denied.

Gateways

connection(client, req, options?) opens a kernel Connection for a gateway client (typed NestSocket: a Socket.IO socket or a raw ws socket), memoised per client. When it aborts, the client receives permdock:error with the Problem Details and is disconnected, or a raw socket is closed with 1008. PermDockGuard on a @SubscribeMessage handler with Protect metadata checks each message against the client's connection the way HTTP protect does: the OAuth scope check, the loader (a null result is a 404 problem), the decision and the approval resume from the PermDock-Approval header on the mapped request. A denial is the usual Problem Details and the connection stays open; a message after revocation throws PermDockRevokedError. Pass revocations to PermDockModule.

@WebSocketGateway()
export class ProjectsGateway
  implements OnGatewayConnection, OnGatewayDisconnect
{
  async handleConnection(client: Socket) {
    await permdock.connection(client, client.request, {
      permission: permissions.project.read,
    });
  }
  async handleDisconnect(client: Socket) {
    (await permdock.connection(client, client.request)).close();
  }
}

Request lifecycle

permdock/nest follows the shared adapter contract through the server kernel. What differs on Nest:

  1. PermDockGuard runs for every HTTP route; it converts the platform request, calls subject(request) once, builds the instance for the tenant the tenant option reads from the routed request, and caches it on the request object. Both @nestjs/platform-express and @nestjs/platform-fastify are supported: the guard reads the parsed body or the raw stream of either, and the filter and decision controller write through a ServerResponse or a FastifyReply. Another platform fails loudly with a TypeError naming both.
  2. If the handler or class carries Protect metadata, the guard runs the loader, validates untrusted input after Nest's own ValidationPipe has run, and decides. Denials throw before the handler executes. A Protect decorator referencing a permission from a different definition than the module's policy is a type error.
  3. Handlers receive the instance through @InjectPermDock() and may call assert, filter or where.
  4. PermDockExceptionFilter renders by transport: HTTP gets the kernel's Problem Details; a ws client with emit receives an exception event carrying status: 'error', the title and the Problem Details body; other transports get the exception rethrown to their own filters.

Example app

apps/examples/nest: a posts module on the Express platform with the guard registered by PermDockModule.forRoot({ guard: 'global' }), Protect applied with decorateMethod on PATCH /posts/:id and POST /posts/:id/publish, and the decision endpoint controller.

tests/integration/src/http/nest.test.ts runs the testHttpAdapter scenarios on both platforms: FileInterceptor uploads on Express, @fastify/multipart on Fastify, an admin module under RouterModule and the decision controller at a tenant path.

Last updated on

On this page