import { Injectable, CanActivate, ExecutionContext, ForbiddenException, BadRequestException, Logger, } from "@nestjs/common"; 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` * - URL parameter: `:workspaceId` * - Request body: `workspaceId` field * * Priority: Header > Param > Body * * 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, or request body)" ); } // 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 * 2. :workspaceId URL parameter * 3. workspaceId in request body */ private extractWorkspaceId(request: AuthenticatedRequest): string | undefined { // 1. Check header const headerWorkspaceId = request.headers["x-workspace-id"]; if (typeof headerWorkspaceId === "string") { return headerWorkspaceId; } // 2. Check URL params const paramWorkspaceId = request.params.workspaceId; if (paramWorkspaceId) { return paramWorkspaceId; } // 3. Check request body const bodyWorkspaceId = request.body.workspaceId; if (typeof bodyWorkspaceId === "string") { return bodyWorkspaceId; } return undefined; } /** * Verifies that a user is a member of the specified workspace */ 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) { this.logger.error( `Failed to verify workspace membership: ${error instanceof Error ? error.message : "Unknown error"}`, error instanceof Error ? error.stack : undefined ); return false; } } }