import { Injectable, CanActivate, ExecutionContext, ForbiddenException, BadRequestException, InternalServerErrorException, Logger, } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../../prisma/prisma.service"; import type { AuthenticatedRequest } from "../types/user.types"; /** * WorkspaceGuard ensures that: * 1. A workspace is specified in the request (header, param, or body) * 2. The authenticated user is a member of that workspace * * This guard should be used in combination with AuthGuard: * * @example * ```typescript * @Controller('tasks') * @UseGuards(AuthGuard, WorkspaceGuard) * export class TasksController { * @Get() * async getTasks(@Workspace() workspaceId: string) { * // workspaceId is verified and available * // Service layer must use withUserContext() for RLS * } * } * ``` * * The workspace ID can be provided via: * - Header: `X-Workspace-Id` (recommended) * - URL parameter: `:workspaceId` * - Request body: `workspaceId` field * - Query parameter: `?workspaceId=xxx` (backward compatibility) * * Priority: Header > Param > Body > Query * * Note: RLS context must be set at the service layer using withUserContext() * or withUserTransaction() to ensure proper transaction scoping with connection pooling. */ @Injectable() export class WorkspaceGuard implements CanActivate { private readonly logger = new Logger(WorkspaceGuard.name); constructor(private readonly prisma: PrismaService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const user = request.user; if (!user?.id) { throw new ForbiddenException("User not authenticated"); } // Extract workspace ID from request const workspaceId = this.extractWorkspaceId(request); if (!workspaceId) { throw new BadRequestException( "Workspace ID is required (via header X-Workspace-Id, URL parameter, request body, or query string)" ); } // Verify user is a member of the workspace const isMember = await this.verifyWorkspaceMembership(user.id, workspaceId); if (!isMember) { this.logger.warn( `Access denied: User ${user.id} is not a member of workspace ${workspaceId}` ); throw new ForbiddenException("You do not have access to this workspace"); } // Attach workspace info to request for convenience request.workspace = { id: workspaceId, }; // Also attach workspaceId to user object for backward compatibility if (request.user) { request.user.workspaceId = workspaceId; } this.logger.debug(`Workspace access granted: User ${user.id} → Workspace ${workspaceId}`); return true; } /** * Extracts workspace ID from request in order of priority: * 1. X-Workspace-Id header (recommended) * 2. :workspaceId URL parameter * 3. workspaceId in request body * 4. workspaceId query parameter (for backward compatibility) */ private extractWorkspaceId(request: AuthenticatedRequest): string | undefined { // 1. Check header (recommended approach) const headerWorkspaceId = request.headers["x-workspace-id"]; if (typeof headerWorkspaceId === "string") { return headerWorkspaceId; } // 2. Check URL params (:workspaceId in route) const paramWorkspaceId = request.params.workspaceId; if (paramWorkspaceId) { return paramWorkspaceId; } // 3. Check request body (body may be undefined for GET requests despite Express typings) const body = request.body as Record | undefined; if (body && typeof body.workspaceId === "string") { return body.workspaceId; } // 4. Check query string (backward compatibility for existing clients) // Access query property if it exists (may not be in all request types) const requestWithQuery = request as typeof request & { query?: Record }; const queryWorkspaceId = requestWithQuery.query?.workspaceId; if (typeof queryWorkspaceId === "string") { return queryWorkspaceId; } return undefined; } /** * Verifies that a user is a member of the specified workspace * * SEC-API-2 FIX: Database errors are no longer swallowed as "access denied". * Connection timeouts, pool exhaustion, and other infrastructure errors * are propagated as 500 errors to avoid masking operational issues. */ private async verifyWorkspaceMembership(userId: string, workspaceId: string): Promise { try { const member = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId, }, }, }); return member !== 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 false; } // Log the error before propagating this.logger.error( `Database error during workspace membership check: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error.stack : undefined ); // Propagate infrastructure errors as 500s, not access denied throw new InternalServerErrorException("Failed to verify workspace access"); } } }