erp-core-backend-v2/src/shared/middleware/rbac.middleware.ts
Adrian Flores Cortes 6a12ff0844 [TASK-2026-02-05-EJECUCION-REMEDIATION-ERP-CORE] feat: Complete Sprint 0-4 data modeling remediation
Sprint 0: Updated inventories (MASTER/BACKEND/DATABASE) with verified baseline
Sprint 1: Fixed 8 P0 blockers - CFDI entities (schema cfdi→fiscal), auth base DDL,
  billing duplication (→operations), 5 project entities, PaymentInvoiceAllocation,
  core.companies DDL, recreate-database.sh array
Sprint 2: 4 new auth entities, session/role/permission DDL reconciliation,
  CFDI PAC+StampQueue, partner address+contact alignment
Sprint 3: CFDI service+controller+routes, mobile service+controller+routes,
  inventory extended DDL (7 tables)
Sprint 4: timestamp→timestamptz (40 files), field divergences, token/roles/permissions
  service alignment with new DDL-aligned entities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:51:55 -06:00

295 lines
8.6 KiB
TypeScript

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<boolean> {
// 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
}
};
}