Skip to content

Express Integration

Fire Shield provides middleware for Express applications to protect routes with RBAC.

Installation

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

Setup

Basic Setup

typescript
import express from 'express'
import { RBAC } from '@fire-shield/core'
import { ExpressRBACAdapter } from '@fire-shield/express'

const app = express()
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 rbacMiddleware = new ExpressRBACAdapter(rbac, {
  getUser: (req) => req.user // Get user from request
})

Middleware

requirePermission

Protect routes with a permission requirement:

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

// Require single permission
app.post('/posts',
  requirePermission('posts:write', { rbac }),
  (req, res) => {
    res.json({ message: 'Post created' })
  }
)

app.delete('/posts/:id',
  requirePermission('posts:delete', { rbac }),
  (req, res) => {
    res.json({ message: 'Post deleted' })
  }
)

adapter.permission

Use the adapter instance to check a permission (picks up shared options automatically):

typescript
app.post('/posts',
  rbacMiddleware.permission('posts:write'),
  (req, res) => {
    res.json({ message: 'Post created' })
  }
)

adapter.any / adapter.all

Require any or all of a set of permissions:

typescript
// Require at least one permission
app.get('/admin',
  rbacMiddleware.any('admin:access', 'moderator:access'),
  (req, res) => {
    res.json({ message: 'Admin panel' })
  }
)

// Require all permissions
app.delete('/posts/:id',
  rbacMiddleware.all('posts:delete', 'posts:own'),
  (req, res) => {
    res.json({ message: 'Post deleted' })
  }
)

requireRole

Protect routes with role requirements:

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

// Require single role
app.get('/admin/users',
  requireRole('admin', { rbac }),
  (req, res) => {
    res.json({ users: [] })
  }
)

adapter.role

typescript
app.get('/admin/users',
  rbacMiddleware.role('admin'),
  (req, res) => {
    res.json({ users: [] })
  }
)

requireResourceAction

Check permissions by resource and action:

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

app.delete('/users/:id',
  requireResourceAction('users', 'delete', { rbac }),
  (req, res) => {
    res.json({ message: 'User deleted' })
  }
)

denyPermission

Middleware that explicitly denies a permission for the request user (stores the denial in RBAC):

typescript
import { denyPermission } from '@fire-shield/express'

// Deny a permission for the user making this request
app.post('/restrict',
  denyPermission('posts:delete', { rbac }),
  (req, res) => {
    res.json({ message: 'posts:delete has been denied for your account' })
  }
)

allowPermission

Middleware that removes an explicit denial for a permission:

typescript
import { allowPermission } from '@fire-shield/express'

// Remove a previously denied permission
app.post('/restore',
  allowPermission('posts:delete', { rbac }),
  (req, res) => {
    res.json({ message: 'posts:delete has been restored' })
  }
)

requireNotDenied

Middleware that blocks the request if the permission is explicitly denied for the user:

typescript
import { requireNotDenied } from '@fire-shield/express'

// Block if permission is in the deny list (even if role grants it)
app.delete('/posts/:id',
  requireNotDenied('posts:delete', { rbac }),
  rbacMiddleware.permission('posts:delete'),
  (req, res) => {
    res.json({ message: 'Post deleted' })
  }
)

Custom Error Handling

Default Behavior

By default, Fire Shield returns 403 Forbidden when permission is denied:

typescript
// Default response for denied access
{
  error: 'Forbidden',
  message: 'Insufficient permissions'
}

Custom Error Handler

Customize the error response:

typescript
const rbacMiddleware = new ExpressRBACAdapter(rbac, {
  getUser: (req) => req.user,
  onUnauthorized: (result, req, res, next) => {
    res.status(403).json({
      error: 'Access Denied',
      message: result.reason,
      requiredPermission: result.reason
    })
  }
})

Error Middleware

Use Express error middleware:

typescript
app.use((err, req, res, next) => {
  if (err.name === 'RBACError') {
    return res.status(403).json({
      error: 'RBAC Error',
      message: err.message,
      permission: err.permission
    })
  }
  next(err)
})

Authentication Integration

With Passport.js

typescript
import passport from 'passport'

app.use(passport.initialize())

app.post('/posts',
  passport.authenticate('jwt', { session: false }),
  rbacMiddleware.permission('posts:write'),
  (req, res) => {
    // req.user is set by passport
    res.json({ message: 'Post created' })
  }
)

With Express Session

typescript
import session from 'express-session'

app.use(session({
  secret: 'your-secret',
  resave: false,
  saveUninitialized: true
}))

// Attach user to request from session
app.use((req, res, next) => {
  if (req.session.userId) {
    req.user = getUserById(req.session.userId)
  }
  next()
})

// Use RBAC middleware
app.post('/posts',
  rbacMiddleware.permission('posts:write'),
  (req, res) => {
    res.json({ message: 'Post created' })
  }
)

With JWT

typescript
import jwt from 'jsonwebtoken'

// JWT middleware
app.use((req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '')
  if (token) {
    try {
      req.user = jwt.verify(token, 'secret')
    } catch (err) {
      // Invalid token
    }
  }
  next()
})

// RBAC middleware
app.delete('/posts/:id',
  rbacMiddleware.permission('posts:delete'),
  (req, res) => {
    res.json({ message: 'Post deleted' })
  }
)

Programmatic Permission Checks

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

typescript
app.get('/posts/:id', (req, res) => {
  const post = getPost(req.params.id)

  // req.authorization is set by the RBAC middleware after a successful check
  const authorized = req.authorization?.allowed

  res.json(post)
})

Dynamic Permissions

Check permissions based on resource ownership:

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

app.delete('/posts/:id',
  requirePermission('posts:delete', { rbac }),
  async (req, res) => {
    const post = await getPost(req.params.id)

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

    if (!isOwner) {
      return res.status(403).json({ error: 'Forbidden' })
    }

    await deletePost(req.params.id)
    res.json({ message: 'Post deleted' })
  }
)

Route Organization

Grouped Routes

typescript
const adminRouter = express.Router()

// All admin routes require admin role
adminRouter.use(rbacMiddleware.role('admin'))

adminRouter.get('/users', (req, res) => {
  res.json({ users: [] })
})

adminRouter.post('/users', (req, res) => {
  res.json({ message: 'User created' })
})

app.use('/admin', adminRouter)

Nested Permissions

typescript
const postsRouter = express.Router()

// All routes require read permission
postsRouter.use(rbacMiddleware.permission('posts:read'))

postsRouter.get('/', (req, res) => {
  res.json({ posts: [] })
})

// Additional permission for write
postsRouter.post('/',
  rbacMiddleware.permission('posts:write'),
  (req, res) => {
    res.json({ message: 'Post created' })
  }
)

// Additional permission for delete
postsRouter.delete('/:id',
  rbacMiddleware.permission('posts:delete'),
  (req, res) => {
    res.json({ message: 'Post deleted' })
  }
)

app.use('/posts', postsRouter)

TypeScript Support

Full TypeScript support with type definitions:

typescript
import { Request, Response, NextFunction } from 'express'
import { RBACUser, AuthorizationResult } from '@fire-shield/core'

// Express Request is already extended by @fire-shield/express:
// req.user?: RBACUser
// req.authorization?: AuthorizationResult

// Type-safe route handler
app.get('/posts', (req: Request, res: Response) => {
  if (req.authorization?.allowed) {
    // Access granted
  }
})

Best Practices

1. Protect All Sensitive Routes

typescript
// ✅ Good: Protected routes
app.post('/posts',
  rbacMiddleware.permission('posts:write'),
  createPost
)

app.delete('/posts/:id',
  rbacMiddleware.permission('posts:delete'),
  deletePost
)

// ❌ Avoid: Unprotected sensitive routes
app.delete('/posts/:id', deletePost)

2. Use Router-Level Middleware

typescript
// ✅ Good: Protect entire router
const adminRouter = express.Router()
adminRouter.use(rbacMiddleware.role('admin'))
adminRouter.get('/users', getUsers)
adminRouter.post('/users', createUser)

// ❌ Avoid: Repeat on every route
app.get('/admin/users',
  rbacMiddleware.role('admin'),
  getUsers
)
app.post('/admin/users',
  rbacMiddleware.role('admin'),
  createUser
)

3. Handle Errors Gracefully

typescript
// ✅ Good: Custom error handling
const rbacMiddleware = new ExpressRBACAdapter(rbac, {
  getUser: (req) => req.user,
  onUnauthorized: (result, req, res, next) => {
    console.warn(`Access denied to ${req.path}`, {
      user: req.user?.id,
      reason: result.reason
    })
    res.status(403).json({
      error: 'Forbidden',
      message: 'Insufficient permissions'
    })
  }
})

4. Document Permission Requirements

typescript
/**
 * Create a new post
 * @route POST /posts
 * @permission posts:write
 * @returns {Post} Created post
 */
app.post('/posts',
  rbacMiddleware.permission('posts:write'),
  createPost
)

Complete Example

typescript
import express from 'express'
import { RBAC } from '@fire-shield/core'
import { ExpressRBACAdapter } from '@fire-shield/express'

const app = express()
const rbac = new RBAC()

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

// RBAC adapter
const rbacMiddleware = new ExpressRBACAdapter(rbac, {
  getUser: (req) => req.user,
  onUnauthorized: (result, req, res, next) => {
    res.status(403).json({
      error: 'Forbidden',
      message: result.reason || 'Insufficient permissions'
    })
  }
})

app.use(express.json())

// Public routes
app.get('/health', (req, res) => {
  res.json({ status: 'ok' })
})

// Protected routes
app.get('/posts',
  rbacMiddleware.permission('posts:read'),
  (req, res) => {
    res.json({ posts: [] })
  }
)

app.post('/posts',
  rbacMiddleware.permission('posts:write'),
  (req, res) => {
    res.json({ message: 'Post created' })
  }
)

app.delete('/posts/:id',
  rbacMiddleware.permission('posts:delete'),
  (req, res) => {
    res.json({ message: 'Post deleted' })
  }
)

// Admin routes
const adminRouter = express.Router()
adminRouter.use(rbacMiddleware.role('admin'))

adminRouter.get('/users', (req, res) => {
  res.json({ users: [] })
})

adminRouter.post('/users', (req, res) => {
  res.json({ message: 'User created' })
})

app.use('/admin', adminRouter)

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000')
})

Next Steps