Skip to main content

chapter 6: server integration

Add authorization middleware to your backend. Protect Express, Hono, NestJS, or Next.js routes with duck-iam guards and generate permission maps for the client.

Goal

BlogDuck has a working authorization engine. Now protect actual HTTP endpoints. Pick the framework you use.

Loading diagram...

How It Works

All server integrations share the same pattern:

  1. Extract the user ID from the request (JWT, session, header)
  2. Determine the action (from HTTP method) and resource (from URL path)
  3. Call engine.can() with the extracted context
  4. Allow or deny the request

HTTP Method to Action Mapping

duck-iam maps HTTP methods to actions automatically via METHOD_ACTION_MAP:

const METHOD_ACTION_MAP = {
  GET: 'read',
  HEAD: 'read',
  OPTIONS: 'read',
  POST: 'create',
  PUT: 'update',
  PATCH: 'update',
  DELETE: 'delete',
}
const METHOD_ACTION_MAP = {
  GET: 'read',
  HEAD: 'read',
  OPTIONS: 'read',
  POST: 'create',
  PUT: 'update',
  PATCH: 'update',
  DELETE: 'delete',
}

URL Path to Resource Mapping

The default resource extraction parses the URL path:

/api/posts/123
      ^     ^
      |     +-- resourceId: '123'
      +-- resourceType: 'posts'

The first path segment after a base path is the resource type, the second is the resource ID. Customize this with the getResource callback.

Environment Extraction

The extractEnvironment() helper reads standard headers:

import { extractEnvironment } from '@gentleduck/iam/server/generic'
 
const env = extractEnvironment(req)
// {
//   ip: '192.168.1.1',       // from req.ip or x-forwarded-for or x-real-ip
//   userAgent: 'Mozilla/...',  // from user-agent header
//   timestamp: 1708300000000,  // Date.now()
// }
import { extractEnvironment } from '@gentleduck/iam/server/generic'
 
const env = extractEnvironment(req)
// {
//   ip: '192.168.1.1',       // from req.ip or x-forwarded-for or x-real-ip
//   userAgent: 'Mozilla/...',  // from user-agent header
//   timestamp: 1708300000000,  // Date.now()
// }

Generic Helpers

Framework-agnostic utilities that work with any server.

generatePermissionMap()

Generate a PermissionMap for the client (Chapter 7):

import { generatePermissionMap } from '@gentleduck/iam/server/generic'
 
const permissions = await generatePermissionMap(engine, userId, [
  { action: 'create', resource: 'post' },
  { action: 'update', resource: 'post', resourceId: 'post-1' },
  { action: 'delete', resource: 'post', resourceId: 'post-1' },
  { action: 'manage', resource: 'dashboard' },
  { action: 'manage', resource: 'user', scope: 'acme' },
])
// { 'create:post': true, 'update:post:post-1': true, ... }
import { generatePermissionMap } from '@gentleduck/iam/server/generic'
 
const permissions = await generatePermissionMap(engine, userId, [
  { action: 'create', resource: 'post' },
  { action: 'update', resource: 'post', resourceId: 'post-1' },
  { action: 'delete', resource: 'post', resourceId: 'post-1' },
  { action: 'manage', resource: 'dashboard' },
  { action: 'manage', resource: 'user', scope: 'acme' },
])
// { 'create:post': true, 'update:post:post-1': true, ... }

Pass this map to the client AccessProvider for permission-based UI rendering.

createSubjectCan()

Create a reusable can function bound to a user:

import { createSubjectCan } from '@gentleduck/iam/server/generic'
 
const can = createSubjectCan(engine, userId)
 
if (await can('delete', 'post', 'post-1')) {
  // delete the post
}
 
if (await can('manage', 'dashboard')) {
  // show admin panel
}
 
// With scope
if (await can('manage', 'user', undefined, 'acme')) {
  // manage users in acme
}
import { createSubjectCan } from '@gentleduck/iam/server/generic'
 
const can = createSubjectCan(engine, userId)
 
if (await can('delete', 'post', 'post-1')) {
  // delete the post
}
 
if (await can('manage', 'dashboard')) {
  // show admin panel
}
 
// With scope
if (await can('manage', 'user', undefined, 'acme')) {
  // manage users in acme
}

Engine Mode on the Server

All server integrations call engine.can() under the hood, which always returns a boolean regardless of engine mode. Middleware, guards, and route handlers work identically in both 'development' and 'production' mode.

Use mode: 'production' for maximum throughput. Production mode skips Decision object allocation, timing, and hooks, giving roughly 2x faster:

src/access.ts
import { Engine } from '@gentleduck/iam'
import { adapter } from './adapter'
 
export const engine = new Engine({
  adapter,
  mode: 'production', // recommended for servers -- ~2x faster, no Decision overhead
})
src/access.ts
import { Engine } from '@gentleduck/iam'
import { adapter } from './adapter'
 
export const engine = new Engine({
  adapter,
  mode: 'production', // recommended for servers -- ~2x faster, no Decision overhead
})

Temporarily switch back to 'development' mode for decision traces. Since engine.can() returns boolean either way, the rest of your code stays the same.

Express

Global middleware

src/server.ts
import express from 'express'
import { engine } from './access'  // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/express'
 
const app = express()
app.use(express.json())
 
app.use(accessMiddleware(engine, {
  getUserId: (req) => req.headers['x-user-id'] as string,
  getScope: (req) => req.headers['x-organization'] as string,
}))
src/server.ts
import express from 'express'
import { engine } from './access'  // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/express'
 
const app = express()
app.use(express.json())
 
app.use(accessMiddleware(engine, {
  getUserId: (req) => req.headers['x-user-id'] as string,
  getScope: (req) => req.headers['x-organization'] as string,
}))

Missing user ID returns 401. Denied permission returns 403.

Full options:

interface ExpressOptions {
  getUserId?: (req) => string | null        // default: req.user?.id
  getResource?: (req) => Resource           // default: parse from URL
  getAction?: (req) => string               // default: METHOD_ACTION_MAP
  getEnvironment?: (req) => Environment     // default: extractEnvironment()
  getScope?: (req) => string | undefined    // default: none
  onDenied?: (req, res) => void             // default: res.status(403).json(...)
  onError?: (err, req, res, next) => void   // default: res.status(500).json(...)
}
interface ExpressOptions {
  getUserId?: (req) => string | null        // default: req.user?.id
  getResource?: (req) => Resource           // default: parse from URL
  getAction?: (req) => string               // default: METHOD_ACTION_MAP
  getEnvironment?: (req) => Environment     // default: extractEnvironment()
  getScope?: (req) => string | undefined    // default: none
  onDenied?: (req, res) => void             // default: res.status(403).json(...)
  onError?: (err, req, res, next) => void   // default: res.status(500).json(...)
}

Per-route guards

src/server.ts
import { guard } from '@gentleduck/iam/server/express'
 
// Explicit action and resource
app.delete('/api/posts/:id',
  guard(engine, 'delete', 'post', {
    getUserId: (req) => req.headers['x-user-id'] as string,
  }),
  (req, res) => {
    res.json({ deleted: req.params.id })
  }
)
 
// With a fixed scope
app.post('/api/admin/users',
  guard(engine, 'manage', 'user', {
    getUserId: (req) => req.headers['x-user-id'] as string,
    scope: 'admin',
  }),
  (req, res) => {
    res.json({ created: true })
  }
)
src/server.ts
import { guard } from '@gentleduck/iam/server/express'
 
// Explicit action and resource
app.delete('/api/posts/:id',
  guard(engine, 'delete', 'post', {
    getUserId: (req) => req.headers['x-user-id'] as string,
  }),
  (req, res) => {
    res.json({ deleted: req.params.id })
  }
)
 
// With a fixed scope
app.post('/api/admin/users',
  guard(engine, 'manage', 'user', {
    getUserId: (req) => req.headers['x-user-id'] as string,
    scope: 'admin',
  }),
  (req, res) => {
    res.json({ created: true })
  }
)

guard() takes an explicit action and resource. The resource ID is auto-extracted from req.params.id.

Admin router

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

Exposed endpoints:

MethodPathDescriptionBody
GET/policiesList all policies
GET/rolesList all roles
PUT/policiesSave/update a policyPolicy object
PUT/rolesSave/update a roleRole object
POST/subjects/:id/rolesAssign role to subject{ roleId, scope? }
DELETE/subjects/:id/roles/:roleIdRevoke role from subject

Protect the admin router with your own auth middleware. Never expose it to unauthenticated users.

To revoke one scoped assignment without affecting the same role in other scopes, call engine.admin.revokeRole(subjectId, roleId, scope) directly.

Hono

Global middleware

src/server.ts
import { Hono } from 'hono'
import { engine } from './access'  // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/hono'
 
const app = new Hono()
 
app.use('*', accessMiddleware(engine, {
  getUserId: (c) => c.get('userId') || c.req.header('x-user-id'),
  getScope: (c) => c.req.header('x-organization'),
}))
src/server.ts
import { Hono } from 'hono'
import { engine } from './access'  // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/hono'
 
const app = new Hono()
 
app.use('*', accessMiddleware(engine, {
  getUserId: (c) => c.get('userId') || c.req.header('x-user-id'),
  getScope: (c) => c.req.header('x-organization'),
}))

The default getUserId checks c.get('userId') first (set by your auth middleware), then falls back to the x-user-id header.

For Cloudflare Workers, the IP is extracted from cf-connecting-ip (then x-forwarded-for as fallback).

Full options:

interface HonoOptions {
  getUserId?: (c) => string | null
  getResource?: (c) => Resource
  getAction?: (c) => string
  getEnvironment?: (c) => Environment
  getScope?: (c) => string | undefined
  onDenied?: (c) => Response
  onError?: (err, c) => Response
}
interface HonoOptions {
  getUserId?: (c) => string | null
  getResource?: (c) => Resource
  getAction?: (c) => string
  getEnvironment?: (c) => Environment
  getScope?: (c) => string | undefined
  onDenied?: (c) => Response
  onError?: (err, c) => Response
}

Per-route guards

src/server.ts
import { guard } from '@gentleduck/iam/server/hono'
 
app.delete('/api/posts/:id',
  guard(engine, 'delete', 'post', {
    getUserId: (c) => c.get('userId'),
  }),
  async (c) => {
    return c.json({ deleted: c.req.param('id') })
  }
)
src/server.ts
import { guard } from '@gentleduck/iam/server/hono'
 
app.delete('/api/posts/:id',
  guard(engine, 'delete', 'post', {
    getUserId: (c) => c.get('userId'),
  }),
  async (c) => {
    return c.json({ deleted: c.req.param('id') })
  }
)

NestJS

Create the access module

src/access/access.module.ts
import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access'  // mode: 'production' recommended
 
@Module({
  providers: [
    createEngineProvider(() => engine),
  ],
  exports: [ACCESS_ENGINE_TOKEN],
})
export class AccessModule {}
src/access/access.module.ts
import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access'  // mode: 'production' recommended
 
@Module({
  providers: [
    createEngineProvider(() => engine),
  ],
  exports: [ACCESS_ENGINE_TOKEN],
})
export class AccessModule {}

createEngineProvider() returns a NestJS provider that makes the engine available via dependency injection using the ACCESS_ENGINE_TOKEN injection token.

Create the access guard

src/access/access.guard.ts
import { CanActivate, ExecutionContext, Injectable, Inject } from '@nestjs/common'
import { nestAccessGuard, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import type { Engine } from '@gentleduck/iam'
 
@Injectable()
export class AccessGuard implements CanActivate {
  private check: ReturnType<typeof nestAccessGuard>
 
  constructor(@Inject(ACCESS_ENGINE_TOKEN) engine: Engine) {
    this.check = nestAccessGuard(engine, {
      getUserId: (req) => req.user?.id || req.user?.sub,
      getScope: (req) => req.headers['x-organization'],
      getResourceId: (req) => req.params?.id,
    })
  }
 
  async canActivate(context: ExecutionContext): Promise<boolean> {
    return this.check(context)
  }
}
src/access/access.guard.ts
import { CanActivate, ExecutionContext, Injectable, Inject } from '@nestjs/common'
import { nestAccessGuard, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import type { Engine } from '@gentleduck/iam'
 
@Injectable()
export class AccessGuard implements CanActivate {
  private check: ReturnType<typeof nestAccessGuard>
 
  constructor(@Inject(ACCESS_ENGINE_TOKEN) engine: Engine) {
    this.check = nestAccessGuard(engine, {
      getUserId: (req) => req.user?.id || req.user?.sub,
      getScope: (req) => req.headers['x-organization'],
      getResourceId: (req) => req.params?.id,
    })
  }
 
  async canActivate(context: ExecutionContext): Promise<boolean> {
    return this.check(context)
  }
}

Full options:

interface NestGuardOptions {
  getUserId?: (req) => string | null          // default: req.user?.id or req.user?.sub
  getEnvironment?: (req) => Environment       // default: extractEnvironment()
  getResourceId?: (req) => string | undefined // default: req.params?.id
  getScope?: (req) => string | undefined      // default: none
  onError?: (err, req) => boolean             // default: return false (deny)
}
interface NestGuardOptions {
  getUserId?: (req) => string | null          // default: req.user?.id or req.user?.sub
  getEnvironment?: (req) => Environment       // default: extractEnvironment()
  getResourceId?: (req) => string | undefined // default: req.params?.id
  getScope?: (req) => string | undefined      // default: none
  onError?: (err, req) => boolean             // default: return false (deny)
}

Use the @Authorize decorator

src/posts/posts.controller.ts
import { Controller, Delete, Get, Post, Param, UseGuards } from '@nestjs/common'
import { Authorize } from '@gentleduck/iam/server/nest'
import { AccessGuard } from '../access/access.guard'
 
@Controller('posts')
@UseGuards(AccessGuard)
export class PostsController {
  // Explicit action and resource
  @Delete(':id')
  @Authorize({ action: 'delete', resource: 'post' })
  async deletePost(@Param('id') id: string) {
    return { deleted: id }
  }
 
  // With scope
  @Post('admin')
  @Authorize({ action: 'manage', resource: 'post', scope: 'admin' })
  async adminAction() {
    return { success: true }
  }
 
  // Infer action from HTTP method, resource from route path
  @Get()
  @Authorize()  // infer: true by default
  async listPosts() {
    return []
  }
}
src/posts/posts.controller.ts
import { Controller, Delete, Get, Post, Param, UseGuards } from '@nestjs/common'
import { Authorize } from '@gentleduck/iam/server/nest'
import { AccessGuard } from '../access/access.guard'
 
@Controller('posts')
@UseGuards(AccessGuard)
export class PostsController {
  // Explicit action and resource
  @Delete(':id')
  @Authorize({ action: 'delete', resource: 'post' })
  async deletePost(@Param('id') id: string) {
    return { deleted: id }
  }
 
  // With scope
  @Post('admin')
  @Authorize({ action: 'manage', resource: 'post', scope: 'admin' })
  async adminAction() {
    return { success: true }
  }
 
  // Infer action from HTTP method, resource from route path
  @Get()
  @Authorize()  // infer: true by default
  async listPosts() {
    return []
  }
}

When @Authorize() uses infer: true (the default), action is inferred from the HTTP method via METHOD_ACTION_MAP and resource is inferred from the route path (last non-parameter segment).

If no @Authorize decorator is present, the guard allows the request through.

AuthorizeMeta interface:

interface AuthorizeMeta {
  action?: string       // explicit action
  resource?: string     // explicit resource type
  scope?: string        // explicit scope
  infer?: boolean       // infer from HTTP method + route (default: true)
}
interface AuthorizeMeta {
  action?: string       // explicit action
  resource?: string     // explicit resource type
  scope?: string        // explicit scope
  infer?: boolean       // infer from HTTP method + route (default: true)
}

Type-safe decorator

src/access/authorize.ts
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
 
type AppAction = 'read' | 'create' | 'update' | 'delete' | 'manage'
type AppResource = 'post' | 'comment' | 'user' | 'dashboard'
type AppScope = 'acme' | 'globex'
 
export const Authorize = createTypedAuthorize<AppAction, AppResource, AppScope>()
 
// Now typos are compile errors:
// @Authorize({ action: 'delet', resource: 'post' })  // TypeScript error
src/access/authorize.ts
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
 
type AppAction = 'read' | 'create' | 'update' | 'delete' | 'manage'
type AppResource = 'post' | 'comment' | 'user' | 'dashboard'
type AppScope = 'acme' | 'globex'
 
export const Authorize = createTypedAuthorize<AppAction, AppResource, AppScope>()
 
// Now typos are compile errors:
// @Authorize({ action: 'delet', resource: 'post' })  // TypeScript error

Next.js (App Router)

Protect API route handlers

app/api/posts/[id]/route.ts
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'  // mode: 'production' recommended
 
export const DELETE = withAccess(engine, 'delete', 'post',
  async (req, { params }) => {
    const { id } = await params
    return Response.json({ deleted: id })
  },
  {
    getUserId: (req) => req.headers.get('x-user-id'),
    scope: 'acme',
  }
)
app/api/posts/[id]/route.ts
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'  // mode: 'production' recommended
 
export const DELETE = withAccess(engine, 'delete', 'post',
  async (req, { params }) => {
    const { id } = await params
    return Response.json({ deleted: id })
  },
  {
    getUserId: (req) => req.headers.get('x-user-id'),
    scope: 'acme',
  }
)

WithAccessOptions:

interface WithAccessOptions {
  getUserId?: (req: Request) => string | null | Promise<string | null>
  getEnvironment?: (req: Request) => Environment
  scope?: string
  onError?: (err: Error, req: Request) => Response
}
interface WithAccessOptions {
  getUserId?: (req: Request) => string | null | Promise<string | null>
  getEnvironment?: (req: Request) => Environment
  scope?: string
  onError?: (err: Error, req: Request) => Response
}

getUserId can be async, useful for reading from cookies or JWT.

Check permissions in Server Components

app/posts/[id]/page.tsx
import { checkAccess, getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
export default async function PostPage({ params }) {
  const { id } = await params
  const userId = 'alice'  // from your auth
 
  // Single check
  const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
 
  // Batch check for the client
  const permissions = await getPermissions(engine, userId, [
    { action: 'update', resource: 'post', resourceId: id },
    { action: 'delete', resource: 'post', resourceId: id },
    { action: 'create', resource: 'comment' },
  ])
 
  return (
    <div>
      <h1>Post {id}</h1>
      {canDelete && <button>Delete</button>}
      <ClientToolbar permissions={permissions} />
    </div>
  )
}
app/posts/[id]/page.tsx
import { checkAccess, getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
export default async function PostPage({ params }) {
  const { id } = await params
  const userId = 'alice'  // from your auth
 
  // Single check
  const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
 
  // Batch check for the client
  const permissions = await getPermissions(engine, userId, [
    { action: 'update', resource: 'post', resourceId: id },
    { action: 'delete', resource: 'post', resourceId: id },
    { action: 'create', resource: 'comment' },
  ])
 
  return (
    <div>
      <h1>Post {id}</h1>
      {canDelete && <button>Delete</button>}
      <ClientToolbar permissions={permissions} />
    </div>
  )
}

checkAccess() returns a boolean. getPermissions() returns a PermissionMap for client-side hydration.

Next.js middleware for route-level protection

middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
const checkAccess = createNextMiddleware(engine, {
  rules: [
    { pattern: '/api/admin', resource: 'dashboard', action: 'manage' },
    { pattern: /^\/api\/posts\/\w+$/, resource: 'post' },
    { pattern: '/api/users', resource: 'user', scope: 'admin' },
  ],
  getUserId: (req) => req.headers.get('x-user-id'),
})
 
export async function middleware(req: Request) {
  const result = await checkAccess(req)
  if (result) return result  // 401 or 403
  // No match or allowed -- continue
}
 
export const config = {
  matcher: ['/api/:path*'],
}
middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
const checkAccess = createNextMiddleware(engine, {
  rules: [
    { pattern: '/api/admin', resource: 'dashboard', action: 'manage' },
    { pattern: /^\/api\/posts\/\w+$/, resource: 'post' },
    { pattern: '/api/users', resource: 'user', scope: 'admin' },
  ],
  getUserId: (req) => req.headers.get('x-user-id'),
})
 
export async function middleware(req: Request) {
  const result = await checkAccess(req)
  if (result) return result  // 401 or 403
  // No match or allowed -- continue
}
 
export const config = {
  matcher: ['/api/:path*'],
}

Rule matching:

  • String patterns use path.startsWith(pattern), so /api/admin matches /api/admin/users
  • Regex patterns use pattern.test(path) for precise matching
  • First matching rule wins (order matters)
  • No matching rule passes the request through
  • Missing action is inferred from the HTTP method

Rule interface:

interface Rule {
  pattern: string | RegExp  // URL path pattern
  resource: string          // resource type
  action?: string           // optional: explicit action (otherwise inferred)
  scope?: string            // optional: scope for this route
}
interface Rule {
  pattern: string | RegExp  // URL path pattern
  resource: string          // resource type
  action?: string           // optional: explicit action (otherwise inferred)
  scope?: string            // optional: scope for this route
}

Custom Error Responses

All integrations support custom error handlers:

// Express
accessMiddleware(engine, {
  onDenied: (req, res) => {
    res.status(403).json({
      error: 'forbidden',
      message: `You cannot ${req.method.toLowerCase()} this resource`,
    })
  },
  onError: (err, req, res, next) => {
    console.error('Auth error:', err)
    res.status(500).json({ error: 'internal' })
  },
})
 
// Hono
accessMiddleware(engine, {
  onDenied: (c) => c.json({ error: 'forbidden' }, 403),
  onError: (err, c) => c.json({ error: 'internal' }, 500),
})
 
// NestJS (via guard options)
nestAccessGuard(engine, {
  onError: (err, req) => {
    console.error('Auth error:', err)
    return false  // deny on error
  },
})
 
// Next.js
withAccess(engine, 'delete', 'post', handler, {
  onError: (err, req) => Response.json({ error: 'internal' }, { status: 500 }),
})
// Express
accessMiddleware(engine, {
  onDenied: (req, res) => {
    res.status(403).json({
      error: 'forbidden',
      message: `You cannot ${req.method.toLowerCase()} this resource`,
    })
  },
  onError: (err, req, res, next) => {
    console.error('Auth error:', err)
    res.status(500).json({ error: 'internal' })
  },
})
 
// Hono
accessMiddleware(engine, {
  onDenied: (c) => c.json({ error: 'forbidden' }, 403),
  onError: (err, c) => c.json({ error: 'internal' }, 500),
})
 
// NestJS (via guard options)
nestAccessGuard(engine, {
  onError: (err, req) => {
    console.error('Auth error:', err)
    return false  // deny on error
  },
})
 
// Next.js
withAccess(engine, 'delete', 'post', handler, {
  onError: (err, req) => Response.json({ error: 'internal' }, { status: 500 }),
})

Default error responses:

  • 401 Unauthorized: no user ID found
  • 403 Forbidden: permission denied
  • 500 Internal Server Error: unexpected error

Permissions Endpoint Pattern

A common pattern is a dedicated endpoint that returns permissions for the client:

Express example
app.get('/api/permissions', async (req, res) => {
  const userId = req.headers['x-user-id'] as string
  if (!userId) return res.status(401).json({ error: 'unauthorized' })
 
  const scope = req.headers['x-organization'] as string
 
  const permissions = await generatePermissionMap(engine, userId, [
    { action: 'create', resource: 'post', scope },
    { action: 'update', resource: 'post', scope },
    { action: 'delete', resource: 'post', scope },
    { action: 'manage', resource: 'dashboard', scope },
    { action: 'manage', resource: 'user', scope },
  ])
 
  res.json(permissions)
})
Express example
app.get('/api/permissions', async (req, res) => {
  const userId = req.headers['x-user-id'] as string
  if (!userId) return res.status(401).json({ error: 'unauthorized' })
 
  const scope = req.headers['x-organization'] as string
 
  const permissions = await generatePermissionMap(engine, userId, [
    { action: 'create', resource: 'post', scope },
    { action: 'update', resource: 'post', scope },
    { action: 'delete', resource: 'post', scope },
    { action: 'manage', resource: 'dashboard', scope },
    { action: 'manage', resource: 'user', scope },
  ])
 
  res.json(permissions)
})

The client calls this endpoint and passes the result to AccessProvider (Chapter 7).


Chapter 6 FAQ


Next: Chapter 7: Client Libraries