From 6a005a6225871c51af3af559c43a4d5eda4f71ca Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 2 Feb 2026 14:58:35 -0600 Subject: [PATCH] [RBAC-005] feat: Propagate RBAC middleware from erp-core - Add rbac.middleware.ts with requirePerm, requireAnyPerm, requireAllPerms, requireAccess, requirePermOrOwner - Add shared middleware index exports - Configure glass-specific roles (manager, production, sales, admin, super_admin) Propagated from erp-core TASK-2026-01-30-RBAC Co-Authored-By: Claude Opus 4.5 --- src/shared/middleware/index.ts | 6 + src/shared/middleware/rbac.middleware.ts | 304 +++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 src/shared/middleware/index.ts create mode 100644 src/shared/middleware/rbac.middleware.ts diff --git a/src/shared/middleware/index.ts b/src/shared/middleware/index.ts new file mode 100644 index 0000000..d594808 --- /dev/null +++ b/src/shared/middleware/index.ts @@ -0,0 +1,6 @@ +/** + * Shared Middleware - Exports + * ERP Vidrio Templado + */ + +export * from './rbac.middleware'; diff --git a/src/shared/middleware/rbac.middleware.ts b/src/shared/middleware/rbac.middleware.ts new file mode 100644 index 0000000..48bc50f --- /dev/null +++ b/src/shared/middleware/rbac.middleware.ts @@ -0,0 +1,304 @@ +/** + * 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'; + +/** + * 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 { + // 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 = [ + 'orders', 'quotes', 'products', 'inventory', 'customers', + 'production', 'cutting', 'tempering', 'reports', 'employees' + ]; + if (managerResources.includes(resource)) { + return true; + } + } + + // Production worker has limited access + if (roles.includes('production')) { + const productionResources = ['orders', 'production', 'cutting', 'tempering']; + const allowedActions = ['read', 'list', 'update']; + if (productionResources.includes(resource) && allowedActions.includes(action)) { + return true; + } + } + + // Sales has customer and quote access + if (roles.includes('sales')) { + const salesResources = ['customers', 'quotes', 'orders', 'products']; + if (salesResources.includes(resource)) { + return true; + } + } + + return false; +} + +/** + * Shorthand middleware for permission check using code format + * @param code Permission in format "resource:action" (e.g., "orders:create") + */ +export function requirePerm(code: string) { + const { resource, action } = parsePermissionCode(code); + + return async (req: Request, res: Response, next: NextFunction): Promise => { + const user = (req as any).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., "orders:read", "orders:list") + */ +export function requireAnyPerm(...codes: string[]) { + const permissions = codes.map(parsePermissionCode); + + return async (req: Request, res: Response, next: NextFunction): Promise => { + const user = (req as any).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., "orders:read", "production:update") + */ +export function requireAllPerms(...codes: string[]) { + const permissions = codes.map(parsePermissionCode); + + return async (req: Request, res: Response, next: NextFunction): Promise => { + const user = (req as any).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 => { + const user = (req as any).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 => { + const user = (req as any).user; + + if (!user) { + res.status(401).json({ + success: false, + error: { code: 'NOT_AUTHENTICATED', message: 'Authentication required' }, + }); + return; + } + + const userId = user.sub || 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}` }, + }); + }; +}