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:
guardcatches errors and passes them tonext(err)— they reach Express's error-handling middleware.accessMiddlewarecatches errors and calls theonErroroption — defaults to a 500 JSON response.
The Express guard() helper reads the resource instance ID from req.params.id. If your
route uses a different param name such as :postId, either use accessMiddleware() with a
custom getResource, or run engine.can() / engine.check() yourself inside the handler
once you have the real resource ID and attributes.
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()))The built-in delete route revokes by subjectId + roleId only. If you store both global and
scoped assignments for the same role and you need to revoke only one scoped assignment, expose a
custom route that calls engine.admin.revokeRole(subjectId, roleId, scope) explicitly.
This exposes:
| Method | Path | Description |
|---|---|---|
| GET | /policies | List all policies |
| GET | /roles | List all roles |
| PUT | /policies | Create or update a policy |
| PUT | /roles | Create or update a role |
| POST | /subjects/:id/roles | Assign a role to a subject |
| DELETE | /subjects/:id/roles/:roleId | Revoke a role from a subject |
The admin router does not secure itself — mount it behind your own admin-only authentication.
Options reference
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (req) -> string or null | req.user?.id | Extract the subject ID from the request |
getAction | (req) -> string | HTTP method map | Map the request to an action |
getResource | (req) -> Resource | Infer from URL path | Map the request to a resource |
getEnvironment | (req) -> Environment | IP + user agent + timestamp | Extract environment context |
getScope | (req) -> string or undefined | undefined | Extract scope (e.g. org ID, team ID) |
onDenied | (req, res) -> void | 403 JSON | Custom denial response |
onError | (err, req, res, next) -> void | 500 JSON | Custom error handler |