Skip to main content

express

Express middleware, per-route guard, and admin router for duck-iam. No runtime dependency on Express.

Install

import { accessMiddleware, guard, adminRouter } from '@gentleduck/iam/server/express'
import { accessMiddleware, guard, adminRouter } from '@gentleduck/iam/server/express'

No runtime dependency on Express — the integration is typed against minimal req/res/next interfaces and works with Express 4, Express 5, and any compatible router.


Global middleware

Apply access control to every route under a path prefix.

import { accessMiddleware } from '@gentleduck/iam/server/express'
import { METHOD_ACTION_MAP } from '@gentleduck/iam/server/generic'
 
const middleware = accessMiddleware(engine, {
  // Extract user ID from your auth layer (passport, jwt, etc.)
  getUserId: (req) => req.user?.id,
 
  // Map HTTP method to action (defaults to METHOD_ACTION_MAP)
  getAction: (req) => METHOD_ACTION_MAP[req.method],
 
  // Infer resource from the URL path
  getResource: (req) => {
    const parts = req.path.split('/').filter(Boolean)
    return { type: parts[0] ?? 'root', id: parts[1], attributes: {} }
  },
 
  // Optional: extract scope from headers or query
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
 
  // Optional: extract environment context (IP, user agent, timestamp)
  getEnvironment: (req) => ({
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: Date.now(),
  }),
 
  // Custom denial response
  onDenied: (req, res) => res.status(403).json({ error: 'Forbidden' }),
 
  // Custom error handler
  onError: (err, req, res) => res.status(500).json({ error: 'Internal server error' }),
})
 
app.use('/api', middleware)
import { accessMiddleware } from '@gentleduck/iam/server/express'
import { METHOD_ACTION_MAP } from '@gentleduck/iam/server/generic'
 
const middleware = accessMiddleware(engine, {
  // Extract user ID from your auth layer (passport, jwt, etc.)
  getUserId: (req) => req.user?.id,
 
  // Map HTTP method to action (defaults to METHOD_ACTION_MAP)
  getAction: (req) => METHOD_ACTION_MAP[req.method],
 
  // Infer resource from the URL path
  getResource: (req) => {
    const parts = req.path.split('/').filter(Boolean)
    return { type: parts[0] ?? 'root', id: parts[1], attributes: {} }
  },
 
  // Optional: extract scope from headers or query
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
 
  // Optional: extract environment context (IP, user agent, timestamp)
  getEnvironment: (req) => ({
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: Date.now(),
  }),
 
  // Custom denial response
  onDenied: (req, res) => res.status(403).json({ error: 'Forbidden' }),
 
  // Custom error handler
  onError: (err, req, res) => res.status(500).json({ error: 'Internal server error' }),
})
 
app.use('/api', middleware)

Per-route guard

Use guard on individual routes when the action and resource are known at definition time.

import { guard } from '@gentleduck/iam/server/express'
 
// Basic guard — action and resource are fixed
app.delete('/posts/:id', guard(engine, 'delete', 'post'), (req, res) => {
  // Only reached if engine.can() returns true
  res.json({ deleted: true })
})
 
// Scoped guard — restrict to a specific scope
app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), (req, res) => {
  res.json({ created: true })
})
 
// With custom environment
app.patch(
  '/posts/:id',
  guard(engine, 'update', 'post', {
    getEnvironment: (req) => ({ ip: req.ip, timestamp: Date.now() }),
  }),
  handler,
)
 
// Custom user ID extraction
app.get(
  '/reports',
  guard(engine, 'read', 'report', {
    getUserId: (req) => req.headers['x-api-key'] as string,
  }),
  handler,
)
import { guard } from '@gentleduck/iam/server/express'
 
// Basic guard — action and resource are fixed
app.delete('/posts/:id', guard(engine, 'delete', 'post'), (req, res) => {
  // Only reached if engine.can() returns true
  res.json({ deleted: true })
})
 
// Scoped guard — restrict to a specific scope
app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), (req, res) => {
  res.json({ created: true })
})
 
// With custom environment
app.patch(
  '/posts/:id',
  guard(engine, 'update', 'post', {
    getEnvironment: (req) => ({ ip: req.ip, timestamp: Date.now() }),
  }),
  handler,
)
 
// Custom user ID extraction
app.get(
  '/reports',
  guard(engine, 'read', 'report', {
    getUserId: (req) => req.headers['x-api-key'] as string,
  }),
  handler,
)

Error handling differs between the two:

  • guard catches errors and passes them to next(err) — they reach Express's error-handling middleware.
  • accessMiddleware catches errors and calls the onError option — defaults to a 500 JSON response.

Admin router

Mount a pre-built admin API for managing policies, roles, and assignments.

import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
 
const createRouter = adminRouter(engine)
app.use('/api/access-admin', createRouter(() => express.Router()))
import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
 
const createRouter = adminRouter(engine)
app.use('/api/access-admin', createRouter(() => express.Router()))

This exposes:

MethodPathDescription
GET/policiesList all policies
GET/rolesList all roles
PUT/policiesCreate or update a policy
PUT/rolesCreate or update a role
POST/subjects/:id/rolesAssign a role to a subject
DELETE/subjects/:id/roles/:roleIdRevoke a role from a subject

The admin router does not secure itself — mount it behind your own admin-only authentication.


Options reference

OptionTypeDefaultDescription
getUserId(req) -> string or nullreq.user?.idExtract the subject ID from the request
getAction(req) -> stringHTTP method mapMap the request to an action
getResource(req) -> ResourceInfer from URL pathMap the request to a resource
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
getScope(req) -> string or undefinedundefinedExtract scope (e.g. org ID, team ID)
onDenied(req, res) -> void403 JSONCustom denial response
onError(err, req, res, next) -> void500 JSONCustom error handler