From b67a035119dbf54eca7db27b0149c6953237da32 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 2 Feb 2026 14:58:06 -0600 Subject: [PATCH] [RBAC-005] feat: Propagate RBAC middleware from erp-core - Add rbac.middleware.ts with requirePerm, requireAnyPerm, requireAllPerms, requireAccess, requirePermOrOwner - Add permission-cache.service.ts for Redis caching - Update shared services and middleware index exports - Configure construction-specific roles (manager, admin, super_admin) Propagated from erp-core TASK-2026-01-30-RBAC Co-Authored-By: Claude Opus 4.5 --- src/shared/middleware/index.ts | 8 + src/shared/middleware/rbac.middleware.ts | 291 ++++++++++++++++++ src/shared/services/index.ts | 1 + .../services/permission-cache.service.ts | 198 ++++++++++++ 4 files changed, 498 insertions(+) create mode 100644 src/shared/middleware/index.ts create mode 100644 src/shared/middleware/rbac.middleware.ts create mode 100644 src/shared/services/permission-cache.service.ts diff --git a/src/shared/middleware/index.ts b/src/shared/middleware/index.ts new file mode 100644 index 0000000..e6b1d9d --- /dev/null +++ b/src/shared/middleware/index.ts @@ -0,0 +1,8 @@ +/** + * Shared Middleware - Exports + * ERP Construccion + */ + +export * from './auth.middleware'; +export * from './fieldPermissions.middleware'; +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..df2b5ad --- /dev/null +++ b/src/shared/middleware/rbac.middleware.ts @@ -0,0 +1,291 @@ +/** + * 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 { UnauthorizedError, ForbiddenError } from '../types/index'; +import { logger } from '../utils/logger'; + +/** + * 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 = [ + 'projects', 'estimates', 'budgets', 'inventory', 'purchases', + 'sales', 'partners', 'employees', 'timesheets', 'reports' + ]; + if (managerResources.includes(resource)) { + return true; + } + } + + // TODO: Implement database permission check when full RBAC is ready + // return permissionsService.hasPermission(tenantId, userId, resource, action); + + 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 => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + const roles = req.user.roles || []; + + const hasPermission = await userHasPermission(roles, resource, action); + + if (hasPermission) { + return next(); + } + + logger.warn('Access denied (requirePerm)', { + userId: req.user.sub, + permission: code, + roles, + }); + throw new ForbiddenError(`No tiene permiso: ${code}`); + } catch (error) { + next(error); + } + }; +} + +/** + * 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 => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + const roles = req.user.roles || []; + + // Super admin bypass + if (roles.includes('super_admin')) { + return next(); + } + + // Check each permission + for (const { resource, action } of permissions) { + const hasPermission = await userHasPermission(roles, resource, action); + if (hasPermission) { + return next(); + } + } + + logger.warn('Access denied (requireAnyPerm)', { + userId: req.user.sub, + permissions: codes, + roles, + }); + throw new ForbiddenError(`No tiene ninguno de los permisos requeridos: ${codes.join(', ')}`); + } catch (error) { + next(error); + } + }; +} + +/** + * 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 => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + const roles = req.user.roles || []; + + // Super admin bypass + if (roles.includes('super_admin')) { + return next(); + } + + // 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) { + return next(); + } + + logger.warn('Access denied (requireAllPerms)', { + userId: req.user.sub, + required: codes, + missing: missingPermissions, + roles, + }); + throw new ForbiddenError(`Faltan permisos: ${missingPermissions.join(', ')}`); + } catch (error) { + next(error); + } + }; +} + +/** + * 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 => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + const roles = req.user.roles || []; + + // Super admin bypass + if (roles.includes('super_admin')) { + return next(); + } + + // Check permission first (if specified) + if (parsedPermission) { + const hasPermission = await userHasPermission( + roles, + parsedPermission.resource, + parsedPermission.action + ); + if (hasPermission) { + return next(); + } + } + + // Fallback to role check (if specified) + if (requiredRoles && requiredRoles.length > 0) { + const hasRole = requiredRoles.some(role => roles.includes(role)); + if (hasRole) { + logger.debug('Access granted via role (hybrid mode)', { + userId: req.user.sub, + permission, + matchedRoles: requiredRoles.filter(r => roles.includes(r)), + }); + return next(); + } + } + + logger.warn('Access denied (requireAccess)', { + userId: req.user.sub, + permission, + requiredRoles, + userRoles: roles, + }); + + const errorDetails = []; + if (permission) errorDetails.push(`permiso: ${permission}`); + if (requiredRoles) errorDetails.push(`roles: ${requiredRoles.join(', ')}`); + throw new ForbiddenError(`Acceso denegado. Requiere ${errorDetails.join(' o ')}`); + } catch (error) { + next(error); + } + }; +} + +/** + * 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 => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + const userId = req.user.sub || req.user.userId; + const roles = req.user.roles || []; + const resourceOwnerId = req.params[ownerField] || req.body?.[ownerField]; + + // Super admin bypass + if (roles.includes('super_admin')) { + return next(); + } + + // Check if user is the owner + if (resourceOwnerId === userId) { + logger.debug('Access granted (owner)', { userId, resource, action }); + return next(); + } + + // Check permission + const hasPermission = await userHasPermission(roles, resource, action); + if (hasPermission) { + return next(); + } + + logger.warn('Access denied (requirePermOrOwner)', { + userId, + permission: code, + resourceOwnerId, + roles, + }); + throw new ForbiddenError(`No tiene permiso: ${code}`); + } catch (error) { + next(error); + } + }; +} diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts index 096aaed..ef08c69 100644 --- a/src/shared/services/index.ts +++ b/src/shared/services/index.ts @@ -4,3 +4,4 @@ export * from './base.service'; export * from './feature-flags.service'; +export * from './permission-cache.service'; diff --git a/src/shared/services/permission-cache.service.ts b/src/shared/services/permission-cache.service.ts new file mode 100644 index 0000000..6b6c1c1 --- /dev/null +++ b/src/shared/services/permission-cache.service.ts @@ -0,0 +1,198 @@ +/** + * Permission Cache Service + * Propagated from erp-core (TASK-2026-01-30-RBAC) + * + * Provides Redis caching for user permissions with 5-minute TTL. + * Falls back gracefully when Redis is not available. + */ + +import { logger } from '../utils/logger'; + +// Permission type (simplified for vertical) +interface EffectivePermission { + resource: string; + action: string; + source?: string; +} + +const CACHE_TTL = 300; // 5 minutes +const CACHE_PREFIX = 'permissions'; + +// Redis client reference - set via init function +let redisClient: any = null; + +/** + * Initialize the cache service with a Redis client + */ +export function initPermissionCache(client: any): void { + redisClient = client; +} + +/** + * Check if Redis is connected + */ +function isRedisConnected(): boolean { + return redisClient && redisClient.status === 'ready'; +} + +/** + * Builds the cache key for user permissions + */ +function buildKey(tenantId: string, userId: string): string { + return `${CACHE_PREFIX}:${tenantId}:${userId}`; +} + +/** + * Get cached permissions for a user + * @returns Array of effective permissions or null if not cached + */ +export async function getCachedPermissions( + tenantId: string, + userId: string +): Promise { + if (!isRedisConnected()) { + return null; + } + + try { + const key = buildKey(tenantId, userId); + const cached = await redisClient.get(key); + + if (cached) { + logger.debug('Permission cache hit', { tenantId, userId }); + return JSON.parse(cached) as EffectivePermission[]; + } + + logger.debug('Permission cache miss', { tenantId, userId }); + return null; + } catch (error) { + logger.error('Error reading permission cache', { + error: (error as Error).message, + tenantId, + userId, + }); + return null; + } +} + +/** + * Cache permissions for a user + */ +export async function setCachedPermissions( + tenantId: string, + userId: string, + permissions: EffectivePermission[] +): Promise { + if (!isRedisConnected()) { + return; + } + + try { + const key = buildKey(tenantId, userId); + await redisClient.setex(key, CACHE_TTL, JSON.stringify(permissions)); + logger.debug('Permissions cached', { + tenantId, + userId, + count: permissions.length, + ttl: CACHE_TTL, + }); + } catch (error) { + logger.error('Error caching permissions', { + error: (error as Error).message, + tenantId, + userId, + }); + } +} + +/** + * Invalidate cached permissions for a specific user + * Call this when user roles or permissions change + */ +export async function invalidateUserPermissions( + tenantId: string, + userId: string +): Promise { + if (!isRedisConnected()) { + return; + } + + try { + const key = buildKey(tenantId, userId); + await redisClient.del(key); + logger.info('User permission cache invalidated', { tenantId, userId }); + } catch (error) { + logger.error('Error invalidating user permission cache', { + error: (error as Error).message, + tenantId, + userId, + }); + } +} + +/** + * Invalidate cached permissions for all users in a tenant + * Call this when role permissions are modified at tenant level + */ +export async function invalidateTenantPermissions(tenantId: string): Promise { + if (!isRedisConnected()) { + return; + } + + try { + const pattern = `${CACHE_PREFIX}:${tenantId}:*`; + let cursor = '0'; + let deletedCount = 0; + + do { + const [nextCursor, keys] = await redisClient.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + + if (keys.length > 0) { + await redisClient.del(...keys); + deletedCount += keys.length; + } + } while (cursor !== '0'); + + logger.info('Tenant permission cache invalidated', { + tenantId, + keysDeleted: deletedCount, + }); + } catch (error) { + logger.error('Error invalidating tenant permission cache', { + error: (error as Error).message, + tenantId, + }); + } +} + +/** + * Check if a specific permission exists in cached permissions + * Returns null if not in cache (need to check DB) + */ +export async function checkCachedPermission( + tenantId: string, + userId: string, + resource: string, + action: string +): Promise { + const permissions = await getCachedPermissions(tenantId, userId); + + if (permissions === null) { + return null; // Not cached, need DB check + } + + return permissions.some(p => p.resource === resource && p.action === action); +} + +/** + * Export service object for convenience + */ +export const permissionCacheService = { + init: initPermissionCache, + get: getCachedPermissions, + set: setCachedPermissions, + invalidateUser: invalidateUserPermissions, + invalidateTenant: invalidateTenantPermissions, + checkPermission: checkCachedPermission, +};