Files
stack/apps/api/src/common/guards/workspace.guard.ts
Jason Woltje e3cba37e8c
All checks were successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
fix(api,web): resolve RLS context SQL error, workspace guard crash, and projects response unwrapping (#531)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-27 04:18:35 +00:00

170 lines
5.5 KiB
TypeScript

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<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
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<string, unknown> | 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<string, unknown> };
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<boolean> {
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");
}
}
}