This guide provides a step-by-step walkthrough for implementing Attribute-Based Access Control (ABAC) in a Node.js Express application using TypeScript.
RBAC vs. ABAC
Role-Based Access Control (RBAC) grants permissions based on a user's role (e.g., admin, editor), while Attribute-Based Access Control (ABAC) provides more granular control by evaluating a combination of attributes from the user, resource, and environment. This guide uses ABAC to determine access to services based on user roles, departments, and service visibility. Want to learn more about RBAC vs ABAC?
Step 1: Project setup
First, set up a new Node.js project with TypeScript.
1. Create a project directory and navigate into it:
# bash mkdir ts-abac-example cd ts-abac-example
2. Initialize a new Node.js project and install dependencies, including TypeScript and type definitions for Node.js and Express:
# bash npm init -y npm install express npm install --save-dev typescript ts-node @types/express @types/node
3. Initialize a TypeScript configuration file tsconfig.json:
# bash npx tsc --init
4. Update tsconfig.json with these settings for a Node.js environment:
# json
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"module": "nodenext",
"target": "esnext",
"types": [],
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"strict": true,
"jsx": "react-jsx",
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}5. Create the necessary directories and files:
# bash mkdir src src/middleware src/policies src/mock-database touch src/server.ts touch src/policies/policies.ts touch src/middleware/auth.ts touch src/mock-database/index.ts
Step 2: Define data types
Create interfaces to enforce type safety across your application.
File: src/types.ts
# typescript
import type { Request } from 'express';
// Defines the structure of a user object.
export interface User {
id: string;
role: 'admin' | 'editor' | 'viewer';
department: 'IT' | 'engineering' | 'sales';
}
// Defines the structure of a service object.
export interface Service {
id: string;
name: string;
ownerId: string;
visibility: 'public' | 'private';
department: 'IT' | 'engineering' | 'sales';
}
export interface AuthenticatedRequest extends Request {
user?: User;
resource?: Service;
}Step 3: Create mock data and database
Use the interfaces to type your mock data, preventing type-related errors.
File: src/data/index.ts
# typescript
import type { User, Service } from '../types';
// In-memory user data store.
const users: Record<string, User> = {
'1': { id: '1', role: 'admin', department: 'IT' },
'2': { id: '2', role: 'editor', department: 'engineering' },
'3': { id: '3', role: 'viewer', department: 'sales' },
};
// In-memory database object for managing services and users.
const servicesDB = {
services: [
{ id: 'a1', name: 'Service A', ownerId: '1', visibility: 'public', department: 'IT' },
{ id: 'b2', name: 'Service B', ownerId: '2', visibility: 'private', department: 'engineering' },
{ id: 'c3', name: 'Service C', ownerId: '3', visibility: 'public', department: 'sales' },
] as Service[],
// Finds a user by their ID.
findUserById: (id: string): User | undefined => users[id],
// Finds a service by its ID.
findServiceById: async (id: string): Promise<Service | undefined> => {
return servicesDB.services.find(service => service.id === id);
},
// Adds a new service to the collection.
createService: async (service: Service): Promise<Service> => {
servicesDB.services.push(service);
return service;
},
// Updates an existing service by its ID.
updateService: async (id: string, newService: Partial<Service>): Promise<Service | undefined> => {
const index = servicesDB.services.findIndex(service => service.id === id);
if (index !== -1) {
servicesDB.services[index] = { ...servicesDB.services[index], ...newService } as Service;
return servicesDB.services[index];
}
return undefined;
}
};
export default servicesDB;Step 4: Define ABAC policies
Use the User and Service types in your policy functions to ensure inputs are correct.
File: src/policies/policies.ts
# typescript
import type { User, Service } from '../types';
// Defines the authorization policies for different actions on services.
export const policies = {
// Policy: Only users in the 'engineering' department can create services.
canCreateService: (user: User): boolean => {
return user.department === 'engineering';
},
// Policy: Admins can read any service; others can only read public services.
canReadService: (user: User, service: Service | undefined): boolean => {
if (!service) return false;
if (user.role === 'admin') return true;
return service.visibility === 'public';
},
// Policy: Admins can edit any service; editors can only edit services they own.
canEditService: (user: User, service: Service | undefined): boolean => {
if (!service) return false;
if (user.role === 'admin') return true;
return user.role === 'editor' && service.ownerId == user.id;
},
// Policy: Only admins can delete services.
canDeleteService: (user: User, service: Service | undefined): boolean => {
if (!service) return false;
return user.role === 'admin';
}
};Step 5: Create the ABAC middleware
The middleware factory now uses the AuthenticatedRequest type, allowing for seamless integration with Express's Request object.
File: src/middleware/auth.ts
# typescript
import { Response, NextFunction } from "express";
import { AuthenticatedRequest, Service } from "../types";
import servicesDB from "../data";
import { policies } from "../policies/policies";
type ActionType = 'create' | 'read' | 'edit' | 'delete';
// Middleware factory to enforce policies based on a given action.
export const enforcePolicy = (action: ActionType) => {
// The actual middleware function returned to Express.
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const user = req.user;
if (!user) return res.status(401).send('Authentication required.');
let isAllowed = false;
let resource;
if (req.params.id) {
resource = await servicesDB.findServiceById(req.params.id);
if (!resource) return res.status(404).send('Resource not found.');
}
switch (action) {
case 'create':
isAllowed = policies.canCreateService(user);
break;
case 'read':
isAllowed = policies.canReadService(user, resource);
break;
case 'edit':
isAllowed = policies.canEditService(user, resource);
break;
case 'delete':
isAllowed = policies.canDeleteService(user, resource);
break;
default:
isAllowed = false;
}
if (isAllowed) {
req.resource = resource as Service;
next();
} else {
res.status(403).send('Access denied.');
}
} catch (error) {
console.error(error);
res.status(500).send('An error occurred.');
}
}
};Step 6: Create the Express server
Here, you'll need to update the imports and type app.use to use your custom request type.
File: src/server.ts
# typescript
import express, { Express, Response, NextFunction } from 'express';
import servicesDB from './data';
import { enforcePolicy } from './middleware/auth';
import { AuthenticatedRequest } from './types';
import { randomUUID } from 'crypto';
// Initialize Express app and set port.
const app: Express = express();
const PORT = 3000;
// Middleware to parse incoming JSON requests.
app.use(express.json());
// Middleware to attach user to request based on 'x-user-id' header.
app.use((req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const userId = req.headers['x-user-id'] as string;
if (userId) {
const user = servicesDB.findUserById(userId);
if (user) {
req.user = user;
}
}
next();
});
// Routes
// Route to create a new service.
app.post('/services', enforcePolicy('create'), async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).send('User not authenticated.');
const newService = {
id: randomUUID(),
...req.body,
ownerId: req.user.id,
}
const service = await servicesDB.createService(newService);
res.status(201).json(service);
});
// Route to read a service by its ID.
app.get('/services/:id', enforcePolicy('read'), (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).send('User not authenticated.');
if (!req.resource) return res.status(404).send('Resource not found.');
res.json(req.resource);
});
// Route to edit a service by its ID.
app.put('/services/:id', enforcePolicy('edit'), async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).send('User not authenticated.');
if (!req.resource) return res.status(404).send('Resource not found.');
const updatedService = await servicesDB.updateService(req.resource.id, req.body);
res.json(updatedService);
});
// Route to delete a service by its ID.
app.delete('/services/:id', enforcePolicy('delete'), async (req: AuthenticatedRequest, res: Response) => {
res.status(204).send();
});
// Start the Express server.
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});Step 7: Run the application
To run your TypeScript application, you can use ts-node to compile and run in one step.
Add a start script to your package.json file:
# json
"scripts": {
"dev": "ts-node src/server.ts",
"build": "tsc",
},Run the server:
# bash npm run dev
You can now test the ABAC policies using the same cURL commands.

Step 8: Test
Below are cURL test scripts to verify the ABAC implementation on your TypeScript Express server. These scripts cover the create, read, edit, and delete actions for different users (admin, editor, viewer) and both authorized and unauthorized scenarios.
Note:
The mock users are identified by the x-user-id header:
- Admin: x-user-id: 1
- Editor: x-user-id: 2
- Viewer: x-user-id: 3
Scenario: Authorized user (editor) creates a service:
# bash
curl -X POST http://localhost:3000/services \
-H "x-user-id: 2" \
-H "Content-Type: application/json" \
-d '{"name": "New Engineering Service", "visibility": "private", "department": "engineering"}'Scenario: Unauthorized user (viewer) creates a service:
# bash
curl -X POST http://localhost:3000/services \
-H "x-user-id: 3" \
-H "Content-Type: application/json" \
-d '{"name": "New Sales Service", "visibility": "public", "department": "sales"}'Scenario: Unauthorized user (viewer) reads a private service:
# bash # Service `b2` is private and owned by an editor curl http://localhost:3000/services/b2 \ -H "x-user-id: 3"
Scenario: Authorized user (admin) reads a private service:
# bash # Service `b2` is private curl http://localhost:3000/services/b2 \ -H "x-user-id: 1"
Scenario: Authorized user (editor) edits their own service:
# bash
# Service `b2` is owned by the editor (`x-user-id: 2`)
curl -X PUT http://localhost:3000/services/b2 \
-H "x-user-id: 2" \
-H "Content-Type: application/json" \
-d '{"name": "Updated by Editor"}'Scenario: Unauthorized user (editor) edits another user's service:
# bash
# Service `a1` is owned by the admin, not the editor
curl -X PUT http://localhost:3000/services/a1 \
-H "x-user-id: 2" \
-H "Content-Type: application/json" \
-d '{"name": "Attempted update by Editor"}'
Scenario: Authorized user (admin) edits any service:
# bash
# Service `c3` is owned by the viewer
curl -X PUT http://localhost:3000/services/c3 \
-H "x-user-id: 1" \
-H "Content-Type: application/json" \
-d '{"name": "Updated by Admin"}'Scenario: Unauthorized user (editor) deletes a service:
# bash curl -X DELETE http://localhost:3000/services/b2 \ -H "x-user-id: 2"
Scenario: Authorized user (admin) deletes a service:
# bash curl -X DELETE http://localhost:3000/services/a1 \ -H "x-user-id: 1"
Scenario: Unauthenticated user attempts to read a service:
# bash curl http://localhost:3000/services/a1
Scenario: Unauthenticated user attempts to create a service:
# bash
curl -X POST http://localhost:3000/services \
-H "Content-Type: application/json" \
-d '{"name": "Unauthenticated Service", "visibility": "public", "department": "engineering"}'Get full Source Code >> Here