From a9abe0876f6c2307b1ee072745432e0664157137 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sat, 31 Jan 2026 00:11:52 -0600 Subject: [PATCH] [RBAC-001] feat: Complete RBAC middleware with permission caching - Add permission-cache.service.ts for Redis-based permission caching (5min TTL) - Complete requirePermission middleware with cache check and DB fallback - Add rbac.middleware.ts with requirePerm, requireAnyPerm, requireAllPerms, requireAccess - Export new service from shared/services/index.ts Co-Authored-By: Claude Opus 4.5 --- src/shared/middleware/auth.middleware.ts | 72 ++++- src/shared/middleware/rbac.middleware.ts | 294 ++++++++++++++++++ src/shared/services/index.ts | 9 + .../services/permission-cache.service.ts | 173 +++++++++++ 4 files changed, 541 insertions(+), 7 deletions(-) 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/auth.middleware.ts b/src/shared/middleware/auth.middleware.ts index a502890..0b01de5 100644 --- a/src/shared/middleware/auth.middleware.ts +++ b/src/shared/middleware/auth.middleware.ts @@ -3,6 +3,12 @@ import jwt from 'jsonwebtoken'; import { config } from '../../config/index.js'; import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.js'; import { logger } from '../utils/logger.js'; +import { permissionsService } from '../../modules/roles/permissions.service.js'; +import { + checkCachedPermission, + getCachedPermissions, + setCachedPermissions, +} from '../services/permission-cache.service.js'; // Re-export AuthenticatedRequest for convenience export { AuthenticatedRequest } from '../types/index.js'; @@ -73,26 +79,78 @@ export function requirePermission(resource: string, action: string) { throw new UnauthorizedError('Usuario no autenticado'); } + const { userId, tenantId, roles } = req.user; + // Superusers bypass permission checks - if (req.user.roles.includes('super_admin')) { + if (roles.includes('super_admin')) { + logger.debug('Permission bypassed (super_admin)', { userId, resource, action }); return next(); } - // TODO: Check permission in database - // For now, we'll implement this when we have the permission checking service - logger.debug('Permission check', { - userId: req.user.userId, + // Check cache first + const cachedResult = await checkCachedPermission(tenantId, userId, resource, action); + + if (cachedResult !== null) { + if (cachedResult) { + return next(); + } + logger.warn('Access denied (cached)', { + userId, + tenantId, + resource, + action, + roles, + }); + throw new ForbiddenError(`No tiene permiso para ${action} en ${resource}`); + } + + // Cache miss - check database + const hasPermission = await permissionsService.hasPermission(tenantId, userId, resource, action); + + if (hasPermission) { + // Warm up cache with all user permissions for future requests + const effectivePermissions = await permissionsService.getEffectivePermissions(tenantId, userId); + await setCachedPermissions(tenantId, userId, effectivePermissions); + return next(); + } + + // Permission denied - log and throw + logger.warn('Access denied', { + userId, + tenantId, resource, action, + roles, }); - - next(); + throw new ForbiddenError(`No tiene permiso para ${action} en ${resource}`); } catch (error) { next(error); } }; } +/** + * Preload user permissions into cache (call after login) + */ +export async function preloadUserPermissions(tenantId: string, userId: string): Promise { + try { + const cached = await getCachedPermissions(tenantId, userId); + if (cached) { + return; // Already cached + } + + const permissions = await permissionsService.getEffectivePermissions(tenantId, userId); + await setCachedPermissions(tenantId, userId, permissions); + logger.debug('User permissions preloaded', { tenantId, userId, count: permissions.length }); + } catch (error) { + logger.error('Error preloading user permissions', { + error: (error as Error).message, + tenantId, + userId, + }); + } +} + export function optionalAuth( req: AuthenticatedRequest, _res: Response, diff --git a/src/shared/middleware/rbac.middleware.ts b/src/shared/middleware/rbac.middleware.ts new file mode 100644 index 0000000..7f34d81 --- /dev/null +++ b/src/shared/middleware/rbac.middleware.ts @@ -0,0 +1,294 @@ +import { Response, NextFunction } from 'express'; +import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js'; +import { logger } from '../utils/logger.js'; +import { permissionsService } from '../../modules/roles/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); + } + }; +} diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts index 0ea3523..889fa4d 100644 --- a/src/shared/services/index.ts +++ b/src/shared/services/index.ts @@ -4,3 +4,12 @@ export { FeatureFlagContext, featureFlagService, } from './feature-flags.service'; + +export { + getCachedPermissions, + setCachedPermissions, + invalidateUserPermissions, + invalidateTenantPermissions, + checkCachedPermission, + permissionCacheService, +} 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..f420340 --- /dev/null +++ b/src/shared/services/permission-cache.service.ts @@ -0,0 +1,173 @@ +import { redisClient, isRedisConnected } from '../../config/redis.js'; +import { logger } from '../utils/logger.js'; +import { EffectivePermission } from '../../modules/roles/permissions.service.js'; + +/** + * Service for caching user permissions in Redis + * Key format: permissions:{tenantId}:{userId} + * TTL: 5 minutes (300 seconds) + */ + +const CACHE_TTL = 300; // 5 minutes +const CACHE_PREFIX = 'permissions'; + +/** + * 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 = { + get: getCachedPermissions, + set: setCachedPermissions, + invalidateUser: invalidateUserPermissions, + invalidateTenant: invalidateTenantPermissions, + checkPermission: checkCachedPermission, +};