import { Injectable, CanActivate, ExecutionContext, ForbiddenException, InternalServerErrorException, Logger, } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { Reflector } from "@nestjs/core"; import { PrismaService } from "../../prisma/prisma.service"; import { PERMISSION_KEY, Permission } from "../decorators/permissions.decorator"; import { WorkspaceMemberRole } from "@prisma/client"; import type { RequestWithWorkspace } from "../types/user.types"; /** * PermissionGuard enforces role-based access control for workspace operations. * * This guard must be used after AuthGuard and WorkspaceGuard, as it depends on: * - request.user.id (set by AuthGuard) * - request.workspace.id (set by WorkspaceGuard) * * @example * ```typescript * @Controller('workspaces') * @UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) * export class WorkspacesController { * @RequirePermission(Permission.WORKSPACE_ADMIN) * @Delete(':id') * async deleteWorkspace() { * // Only ADMIN or OWNER can execute this * } * * @RequirePermission(Permission.WORKSPACE_MEMBER) * @Get('tasks') * async getTasks() { * // Any workspace member can execute this * } * } * ``` */ @Injectable() export class PermissionGuard implements CanActivate { private readonly logger = new Logger(PermissionGuard.name); constructor( private readonly reflector: Reflector, private readonly prisma: PrismaService ) {} async canActivate(context: ExecutionContext): Promise { // Get required permission from decorator const requiredPermission = this.reflector.getAllAndOverride( PERMISSION_KEY, [context.getHandler(), context.getClass()] ); // If no permission is specified, allow access if (!requiredPermission) { return true; } const request = context.switchToHttp().getRequest(); // Note: Despite types, user/workspace may be null if guards didn't run // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const userId = request.user?.id; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const workspaceId = request.workspace?.id; if (!userId || !workspaceId) { this.logger.error( "PermissionGuard: Missing user or workspace context. Ensure AuthGuard and WorkspaceGuard are applied first." ); throw new ForbiddenException("Authentication and workspace context required"); } // Get user's role in the workspace const userRole = await this.getUserWorkspaceRole(userId, workspaceId); if (!userRole) { throw new ForbiddenException("You are not a member of this workspace"); } // Check if user's role meets the required permission const hasPermission = this.checkPermission(userRole, requiredPermission); if (!hasPermission) { this.logger.warn( `Permission denied: User ${userId} with role ${userRole} attempted to access ${requiredPermission} in workspace ${workspaceId}` ); throw new ForbiddenException(`Insufficient permissions. Required: ${requiredPermission}`); } // Attach role to request for convenience request.user.workspaceRole = userRole; this.logger.debug(`Permission granted: User ${userId} (${userRole}) → ${requiredPermission}`); return true; } /** * Fetches the user's role in a workspace * * SEC-API-3 FIX: Database errors are no longer swallowed as null role. * Connection timeouts, pool exhaustion, and other infrastructure errors * are propagated as 500 errors to avoid masking operational issues. */ private async getUserWorkspaceRole( userId: string, workspaceId: string ): Promise { try { const member = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId, }, }, select: { role: true, }, }); return member?.role ?? null; } catch (error) { // Only handle Prisma "not found" errors (P2025) as expected cases // All other database errors (connection, timeout, pool) should propagate if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025" // Record not found ) { return null; } // Log the error before propagating this.logger.error( `Database error during permission check: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error.stack : undefined ); // Propagate infrastructure errors as 500s, not permission denied throw new InternalServerErrorException("Failed to verify permissions"); } } /** * Checks if a user's role satisfies the required permission level */ private checkPermission(userRole: WorkspaceMemberRole, requiredPermission: Permission): boolean { switch (requiredPermission) { case Permission.WORKSPACE_OWNER: return userRole === WorkspaceMemberRole.OWNER; case Permission.WORKSPACE_ADMIN: return userRole === WorkspaceMemberRole.OWNER || userRole === WorkspaceMemberRole.ADMIN; case Permission.WORKSPACE_MEMBER: return ( userRole === WorkspaceMemberRole.OWNER || userRole === WorkspaceMemberRole.ADMIN || userRole === WorkspaceMemberRole.MEMBER ); case Permission.WORKSPACE_ANY: // Any role including GUEST return true; default: { const exhaustiveCheck: never = requiredPermission; this.logger.error(`Unknown permission: ${String(exhaustiveCheck)}`); return false; } } } }