Skip to content
Merged

Dev #68

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Request, Response } from 'express';
import { requirePermissions, requireRoles, requireAny, RequestUser } from '../rbacMiddleware'; // Import RequestUser
import { UserRole } from '@prisma/client';
import { USER_PERMISSIONS, ADMIN_PERMISSIONS } from '../../constants/permissions';
import { HttpException } from '../../../exceptions/HttpException';

// Mock request, response, and next function
const mockRequest = (user?: RequestUser): Partial<Request> => ({
// Use RequestUser
user,
});

const mockResponse = (): Partial<Response> => ({
status: jest.fn().mockReturnThis(),
json: jest.fn(),
});

const mockNext = jest.fn();

describe('RBAC Middleware', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('requirePermissions', () => {
it('should call next() if user has all required permissions', () => {
const req = mockRequest({
id: '123',
role: UserRole.ADMIN, // Uses 'role' as per RequestUser
}) as Request;

const res = mockResponse() as Response;
const middleware = requirePermissions([ADMIN_PERMISSIONS.READ_USERS]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith();
expect(mockNext).not.toHaveBeenCalledWith(expect.any(HttpException));
});

it('should call next() with HttpException if user does not have required permissions', () => {
const req = mockRequest({
id: '123',
role: UserRole.USER, // Uses 'role'
}) as Request;

const res = mockResponse() as Response;
const middleware = requirePermissions([ADMIN_PERMISSIONS.READ_USERS]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith(expect.any(HttpException));
expect(mockNext.mock.calls[0][0].status).toBe(403);
});

it('should call next() with HttpException if user is not authenticated', () => {
const req = mockRequest() as Request; // No user
const res = mockResponse() as Response;
const middleware = requirePermissions([USER_PERMISSIONS.READ_LABS]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith(expect.any(HttpException));
expect(mockNext.mock.calls[0][0].status).toBe(401);
});
});

describe('requireRoles', () => {
it('should call next() if user has the required role', () => {
const req = mockRequest({
id: '123',
role: UserRole.ADMIN, // Uses 'role'
}) as Request;

const res = mockResponse() as Response;
const middleware = requireRoles([UserRole.ADMIN]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith();
expect(mockNext).not.toHaveBeenCalledWith(expect.any(HttpException));
});

it('should call next() with HttpException if user does not have the required role', () => {
const req = mockRequest({
id: '123',
role: UserRole.USER, // Uses 'role'
}) as Request;

const res = mockResponse() as Response;
const middleware = requireRoles([UserRole.ADMIN]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith(expect.any(HttpException));
expect(mockNext.mock.calls[0][0].status).toBe(403);
});
});

describe('requireAny', () => {
it('should call next() if user has any of the required permissions', () => {
const req = mockRequest({
id: '123',
role: UserRole.USER, // Uses 'role'
}) as Request;

const res = mockResponse() as Response;
const middleware = requireAny([USER_PERMISSIONS.READ_LABS, ADMIN_PERMISSIONS.READ_USERS]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith();
expect(mockNext).not.toHaveBeenCalledWith(expect.any(HttpException));
});

it('should call next() if user has any of the required roles', () => {
const req = mockRequest({
id: '123',
role: UserRole.USER, // Uses 'role'
}) as Request;

const res = mockResponse() as Response;
const middleware = requireAny([UserRole.USER, UserRole.ADMIN]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith();
expect(mockNext).not.toHaveBeenCalledWith(expect.any(HttpException));
});

it('should call next() with HttpException if user does not have any of the required permissions or roles', () => {
const req = mockRequest({
id: '123',
role: UserRole.USER, // Uses 'role'
}) as Request;

const res = mockResponse() as Response;
const middleware = requireAny([ADMIN_PERMISSIONS.READ_USERS, UserRole.ADMIN]);

middleware(req, res, mockNext);

expect(mockNext).toHaveBeenCalledWith(expect.any(HttpException));
expect(mockNext.mock.calls[0][0].status).toBe(403);
});
});
});
140 changes: 140 additions & 0 deletions backend/src/authorization/middleware/rbacMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Request, Response, NextFunction } from 'express';
import { Permission } from '../constants/permissions';
import { UserRole } from '@prisma/client';
import { hasPermission, hasAnyPermission, AuthUser } from '../utils/permissionChecker';
import { HttpException } from '../../exceptions/HttpException';

/**
* Interface for authenticated user in request, reflecting actual usage/errors
*/
export interface RequestUser {
id: string;
role: UserRole;
organizationId?: string | null;
}

// Extend Express Request interface to include user
// This is a standard way to augment Express's Request type.
// If @typescript-eslint/no-namespace is strict, you might need to disable it for this block.
// eslint-disable-next-line @typescript-eslint/no-namespace
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
user?: RequestUser;
}
}
}

/**
* Options for the RBAC middleware
*/
interface RBACOptions {
allowMultiple?: boolean;
}

// Helper to adapt RequestUser to AuthUser
const adaptUserForPermissionChecker = (user: RequestUser): AuthUser => {
return {
...user,
user_role: user.role, // Map role to user_role
};
};

/**
* Middleware to check if user has the required permissions
* @param permissions Array of permissions required to access the route
* @param options Configuration options
* @returns Express middleware
*/
export const requirePermissions = (permissions: Permission[], options: RBACOptions = {}) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
return next(new HttpException(401, 'Authentication required'));
}

const { allowMultiple = false } = options;
const adaptedUser = adaptUserForPermissionChecker(req.user);

const hasRequiredPermissions = allowMultiple
? hasAnyPermission(adaptedUser, permissions)
: permissions.every(permission => hasPermission(adaptedUser, permission));

if (!hasRequiredPermissions) {
return next(new HttpException(403, 'Insufficient permissions'));
}

next();
};
};

/**
* Middleware to check if user has the required role
* @param roles Array of roles required to access the route
* @param options Configuration options
* @returns Express middleware
*/
export const requireRoles = (roles: UserRole[], options: RBACOptions = {}) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
return next(new HttpException(401, 'Authentication required'));
}

const { allowMultiple = false } = options;

const hasRequiredRole = allowMultiple
? roles.includes(req.user.role) // Use req.user.role directly
: req.user.role === roles[0]; // Use req.user.role directly

if (!hasRequiredRole) {
return next(new HttpException(403, 'Insufficient role permissions'));
}

next();
};
};

// Type guard to check if an item is a UserRole
function isUserRole(item: Permission | UserRole): item is UserRole {
// Check if 'item' is one of the string values of the UserRole enum
return Object.values(UserRole).some(roleValue => roleValue === item);
}

/**
* Combined middleware to check if user has any of the specified permissions or roles
* @param permissionsOrRoles Array of permissions or roles
* @returns Express middleware
*/
export const requireAny = (permissionsOrRoles: (Permission | UserRole)[]) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
return next(new HttpException(401, 'Authentication required'));
}

const separatedPermissions: Permission[] = [];
const separatedRoles: UserRole[] = [];

for (const item of permissionsOrRoles) {
if (isUserRole(item)) {
// Use the type guard
separatedRoles.push(item); // item is now UserRole
} else {
// item is now Permission (or string, if Permission is string)
separatedPermissions.push(item);
}
}

const adaptedUser = adaptUserForPermissionChecker(req.user);

const hasAnyOfPermissions =
separatedPermissions.length > 0 ? hasAnyPermission(adaptedUser, separatedPermissions) : false;
const hasAnyOfRoles =
separatedRoles.length > 0 ? separatedRoles.includes(req.user.role) : false;

if (!hasAnyOfPermissions && !hasAnyOfRoles) {
return next(new HttpException(403, 'Insufficient permissions'));
}

next();
};
};
Loading