Usage Guide

A comprehensive guide to using ExpressRoleManager for role-based access control in Express.js applications.

Introduction

The ExpressRoleManager is a powerful tool for implementing role-based access control (RBAC) in Express.js applications. It integrates seamlessly with the @iamjs/core package to provide robust authorization capabilities. This guide will walk you through setting up, configuring, and using the ExpressRoleManager in various scenarios.

Basic Setup

To get started with ExpressRoleManager, you need to set up your roles, create a schema, and initialize the manager.

import express from 'express';
import { Role, Schema } from '@iamjs/core';
import { ExpressRoleManager } from '@iamjs/express';
 
// Define roles
const userRole = new Role({
  name: 'user',
  config: {
    posts: {
      base: 'cr--l',
      custom: {
        like: true,
        comment: true
      }
    },
    profile: {
      base: 'crud-',
      custom: {
        changePassword: true
      }
    }
  }
});
 
const adminRole = new Role({
  name: 'admin',
  config: {
    posts: {
      base: 'crudl',
      custom: {
        pin: true,
        delete: true
      }
    },
    users: {
      base: 'crudl',
      custom: {
        ban: true
      }
    }
  }
});
 
// Create schema
const schema = new Schema({
  roles: { user: userRole, admin: adminRole }
});
 
// Initialize ExpressRoleManager
const roleManager = new ExpressRoleManager({
  schema,
  onError: (err, req, res, next) => {
    res.status(403).json({ error: 'Access denied' });
  },
  onSuccess: (req, res, next) => {
    next();
  }
});
 
const app = express();
 
// Use the role manager in a route
app.get('/posts',
  roleManager.check({
    resources: 'posts',
    actions: ['read', 'list'],
    role: 'user'
  }),
  (req, res) => {
    res.json({ posts: ['Post 1', 'Post 2'] });
  }
);
 
app.listen(3000, () => console.log('Server running on port 3000'));

This basic setup demonstrates how to define roles, create a schema, initialize the ExpressRoleManager, and use it in a route.

Advanced Usage

For more complex scenarios, you might need to dynamically determine the role or perform additional checks.

import express from 'express';
import { Role, Schema } from '@iamjs/core';
import { ExpressRoleManager } from '@iamjs/express';
 
// ... (previous role and schema setup)
 
const app = express();
 
// Middleware to simulate user authentication
const auth = async (req, res, next) => {
  // In a real app, you'd verify a token or session here
  req.user = { id: 1, role: 'user' };
  next();
};
 
// Middleware to fetch user permissions
const fetchUserPermissions = async (req, res, next) => {
  // In a real app, you might fetch this from a database
  req.permissions = userRole.toObject();
  next();
};
 
const roleManager = new ExpressRoleManager({
  schema,
  onError: (err, req, res, next) => {
    res.status(403).json({ error: 'Access denied' });
  },
  onSuccess: (req, res, next) => {
    next();
  }
});
 
app.post('/posts',
  auth,
  fetchUserPermissions,
  roleManager.check({
    resources: 'posts',
    actions: ['create'],
    strict: true,
    construct: true,
    data: async (req) => req.permissions
  }),
  (req, res) => {
    res.json({ message: 'Post created successfully' });
  }
);
 
app.delete('/posts/:id',
  auth,
  fetchUserPermissions,
  roleManager.check({
    resources: 'posts',
    actions: ['delete'],
    strict: true,
    construct: true,
    data: async (req) => req.permissions
  }),
  (req, res) => {
    res.json({ message: 'Post deleted successfully' });
  }
);
 
app.listen(3000, () => console.log('Server running on port 3000'));

This advanced example shows how to use middleware for authentication and fetching user permissions, and how to use the construct and data options for dynamic role checking.

Success and Error Handling

Customizing success and error handling allows you to tailor the behavior of your application based on authorization results.

const roleManager = new ExpressRoleManager({
  schema,
  onError: (err, req, res, next) => {
    console.error('Authorization error:', err);
    res.status(403).json({
      error: 'Access denied',
      details: err.message
    });
  },
  onSuccess: (req, res, next) => {
    console.log('Authorization successful');
    // You can add custom headers or modify the request object here
    req.authorized = true;
    next();
  }
});

Logging User Activity

The onActivity handler allows you to log or process user activities, which can be useful for auditing or analytics.

const roleManager = new ExpressRoleManager({
  schema,
  onError: (err, req, res, next) => { /* ... */ },
  onSuccess: (req, res, next) => { /* ... */ },
  async onActivity(data) {
    console.log('User activity:', {
      timestamp: new Date(),
      userId: data.req.user.id,
      role: data.role,
      resources: data.resources,
      actions: data.actions,
      success: data.success
    });
    // In a real app, you might save this to a database or send to a logging service
  }
});

TypeScript Support

ExpressRoleManager provides full TypeScript support, allowing you to specify types for request and response objects. You can also extend the Express Request type globally for a more streamlined development experience.

Local Type Extensions

You can create custom interfaces that extend the Express Request type for local use:

import { Request, Response, NextFunction } from 'express';
import { Role, Schema } from '@iamjs/core';
import { ExpressRoleManager } from '@iamjs/express';
 
interface CustomRequest extends Request {
  user?: { id: number; role: string };
  permissions?: any;
}
 
const roleManager = new ExpressRoleManager({
  schema,
  onSuccess: <CustomRequest, Response>(req, res, next) => {
    console.log('Authorized user:', req.user?.id);
    next();
  },
  onError: <CustomRequest, Response>(err, req, res, next) => {
    console.error('Authorization failed for user:', req.user?.id);
    res.status(403).json({ error: 'Access denied' });
  },
});
 
app.get('/protected',
  roleManager.check<CustomRequest, Response>({
    resources: 'protectedResource',
    actions: ['read'],
    construct: true,
    data: async (req) => req.permissions
  }),
  (req: CustomRequest, res: Response) => {
    res.json({ message: 'Access granted', userId: req.user?.id });
  }
);

Global Type Extensions

For a more global approach, you can extend the Express Request type for your entire project. This is particularly useful when you want to add custom properties to the Request object across multiple files.

Create a file named types/express/index.d.ts in your project and add the following code:

import express from 'express';
 
declare global {
  namespace Express {
    interface Request {
      user?: { id: number; role: string };
      permissions?: any;
    }
  }
}

After adding this global declaration, you can use the extended Request type throughout your project without explicitly importing or extending it in each file:

import { Request, Response } from 'express';
import { ExpressRoleManager } from '@iamjs/express';
 
const roleManager = new ExpressRoleManager({
  schema,
  onSuccess: (req: Request, res: Response, next) => {
    console.log('Authorized user:', req.user?.id); // TypeScript now recognizes req.user
    next();
  },
  // ... rest of the configuration
});
 
app.get('/protected',
  roleManager.check({
    resources: 'protectedResource',
    actions: ['read'],
    construct: true,
    data: async (req) => req.permissions // TypeScript recognizes req.permissions
  }),
  (req: Request, res: Response) => {
    res.json({ message: 'Access granted', userId: req.user?.id });
  }
);

For more information on declaration merging and global augmentation in TypeScript, refer to the official TypeScript documentation on Declaration Merging. By using these TypeScript features, you can ensure type safety throughout your Express application while using ExpressRoleManager, leading to more robust and maintainable code.

Headless Checks without Express Middleware Context

The checkFn method allows you to perform authorization checks without the Express middleware context. This is particularly useful for:

  • Background jobs
  • API-based applications
  • Serverless functions
  • Any scenario where you need programmatic access control

Basic Usage

import { ExpressRoleManager, Schema, Role } from '@iamjs/express';
 
// Define roles and schema
const userRole = new Role({
  name: 'user',
  config: {
    documents: {
      base: 'r--l',
      custom: {
        download: true
      }
    }
  }
});
 
const adminRole = new Role({
  name: 'admin',
  config: {
    documents: {
      base: 'crudl',
      custom: {
        delete: true
      }
    }
  }
});
 
const schema = new Schema({
  roles: { user: userRole, admin: adminRole }
});
 
// Initialize ExpressRoleManager
const roleManager = new ExpressRoleManager({ schema });
 
// Headless check function
async function canAccess(userId: string, resource: string, action: string): Promise<boolean> {
  const userRole = await getUserRole(userId); // Implement this function to fetch user's role
  
  return await roleManager.checkFn({
    role: userRole,
    resources: resource,
    actions: action
  });
}
 
// Usage in a background job
async function processDocuments() {
  const documents = await fetchDocumentsToProcess();
  for (const doc of documents) {
    if (await canAccess(doc.ownerId, 'documents', 'read')) {
      await processDocument(doc);
    } else {
      console.log(`Access denied for document ${doc.id}`);
    }
  }
}

With Dynamic Role Construction

import { ExpressRoleManager, Schema } from '@iamjs/express';
 
const schema = new Schema({ /* ... */ });
const roleManager = new ExpressRoleManager({ schema });
 
async function checkDynamicPermission(userId: string, resource: string, action: string): Promise<boolean> {
  const userPermissions = await fetchUserPermissions(userId);
  
  return await roleManager.checkFn({
    resources: resource,
    actions: action,
    construct: true,
    data: userPermissions
  });
}
 
// Usage in an API endpoint
app.get('/api/documents/:id', async (req, res) => {
  const canAccess = await checkDynamicPermission(req.user.id, 'documents', 'read');
  
  if (canAccess) {
    const document = await fetchDocument(req.params.id);
    res.json(document);
  } else {
    res.status(403).json({ error: 'Access denied' });
  }
});

In Serverless Functions

import { ExpressRoleManager, Schema, Role } from '@iamjs/express';
 
const schema = new Schema({ /* ... */ });
const roleManager = new ExpressRoleManager({ schema });
 
export async function handleDocumentAccess(event, context) {
  const { userId, action } = JSON.parse(event.body);
  
  const userRole = await getUserRoleFromDatabase(userId);
  
  const canAccess = await roleManager.checkFn({
    role: userRole,
    resources: 'documents',
    actions: action
  });
  
  if (canAccess) {
    // Perform the action
    return {
      statusCode: 200,
      body: JSON.stringify({ message: 'Access granted' })
    };
  } else {
    return {
      statusCode: 403,
      body: JSON.stringify({ error: 'Access denied' })
    };
  }
}

Batch Operations

async function batchProcessDocuments(userIds: string[], action: string) {
  const results = await Promise.all(
    userIds.map(async (userId) => {
      const userRole = await getUserRole(userId);
      const canAccess = await roleManager.checkFn({
        role: userRole,
        resources: 'documents',
        actions: action
      });
      return { userId, canAccess };
    })
  );
 
  return results.filter(result => result.canAccess).map(result => result.userId);
}
 
// Usage
const userIdsWithAccess = await batchProcessDocuments(['user1', 'user2', 'user3'], 'read');
console.log('Users with read access:', userIdsWithAccess);

These examples demonstrate how checkFn can be used in various "headless" scenarios, providing flexible and powerful authorization checks outside of the typical Express middleware flow. This approach allows you to implement consistent access control across different parts of your application, from web routes to background jobs and serverless functions.

Best Practices and Tips

  1. Granular Permissions: Define roles with specific, granular permissions to allow for flexible access control.

  2. Dynamic Role Assignment: Use the construct and data options to dynamically assign roles based on user data or other factors.

  3. Error Handling: Provide clear, informative error messages in your onError handler to aid in debugging and improve user experience.

  4. Logging and Auditing: Utilize the onActivity handler to maintain a comprehensive audit trail of authorization attempts and successes.

  5. Performance Optimization: If you're dealing with a large number of roles or complex permission structures, consider caching role data or authorization results.

  6. Security Considerations: Always validate and sanitize user input, especially when constructing roles dynamically.

  7. Testing: Implement thorough unit and integration tests for your authorization logic, covering various role and permission scenarios.