Fastify Integration
Fire Shield provides plugins and hooks for Fastify applications to protect routes with RBAC.
Installation
npm install @fire-shield/fastify@3.1.1 @fire-shield/core@3.1.1 fastifySetup
Basic Setup
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:
// 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:
// 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:
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:
import { requireRole } from '@fire-shield/fastify'
// Single role
fastify.get('/admin/users', {
preHandler: requireRole('admin', { rbac })
}, async (request, reply) => {
return { users: [] }
})adapter.role
fastify.get('/admin/users', {
preHandler: rbacHooks.role('admin')
}, async (request, reply) => {
return { users: [] }
})requireResourceAction
Check permissions by resource and action:
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:
// 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):
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:
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:
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:
{
"statusCode": 403,
"error": "Forbidden",
"message": "Insufficient permissions"
}Custom Error Handler
Customize the error response:
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:
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
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
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:
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
// 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
// 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:
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:
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
// ✅ 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
// ✅ 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
// ✅ 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
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:
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
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
- Explore Express Integration
- Learn about Permissions
- Check out API Reference
