[RBAC-005] feat: Propagate RBAC middleware from erp-core

- Add rbac.middleware.ts with requirePerm, requireAnyPerm, requireAllPerms, requireAccess, requirePermOrOwner
- Update shared middleware index exports
- Configure retail-specific roles (manager, cashier, admin, super_admin)

Propagated from erp-core TASK-2026-01-30-RBAC

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-02 14:58:08 -06:00
parent ebbe7d1d36
commit ef5861bedd
2 changed files with 298 additions and 0 deletions

View File

@ -1,3 +1,4 @@
export * from './tenant.middleware';
export * from './auth.middleware';
export * from './branch.middleware';
export * from './rbac.middleware';

View File

@ -0,0 +1,297 @@
/**
* RBAC Middleware - Role-Based Access Control
* Propagated from erp-core (TASK-2026-01-30-RBAC)
*
* Provides permission-based authorization with hybrid role fallback
* for gradual migration from role-based to permission-based access control.
*/
import { Request, Response, NextFunction } from 'express';
import { AuthenticatedRequest } from '../types';
/**
* Parse permission code format "resource:action" or "resource.action"
*/
function parsePermissionCode(code: string): { resource: string; action: string } {
const separator = code.includes(':') ? ':' : '.';
const parts = code.split(separator);
if (parts.length < 2 || !parts[0] || !parts[1]) {
throw new Error(`Invalid permission code format: ${code}. Expected "resource:action" or "resource.action"`);
}
return { resource: parts[0], action: parts[1] };
}
/**
* Check if user has permission (role-based fallback for verticals without full RBAC)
*/
async function userHasPermission(
roles: string[],
resource: string,
action: string
): Promise<boolean> {
// Super admin bypass
if (roles.includes('super_admin')) {
return true;
}
// Admin has all permissions except system:manage
if (roles.includes('admin') && !(resource === 'system' && action === 'manage')) {
return true;
}
// Manager has CRUD on most resources
if (roles.includes('manager')) {
const managerResources = [
'pos', 'branches', 'cash', 'inventory', 'pricing',
'invoicing', 'customers', 'products', 'sales', 'reports'
];
if (managerResources.includes(resource)) {
return true;
}
}
// Cashier has POS and cash operations
if (roles.includes('cashier')) {
const cashierResources = ['pos', 'cash', 'customers'];
const allowedActions = ['read', 'create', 'list'];
if (cashierResources.includes(resource) && allowedActions.includes(action)) {
return true;
}
}
return false;
}
/**
* Shorthand middleware for permission check using code format
* @param code Permission in format "resource:action" (e.g., "invoices:create")
*/
export function requirePerm(code: string) {
const { resource, action } = parsePermissionCode(code);
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = (req as AuthenticatedRequest).user;
if (!user) {
res.status(401).json({
success: false,
error: { code: 'NOT_AUTHENTICATED', message: 'Authentication required' },
});
return;
}
const roles = user.roles || [];
const hasPermission = await userHasPermission(roles, resource, action);
if (hasPermission) {
next();
return;
}
res.status(403).json({
success: false,
error: { code: 'FORBIDDEN', message: `No tiene permiso: ${code}` },
});
};
}
/**
* Require ANY of the specified permissions (OR logic)
* @param codes Permission codes (e.g., "invoices:read", "invoices:list")
*/
export function requireAnyPerm(...codes: string[]) {
const permissions = codes.map(parsePermissionCode);
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = (req as AuthenticatedRequest).user;
if (!user) {
res.status(401).json({
success: false,
error: { code: 'NOT_AUTHENTICATED', message: 'Authentication required' },
});
return;
}
const roles = user.roles || [];
// Super admin bypass
if (roles.includes('super_admin')) {
next();
return;
}
// Check each permission
for (const { resource, action } of permissions) {
const hasPermission = await userHasPermission(roles, resource, action);
if (hasPermission) {
next();
return;
}
}
res.status(403).json({
success: false,
error: { code: 'FORBIDDEN', message: `No tiene ninguno de los permisos: ${codes.join(', ')}` },
});
};
}
/**
* Require ALL of the specified permissions (AND logic)
* @param codes Permission codes (e.g., "invoices:read", "payments:create")
*/
export function requireAllPerms(...codes: string[]) {
const permissions = codes.map(parsePermissionCode);
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = (req as AuthenticatedRequest).user;
if (!user) {
res.status(401).json({
success: false,
error: { code: 'NOT_AUTHENTICATED', message: 'Authentication required' },
});
return;
}
const roles = user.roles || [];
// Super admin bypass
if (roles.includes('super_admin')) {
next();
return;
}
// Check all permissions
const missingPermissions: string[] = [];
for (let i = 0; i < permissions.length; i++) {
const { resource, action } = permissions[i];
const hasPermission = await userHasPermission(roles, resource, action);
if (!hasPermission) {
missingPermissions.push(codes[i]);
}
}
if (missingPermissions.length === 0) {
next();
return;
}
res.status(403).json({
success: false,
error: { code: 'FORBIDDEN', message: `Faltan permisos: ${missingPermissions.join(', ')}` },
});
};
}
/**
* Hybrid access control for gradual migration from role-based to permission-based
* Allows access if user has ANY of the specified roles OR has the specified permission
* @param options.roles Legacy roles to check (fallback)
* @param options.permission Permission code to check (preferred)
*/
export function requireAccess(options: { roles?: string[]; permission?: string }) {
const { roles: requiredRoles, permission } = options;
const parsedPermission = permission ? parsePermissionCode(permission) : null;
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = (req as AuthenticatedRequest).user;
if (!user) {
res.status(401).json({
success: false,
error: { code: 'NOT_AUTHENTICATED', message: 'Authentication required' },
});
return;
}
const roles = user.roles || [];
// Super admin bypass
if (roles.includes('super_admin')) {
next();
return;
}
// Check permission first (if specified)
if (parsedPermission) {
const hasPermission = await userHasPermission(
roles,
parsedPermission.resource,
parsedPermission.action
);
if (hasPermission) {
next();
return;
}
}
// Fallback to role check (if specified)
if (requiredRoles && requiredRoles.length > 0) {
const hasRole = requiredRoles.some(role => roles.includes(role));
if (hasRole) {
next();
return;
}
}
const errorDetails = [];
if (permission) errorDetails.push(`permiso: ${permission}`);
if (requiredRoles) errorDetails.push(`roles: ${requiredRoles.join(', ')}`);
res.status(403).json({
success: false,
error: { code: 'FORBIDDEN', message: `Acceso denegado. Requiere ${errorDetails.join(' o ')}` },
});
};
}
/**
* Check permission for resource owner (user can access their own resources)
* @param code Permission code for non-owners
* @param ownerField Field name in request params that contains owner ID (default: 'userId')
*/
export function requirePermOrOwner(code: string, ownerField: string = 'userId') {
const { resource, action } = parsePermissionCode(code);
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = (req as AuthenticatedRequest).user;
if (!user) {
res.status(401).json({
success: false,
error: { code: 'NOT_AUTHENTICATED', message: 'Authentication required' },
});
return;
}
const userId = user.userId;
const roles = user.roles || [];
const resourceOwnerId = req.params[ownerField] || req.body?.[ownerField];
// Super admin bypass
if (roles.includes('super_admin')) {
next();
return;
}
// Check if user is the owner
if (resourceOwnerId === userId) {
next();
return;
}
// Check permission
const hasPermission = await userHasPermission(roles, resource, action);
if (hasPermission) {
next();
return;
}
res.status(403).json({
success: false,
error: { code: 'FORBIDDEN', message: `No tiene permiso: ${code}` },
});
};
}