Skip to content

Fastify Integration

Fire Shield provides plugins and hooks for Fastify applications to protect routes with RBAC.

Installation

bash
npm install @fire-shield/fastify@3.1.1 @fire-shield/core@3.1.1 fastify

Setup

Basic Setup

typescript
import Fastify from 'fastify'
import { RBAC } from '@fire-shield/core'
import { FastifyRBACAdapter, fastifyRBACPlugin } from '@fire-shield/fastify'

const fastify = Fastify()
const rbac = new RBAC()

// Define roles
rbac.createRole('admin', ['posts:*', 'users:*'])
rbac.createRole('editor', ['posts:read', 'posts:write'])
rbac.createRole('viewer', ['posts:read'])

// Create RBAC adapter
const rbacHooks = new FastifyRBACAdapter(rbac, {
  getUser: (request) => request.user
})

// Optionally register as a Fastify plugin (decorates fastify.rbac and fastify.authorize)
fastify.register(fastifyRBACPlugin(rbac))

Route Protection

adapter.permission

Protect routes with a permission requirement:

typescript
// Single permission
fastify.post('/posts', {
  preHandler: rbacHooks.permission('posts:write')
}, async (request, reply) => {
  return { message: 'Post created' }
})

adapter.any / adapter.all

Require any or all of a set of permissions:

typescript
// Require at least one permission
fastify.get('/admin', {
  preHandler: rbacHooks.any('admin:access', 'moderator:access')
}, async (request, reply) => {
  return { message: 'Admin panel' }
})

// Require all permissions
fastify.delete('/posts/:id', {
  preHandler: rbacHooks.all('posts:delete', 'posts:own')
}, async (request, reply) => {
  return { message: 'Post deleted' }
})

requirePermission

Use the standalone function when you need a one-off hook without a shared adapter:

typescript
import { requirePermission } from '@fire-shield/fastify'

fastify.post('/posts', {
  preHandler: requirePermission('posts:write', { rbac })
}, async (request, reply) => {
  return { message: 'Post created' }
})

requireRole

Protect routes with role requirements:

typescript
import { requireRole } from '@fire-shield/fastify'

// Single role
fastify.get('/admin/users', {
  preHandler: requireRole('admin', { rbac })
}, async (request, reply) => {
  return { users: [] }
})

adapter.role

typescript
fastify.get('/admin/users', {
  preHandler: rbacHooks.role('admin')
}, async (request, reply) => {
  return { users: [] }
})

requireResourceAction

Check permissions by resource and action:

typescript
import { requireResourceAction } from '@fire-shield/fastify'

fastify.delete('/users/:id', {
  preHandler: requireResourceAction('users', 'delete', { rbac })
}, async (request, reply) => {
  return { message: 'User deleted' }
})

adapter.denyPermission / adapter.allowPermission

Hooks that explicitly deny or restore a permission for the request user:

typescript
// Deny a permission for the user making this request
fastify.post('/restrict', {
  preHandler: rbacHooks.denyPermission('posts:delete')
}, async (request, reply) => {
  return { message: 'posts:delete has been denied' }
})

// Remove a previously denied permission
fastify.post('/restore', {
  preHandler: rbacHooks.allowPermission('posts:delete')
}, async (request, reply) => {
  return { message: 'posts:delete has been restored' }
})

adapter.notDenied

Hook that blocks the request if the permission is explicitly denied (even if the role grants it):

typescript
fastify.delete('/posts/:id', {
  preHandler: [
    rbacHooks.notDenied('posts:delete'),
    rbacHooks.permission('posts:delete')
  ]
}, async (request, reply) => {
  return { message: 'Post deleted' }
})

Standalone denyPermission / allowPermission / requireNotDenied

Use without an adapter when you need one-off hooks:

typescript
import { denyPermission, allowPermission, requireNotDenied } from '@fire-shield/fastify'

fastify.post('/restrict', {
  preHandler: denyPermission('posts:delete', { rbac })
}, async (request, reply) => {
  return { message: 'Permission denied' }
})

adapter.getDeniedPermissions / adapter.isDenied

Programmatic checks inside route handlers:

typescript
fastify.get('/my-permissions', async (request, reply) => {
  const denied = rbacHooks.getDeniedPermissions(request)
  const cannotDelete = rbacHooks.isDenied(request, 'posts:delete')
  return { denied, cannotDelete }
})

Custom Error Handling

Default Behavior

By default, Fire Shield throws a Fastify error with 403 Forbidden:

json
{
  "statusCode": 403,
  "error": "Forbidden",
  "message": "Insufficient permissions"
}

Custom Error Handler

Customize the error response:

typescript
const rbacHooks = new FastifyRBACAdapter(rbac, {
  getUser: (request) => request.user,
  onUnauthorized: (result, request, reply) => {
    reply.code(403).send({
      error: 'Access Denied',
      message: result.reason,
      requiredPermission: result.reason
    })
  }
})

Error Hook

Use Fastify error hooks:

typescript
fastify.setErrorHandler((error, request, reply) => {
  if (error.statusCode === 403) {
    request.log.warn('RBAC access denied', {
      user: request.user?.id,
      path: request.url
    })
  }

  reply.send(error)
})

Authentication Integration

With JWT

typescript
import fastifyJwt from '@fastify/jwt'

// Register JWT plugin
fastify.register(fastifyJwt, {
  secret: 'your-secret-key'
})

// Authentication decorator
fastify.decorate('authenticate', async (request, reply) => {
  try {
    await request.jwtVerify()
  } catch (err) {
    reply.send(err)
  }
})

// Use authentication and RBAC together
fastify.post('/posts', {
  preHandler: [
    fastify.authenticate,
    rbacHooks.permission('posts:write')
  ]
}, async (request, reply) => {
  return { message: 'Post created' }
})

With Session

typescript
import fastifySession from '@fastify/session'
import fastifyCookie from '@fastify/cookie'

fastify.register(fastifyCookie)
fastify.register(fastifySession, {
  secret: 'a-secret-with-minimum-length-of-32-characters',
  cookie: { secure: false }
})

// Attach user from session
fastify.addHook('preHandler', async (request, reply) => {
  if (request.session.userId) {
    request.user = await getUserById(request.session.userId)
  }
})

// Protected route
fastify.delete('/posts/:id', {
  preHandler: rbacHooks.permission('posts:delete')
}, async (request, reply) => {
  return { message: 'Post deleted' }
})

Programmatic Permission Checks

Access the authorization result in route handlers via request.authorization:

typescript
fastify.get('/posts/:id', async (request, reply) => {
  const post = await getPost(request.params.id)

  // request.authorization is set by the RBAC hook after a successful check
  const authorized = (request as any).authorization?.allowed

  return post
})

Route Organization

Grouped Routes

typescript
// Admin routes plugin
async function adminRoutes(fastify, options) {
  // All admin routes require admin role
  fastify.addHook('preHandler', rbacHooks.role('admin'))

  fastify.get('/users', async (request, reply) => {
    return { users: [] }
  })

  fastify.post('/users', async (request, reply) => {
    return { message: 'User created' }
  })
}

fastify.register(adminRoutes, { prefix: '/admin' })

Nested Permissions

typescript
// Posts routes plugin
async function postsRoutes(fastify, options) {
  // All routes require read permission
  fastify.addHook('preHandler', rbacHooks.permission('posts:read'))

  fastify.get('/', async (request, reply) => {
    return { posts: [] }
  })

  // Additional permission for write
  fastify.post('/', {
    preHandler: rbacHooks.permission('posts:write')
  }, async (request, reply) => {
    return { message: 'Post created' }
  })

  // Additional permission for delete
  fastify.delete('/:id', {
    preHandler: rbacHooks.permission('posts:delete')
  }, async (request, reply) => {
    return { message: 'Post deleted' }
  })
}

fastify.register(postsRoutes, { prefix: '/posts' })

Dynamic Permissions

Check permissions based on resource ownership:

typescript
fastify.delete('/posts/:id',
  { preHandler: rbacHooks.permission('posts:delete') },
  async (request, reply) => {
    const post = await getPost(request.params.id)

    // Check if user owns the post
    const isOwner = post.authorId === request.user.id

    if (!isOwner) {
      return reply.code(403).send({ error: 'Forbidden' })
    }

    await deletePost(request.params.id)
    return { message: 'Post deleted' }
  }
)

TypeScript Support

Full TypeScript support with type definitions:

typescript
import { FastifyRequest, FastifyReply } from 'fastify'
import { RBACUser } from '@fire-shield/core'

// Fastify Request is already extended by @fire-shield/fastify:
// request.user?: RBACUser

// Type-safe route handler
fastify.get<{
  Params: { id: string }
}>('/posts/:id', async (request, reply) => {
  return { id: request.params.id }
})

Best Practices

1. Use Hooks for Route Groups

typescript
// ✅ Good: Apply to all routes in plugin
async function adminRoutes(fastify) {
  fastify.addHook('preHandler', rbacHooks.role('admin'))

  fastify.get('/users', getUsers)
  fastify.post('/users', createUser)
  fastify.delete('/users/:id', deleteUser)
}

// ❌ Avoid: Repeat on every route
fastify.get('/admin/users', {
  preHandler: rbacHooks.role('admin')
}, getUsers)
fastify.post('/admin/users', {
  preHandler: rbacHooks.role('admin')
}, createUser)

2. Chain Handlers

typescript
// ✅ Good: Multiple handlers in array
fastify.post('/posts', {
  preHandler: [
    fastify.authenticate,
    rbacHooks.permission('posts:write'),
    validatePostData
  ]
}, createPost)

// ✅ Also good: Single combined handler
fastify.post('/posts', {
  preHandler: rbacHooks.permission('posts:write')
}, createPost)

3. Handle Errors Properly

typescript
// ✅ Good: Custom error handling
const rbacHooks = new FastifyRBACAdapter(rbac, {
  getUser: (request) => request.user,
  onUnauthorized: (result, request, reply) => {
    request.log.warn({
      path: request.url,
      user: request.user?.id,
      reason: result.reason
    }, 'RBAC access denied')

    reply.code(403).send({
      error: 'Forbidden',
      message: 'Insufficient permissions'
    })
  }
})

4. Document Routes

typescript
fastify.post('/posts', {
  schema: {
    description: 'Create a new post',
    tags: ['posts'],
    summary: 'Create post',
    security: [{ bearerAuth: [] }]
  },
  preHandler: rbacHooks.permission('posts:write')
}, async (request, reply) => {
  return { message: 'Post created' }
})

OpenAPI/Swagger Integration

Document permission requirements in OpenAPI:

typescript
import fastifySwagger from '@fastify/swagger'
import fastifySwaggerUI from '@fastify/swagger-ui'

fastify.register(fastifySwagger, {
  openapi: {
    info: {
      title: 'API Documentation',
      version: '1.0.0'
    },
    components: {
      securitySchemes: {
        bearerAuth: {
          type: 'http',
          scheme: 'bearer',
          bearerFormat: 'JWT'
        }
      }
    }
  }
})

fastify.register(fastifySwaggerUI, {
  routePrefix: '/documentation'
})

// Document permission requirements
fastify.post('/posts', {
  schema: {
    description: 'Create a new post (requires posts:write permission)',
    tags: ['posts'],
    security: [{ bearerAuth: [] }],
    response: {
      200: {
        type: 'object',
        properties: {
          message: { type: 'string' }
        }
      },
      403: {
        type: 'object',
        properties: {
          error: { type: 'string' }
        }
      }
    }
  },
  preHandler: rbacHooks.permission('posts:write')
}, async (request, reply) => {
  return { message: 'Post created' }
})

Complete Example

typescript
import Fastify from 'fastify'
import { RBAC } from '@fire-shield/core'
import { FastifyRBACAdapter, fastifyRBACPlugin } from '@fire-shield/fastify'
import fastifyJwt from '@fastify/jwt'

const fastify = Fastify({
  logger: true
})

const rbac = new RBAC()

// Setup roles
rbac.createRole('admin', ['*'])
rbac.createRole('editor', ['posts:*', 'comments:*'])
rbac.createRole('viewer', ['posts:read', 'comments:read'])

// JWT plugin
fastify.register(fastifyJwt, {
  secret: 'your-secret-key'
})

// RBAC adapter
const rbacHooks = new FastifyRBACAdapter(rbac, {
  getUser: (request) => request.user,
  onUnauthorized: (result, request, reply) => {
    reply.code(403).send({
      error: 'Forbidden',
      message: result.reason || 'Insufficient permissions'
    })
  }
})

// Register RBAC plugin (optional, adds fastify.rbac and fastify.authorize decorators)
fastify.register(fastifyRBACPlugin(rbac))

// Authentication decorator
fastify.decorate('authenticate', async (request, reply) => {
  try {
    await request.jwtVerify()
  } catch (err) {
    reply.send(err)
  }
})

// Public route
fastify.get('/health', async () => {
  return { status: 'ok' }
})

// Protected routes
fastify.get('/posts', {
  preHandler: [
    fastify.authenticate,
    rbacHooks.permission('posts:read')
  ]
}, async () => {
  return { posts: [] }
})

fastify.post('/posts', {
  preHandler: [
    fastify.authenticate,
    rbacHooks.permission('posts:write')
  ]
}, async () => {
  return { message: 'Post created' }
})

fastify.delete('/posts/:id', {
  preHandler: [
    fastify.authenticate,
    rbacHooks.permission('posts:delete')
  ]
}, async () => {
  return { message: 'Post deleted' }
})

// Admin routes
async function adminRoutes(fastify) {
  fastify.addHook('preHandler', rbacHooks.role('admin'))

  fastify.get('/users', async () => {
    return { users: [] }
  })

  fastify.post('/users', async () => {
    return { message: 'User created' }
  })
}

fastify.register(adminRoutes, { prefix: '/admin' })

fastify.listen({ port: 3000 }, (err) => {
  if (err) throw err
  console.log('Server running on http://localhost:3000')
})

Next Steps