import { Response, NextFunction } from 'express'; import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js'; import { logger } from '../utils/logger.js'; import { permissionsService } from '../../modules/roles/services/permissions.service.js'; import { checkCachedPermission, getCachedPermissions, setCachedPermissions, } from '../services/permission-cache.service.js'; /** * Parse permission code format "resource:action" or "resource.action" */ function parsePermissionCode(code: string): { resource: string; action: string } { const separator = code.includes(':') ? ':' : '.'; const [resource, action] = code.split(separator); if (!resource || !action) { throw new Error(`Invalid permission code format: ${code}. Expected "resource:action" or "resource.action"`); } return { resource, action }; } /** * Check if user has permission (helper function) */ async function userHasPermission( tenantId: string, userId: string, roles: string[], resource: string, action: string ): Promise { // Super admin bypass if (roles.includes('super_admin')) { return true; } // Check cache first const cachedResult = await checkCachedPermission(tenantId, userId, resource, action); if (cachedResult !== null) { return cachedResult; } // Check database const hasPermission = await permissionsService.hasPermission(tenantId, userId, resource, action); // Warm up cache if (!hasPermission) { const permissions = await permissionsService.getEffectivePermissions(tenantId, userId); await setCachedPermissions(tenantId, userId, permissions); } return hasPermission; } /** * 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: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { try { if (!req.user) { throw new UnauthorizedError('Usuario no autenticado'); } const { userId, tenantId, roles } = req.user; const hasPermission = await userHasPermission(tenantId, userId, roles, resource, action); if (hasPermission) { return next(); } logger.warn('Access denied (requirePerm)', { userId, tenantId, 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: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { try { if (!req.user) { throw new UnauthorizedError('Usuario no autenticado'); } const { userId, tenantId, roles } = req.user; // Super admin bypass if (roles.includes('super_admin')) { return next(); } // Check each permission for (const { resource, action } of permissions) { const hasPermission = await userHasPermission(tenantId, userId, roles, resource, action); if (hasPermission) { return next(); } } logger.warn('Access denied (requireAnyPerm)', { userId, tenantId, 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: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { try { if (!req.user) { throw new UnauthorizedError('Usuario no autenticado'); } const { userId, tenantId, roles } = req.user; // 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(tenantId, userId, roles, resource, action); if (!hasPermission) { missingPermissions.push(codes[i]); } } if (missingPermissions.length === 0) { return next(); } logger.warn('Access denied (requireAllPerms)', { userId, tenantId, 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: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { try { if (!req.user) { throw new UnauthorizedError('Usuario no autenticado'); } const { userId, tenantId, roles } = req.user; // Super admin bypass if (roles.includes('super_admin')) { return next(); } // Check permission first (if specified) if (parsedPermission) { const hasPermission = await userHasPermission( tenantId, userId, 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, tenantId, permission, matchedRoles: requiredRoles.filter(r => roles.includes(r)), }); return next(); } } logger.warn('Access denied (requireAccess)', { userId, tenantId, 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: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { try { if (!req.user) { throw new UnauthorizedError('Usuario no autenticado'); } const { userId, tenantId, roles } = req.user; 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(tenantId, userId, roles, resource, action); if (hasPermission) { return next(); } logger.warn('Access denied (requirePermOrOwner)', { userId, tenantId, permission: code, resourceOwnerId, roles, }); throw new ForbiddenError(`No tiene permiso: ${code}`); } catch (error) { next(error); } }; }