Skip to main content

nestjs

NestJS guard, @Authorize decorator, type-safe decorator factory, and engine provider for duck-iam.

Install

import {
  nestAccessGuard,
  Authorize,
  createTypedAuthorize,
  createEngineProvider,
  IAM_ACCESS_ENGINE_TOKEN,
  IAM_ACCESS_METADATA_KEY,
} from '@gentleduck/iam/server/nest'

Zero hard dependency on @nestjs/common - uses minimal duck-typed interfaces. reflect-metadata is used when available, with a direct property fallback so the decorator works without it.


Guard factory

Create a NestJS-compatible guard function. It reads metadata from the @Authorize decorator on each handler.

import { nestAccessGuard } from '@gentleduck/iam/server/nest'

const canAccess = nestAccessGuard(engine, {
  getUserId: (req) => req.user?.id ?? req.user?.sub,
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
  getResourceId: (req) => req.params?.id,
  onError: (err, req) => false,
})

// Register as a global guard in your module:
// APP_GUARD -> { provide: APP_GUARD, useValue: { canActivate: canAccess } }

Handlers without an @Authorize decorator are allowed by default. The guard only activates when metadata is present.


The Authorize decorator

Annotate controller methods with their required permissions.

import { Authorize } from '@gentleduck/iam/server/nest'

@Controller('posts')
export class PostsController {
  @Delete(':id')
  @Authorize({ action: 'delete', resource: 'post' })
  async deletePost(@Param('id') id: string) {
    return this.postsService.delete(id)
  }

  @Post()
  @Authorize({ action: 'create', resource: 'post' })
  async createPost(@Body() dto: CreatePostDto) {
    return this.postsService.create(dto)
  }

  // Infer action from HTTP method, resource from route path
  @Get()
  @Authorize({ infer: true })
  async listPosts() {
    return this.postsService.list()
  }
}

Scoped authorization

Attach a scope to restrict access to a specific context.

@Authorize({ action: 'manage', resource: 'billing', scope: 'admin' })
async updateBilling() { ... }

If no scope is set on the decorator, the guard falls back to the getScope option from the guard factory.


Type-safe decorator

createTypedAuthorize constrains the decorator to your application's exact action, resource, and scope types. Typos become compile-time errors.

import { createTypedAuthorize } from '@gentleduck/iam/server/nest'

type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'user' | 'billing' | 'report'
type Scope = 'admin' | 'member'

const Auth = createTypedAuthorize<Action, Resource, Scope>()

// This compiles:
@Auth({ action: 'delete', resource: 'post' })

// This is a compile error - "craete" is not in Action:
@Auth({ action: 'craete', resource: 'post' })

Engine provider

Register the engine as a NestJS provider:

import { Module } from '@nestjs/common'
import { APP_GUARD } from '@nestjs/core'
import {
  createEngineProvider,
  IAM_ACCESS_ENGINE_TOKEN,
  nestAccessGuard,
} from '@gentleduck/iam/server/nest'

@Module({
  providers: [
    createEngineProvider(() => new IamEngine({ adapter })),
    {
      provide: APP_GUARD,
      useFactory: (engine) => ({ canActivate: nestAccessGuard(engine) }),
      inject: [IAM_ACCESS_ENGINE_TOKEN],
    },
  ],
})
export class AppModule {}

The engine factory can be async - return a Promise<IamEngine> to support adapter setup that requires await.


Notes

  • reflect-metadata is optional but recommended. The integration uses it when available and falls back to a property attached directly to the descriptor. In a normal Nest app, load reflect-metadata once at the entry point.
  • No @Authorize = allowed. A handler without metadata is treated as public. Use route-level Nest guards or other middleware for blanket coverage.
  • onError returns boolean. Default is () => false (deny on engine error). Return true only if you want a fail-open behavior in specific cases.

Options reference

OptionTypeDefaultDescription
getUserId(req) -> string or nullreq.user?.id ?? req.user?.subExtract the subject ID
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
getResourceId(req) -> string or undefinedreq.params?.idExtract the resource instance ID
getScope(req) -> string or undefinedundefinedFallback scope when decorator has none
onError(err, req) -> boolean() => falseReturn true to allow on error, false to deny

Exported tokens

TokenTypePurpose
IAM_ACCESS_ENGINE_TOKENstring ('ACCESS_ENGINE')DI token for the engine
IAM_ACCESS_METADATA_KEYstring ('duck-iam:authorize')Reflect metadata key for the decorator

Admin operations

NestJS routes via controllers, so duck-iam ships gated admin operations (not a router factory). createAdminOperations returns a record of pre-gated functions to plug into your controller methods. The authorize callback is required - the factory throws if it's missing.

import { Controller, Get, Put, Post, Delete, Inject, Req, Body, Param } from '@nestjs/common'
import { IAM_ACCESS_ENGINE_TOKEN, createAdminOperations } from '@gentleduck/iam/server/nest'

@Controller('admin')
export class IamAdminController {
  private readonly h

  constructor(@Inject(IAM_ACCESS_ENGINE_TOKEN) engine) {
    this.h = createAdminOperations(engine, {
      authorize: (req) => req.user?.role === 'platform-admin',
    })
  }

  @Get('policies')        listPolicies(@Req() req) { return this.h.listPolicies(req) }
  @Get('roles')           listRoles(@Req() req)    { return this.h.listRoles(req) }
  @Put('policies')        savePolicy(@Req() req, @Body() body) { return this.h.savePolicy(req, body) }
  @Put('roles')           saveRole(@Req() req, @Body() body)   { return this.h.saveRole(req, body) }

  @Post('subjects/:id/roles')
  assignRole(@Req() req, @Param('id') id, @Body() body) { return this.h.assignRole(req, id, body) }

  @Delete('subjects/:id/roles/:roleId')
  revokeRole(@Req() req, @Param('id') id, @Param('roleId') roleId) { return this.h.revokeRole(req, id, roleId) }
}

Every operation throws Error & { status: 401 } when authorize returns false - wire your Nest exception filter to map that to a 401 response, or call authorize yourself inside a @UseGuards() and skip this helper.

CSRF protection (default-on)

Mutation operations (savePolicy, saveRole, assignRole, revokeRole) run a Sec-Fetch-Site check by default (SEC-103 / CAVEAT-2). Failures throw Error & { status: 403 }. Browsers populate the header automatically; cross-site form posts are rejected, same-site / same-origin requests pass. Non-browser callers (no header) pass - they must be gated by bearer / mTLS.

// Default - cookie-auth admin UIs get CSRF protection with no opt-in.
createAdminOperations(engine, { authorize })

// Server-to-server bearer/mTLS API - disable the check entirely.
createAdminOperations(engine, { authorize, csrfCheck: false })

// Stricter - Origin allowlist.
const ADMIN_ORIGINS = new Set(['https://admin.example.com'])
createAdminOperations(engine, {
  authorize,
  csrfCheck: (req) => ADMIN_ORIGINS.has(req.headers?.origin as string),
})

The admin operations options object conforms to Nest.IAdminOptions with authorize of shape Nest.IAdminAuthorize. The metadata read by the guard from @Authorize conforms to Nest.IAuthorizeMeta, and the guard factory accepts Nest.IGuardOptions.


Types

All types live under the Nest namespace at @gentleduck/iam/server/nest. Type-only - zero bundle cost.

  • Nest.IAuthorizeMeta - shape of the metadata stamped onto a handler by @Authorize and createTypedAuthorize.
  • Nest.IGuardOptions - options for nestAccessGuard (getUserId, getEnvironment, getResourceId, getScope, onError).
  • Nest.IAdminAuthorize - signature of the required authorize callback for createAdminOperations.
  • Nest.IAdminOptions - options for createAdminOperations.
import type { Nest } from '@gentleduck/iam/server/nest'

const guardOpts: Nest.IGuardOptions = {
  getUserId: (req) => req.user?.id ?? req.user?.sub,
}

const meta: Nest.IAuthorizeMeta = { action: 'delete', resource: 'post' }

Deprecated bare aliases (IAuthorizeMeta, IGuardOptions, IAdminAuthorize, IAdminOptions) remain for back-compat and will be removed in 3.0.