All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
createAuthMiddleware was calling SET LOCAL on the raw PrismaClient outside of any transaction. In PostgreSQL, SET LOCAL without a transaction acts as a session-level SET, which can leak RLS context to subsequent requests sharing the same pooled connection, enabling cross-tenant data access. Wrapped the setCurrentUser call and downstream handler execution inside a $transaction block so SET LOCAL is automatically reverted when the transaction ends (on both success and failure). Added comprehensive test suite for db-context module verifying: - RLS context is set on the transaction client, not the raw client - next() executes inside the transaction boundary - Authentication errors prevent any transaction from starting - Errors in downstream handlers propagate correctly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
/**
|
|
* Database Context Utilities for Row-Level Security (RLS)
|
|
*
|
|
* This module provides utilities for setting the current user context
|
|
* in the database, enabling Row-Level Security policies to automatically
|
|
* filter queries to only the data the user is authorized to access.
|
|
*
|
|
* @see docs/design/multi-tenant-rls.md for full documentation
|
|
*/
|
|
|
|
import { PrismaClient } from "@prisma/client";
|
|
|
|
// Global prisma instance for standalone usage
|
|
// Note: In NestJS controllers/services, inject PrismaService instead
|
|
let prisma: PrismaClient | null = null;
|
|
|
|
function getPrismaInstance(): PrismaClient {
|
|
prisma ??= new PrismaClient();
|
|
return prisma;
|
|
}
|
|
|
|
/**
|
|
* Sets the current user ID for RLS policies within a transaction context.
|
|
* Must be called before executing any queries that rely on RLS.
|
|
*
|
|
* Note: SET LOCAL must be used within a transaction to ensure it's scoped
|
|
* correctly with connection pooling. This is a low-level function - prefer
|
|
* using withUserContext or withWorkspaceContext for most use cases.
|
|
*
|
|
* @param userId - The UUID of the current user
|
|
* @param client - Prisma client (required - must be a transaction client)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* await prisma.$transaction(async (tx) => {
|
|
* await setCurrentUser(userId, tx);
|
|
* const tasks = await tx.task.findMany(); // Automatically filtered by RLS
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function setCurrentUser(userId: string, client: PrismaClient): Promise<void> {
|
|
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
|
}
|
|
|
|
/**
|
|
* Sets the current workspace ID for RLS policies within a transaction context.
|
|
* Must be called before executing any workspace-scoped queries.
|
|
*
|
|
* @param workspaceId - The UUID of the current workspace
|
|
* @param client - Prisma client (required - must be a transaction client)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* await prisma.$transaction(async (tx) => {
|
|
* await setCurrentWorkspace(workspaceId, tx);
|
|
* const tasks = await tx.task.findMany(); // Filtered by workspace via RLS
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function setCurrentWorkspace(
|
|
workspaceId: string,
|
|
client: PrismaClient
|
|
): Promise<void> {
|
|
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
|
}
|
|
|
|
/**
|
|
* Sets both user and workspace context for RLS policies.
|
|
* This is the recommended way to set context for workspace-scoped operations.
|
|
*
|
|
* @param userId - The UUID of the current user
|
|
* @param workspaceId - The UUID of the current workspace
|
|
* @param client - Prisma client (required - must be a transaction client)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* await prisma.$transaction(async (tx) => {
|
|
* await setWorkspaceContext(userId, workspaceId, tx);
|
|
* const tasks = await tx.task.findMany(); // Filtered by workspace via RLS
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function setWorkspaceContext(
|
|
userId: string,
|
|
workspaceId: string,
|
|
client: PrismaClient
|
|
): Promise<void> {
|
|
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
|
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
|
}
|
|
|
|
/**
|
|
* Clears the current user context within a transaction.
|
|
* Use this to reset the session or when switching users.
|
|
*
|
|
* Note: SET LOCAL is automatically cleared at transaction end,
|
|
* so explicit clearing is typically unnecessary.
|
|
*
|
|
* @param client - Prisma client (required - must be a transaction client)
|
|
*/
|
|
export async function clearCurrentUser(client: PrismaClient): Promise<void> {
|
|
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
|
}
|
|
|
|
/**
|
|
* Clears both user and workspace context within a transaction.
|
|
*
|
|
* Note: SET LOCAL is automatically cleared at transaction end,
|
|
* so explicit clearing is typically unnecessary.
|
|
*
|
|
* @param client - Prisma client (required - must be a transaction client)
|
|
*/
|
|
export async function clearWorkspaceContext(client: PrismaClient): Promise<void> {
|
|
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
|
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
|
}
|
|
|
|
/**
|
|
* Executes a function with the current user context set within a transaction.
|
|
* Automatically sets the user context and ensures it's properly scoped.
|
|
*
|
|
* @param userId - The UUID of the current user
|
|
* @param fn - The function to execute with user context (receives transaction client)
|
|
* @returns The result of the function
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const tasks = await withUserContext(userId, async (tx) => {
|
|
* return tx.task.findMany({
|
|
* where: { workspaceId }
|
|
* });
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function withUserContext<T>(
|
|
userId: string,
|
|
fn: (tx: PrismaClient) => Promise<T>
|
|
): Promise<T> {
|
|
const prismaClient = getPrismaInstance();
|
|
return prismaClient.$transaction(async (tx) => {
|
|
await setCurrentUser(userId, tx as PrismaClient);
|
|
return fn(tx as PrismaClient);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Executes a function within a transaction with the current user context set.
|
|
* Useful for operations that need atomicity and RLS.
|
|
*
|
|
* @param userId - The UUID of the current user
|
|
* @param fn - The function to execute with transaction and user context
|
|
* @returns The result of the function
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const workspace = await withUserTransaction(userId, async (tx) => {
|
|
* const workspace = await tx.workspace.create({
|
|
* data: { name: 'New Workspace', ownerId: userId }
|
|
* });
|
|
*
|
|
* await tx.workspaceMember.create({
|
|
* data: {
|
|
* workspaceId: workspace.id,
|
|
* userId,
|
|
* role: 'OWNER'
|
|
* }
|
|
* });
|
|
*
|
|
* return workspace;
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function withUserTransaction<T>(
|
|
userId: string,
|
|
fn: (tx: PrismaClient) => Promise<T>
|
|
): Promise<T> {
|
|
const prismaClient = getPrismaInstance();
|
|
return prismaClient.$transaction(async (tx) => {
|
|
await setCurrentUser(userId, tx as PrismaClient);
|
|
return fn(tx as PrismaClient);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Executes a function with workspace context (user + workspace) set within a transaction.
|
|
* This is the recommended way for workspace-scoped operations.
|
|
*
|
|
* @param userId - The UUID of the current user
|
|
* @param workspaceId - The UUID of the current workspace
|
|
* @param fn - The function to execute with context (receives transaction client)
|
|
* @returns The result of the function
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const tasks = await withWorkspaceContext(userId, workspaceId, async (tx) => {
|
|
* return tx.task.findMany({
|
|
* where: { status: 'IN_PROGRESS' }
|
|
* });
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function withWorkspaceContext<T>(
|
|
userId: string,
|
|
workspaceId: string,
|
|
fn: (tx: PrismaClient) => Promise<T>
|
|
): Promise<T> {
|
|
const prismaClient = getPrismaInstance();
|
|
return prismaClient.$transaction(async (tx) => {
|
|
await setWorkspaceContext(userId, workspaceId, tx as PrismaClient);
|
|
return fn(tx as PrismaClient);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Higher-order function that wraps a handler with user context.
|
|
* Useful for API routes and tRPC procedures.
|
|
*
|
|
* @param handler - The handler function that requires user context
|
|
* @returns A new function that sets user context before calling the handler
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // In a tRPC procedure
|
|
* export const getTasks = withAuth(async ({ ctx, input }) => {
|
|
* return prisma.task.findMany({
|
|
* where: { workspaceId: input.workspaceId }
|
|
* });
|
|
* });
|
|
* ```
|
|
*/
|
|
export function withAuth<TArgs extends { ctx: { userId: string } }, TResult>(
|
|
handler: (args: TArgs) => Promise<TResult>
|
|
) {
|
|
return async (args: TArgs): Promise<TResult> => {
|
|
return withUserContext(args.ctx.userId, () => handler(args));
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verifies that a user has access to a specific workspace.
|
|
* This is an additional application-level check on top of RLS.
|
|
*
|
|
* @param userId - The UUID of the user
|
|
* @param workspaceId - The UUID of the workspace
|
|
* @returns True if the user is a member of the workspace
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* if (!await verifyWorkspaceAccess(userId, workspaceId)) {
|
|
* throw new Error('Access denied');
|
|
* }
|
|
* ```
|
|
*/
|
|
export async function verifyWorkspaceAccess(userId: string, workspaceId: string): Promise<boolean> {
|
|
return withUserContext(userId, async (tx) => {
|
|
const member = await tx.workspaceMember.findUnique({
|
|
where: {
|
|
workspaceId_userId: {
|
|
workspaceId,
|
|
userId,
|
|
},
|
|
},
|
|
});
|
|
return member !== null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets all workspaces accessible by a user.
|
|
* Uses RLS to automatically filter to authorized workspaces.
|
|
*
|
|
* @param userId - The UUID of the user
|
|
* @returns Array of workspaces the user can access
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const workspaces = await getUserWorkspaces(userId);
|
|
* ```
|
|
*/
|
|
export async function getUserWorkspaces(userId: string) {
|
|
return withUserContext(userId, async (tx) => {
|
|
return tx.workspace.findMany({
|
|
include: {
|
|
members: {
|
|
where: { userId },
|
|
select: { role: true },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if a user has admin access to a workspace.
|
|
*
|
|
* @param userId - The UUID of the user
|
|
* @param workspaceId - The UUID of the workspace
|
|
* @returns True if the user is an OWNER or ADMIN
|
|
*/
|
|
export async function isWorkspaceAdmin(userId: string, workspaceId: string): Promise<boolean> {
|
|
return withUserContext(userId, async (tx) => {
|
|
const member = await tx.workspaceMember.findUnique({
|
|
where: {
|
|
workspaceId_userId: {
|
|
workspaceId,
|
|
userId,
|
|
},
|
|
},
|
|
});
|
|
return member?.role === "OWNER" || member?.role === "ADMIN";
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Executes a query without RLS restrictions.
|
|
* ⚠️ USE WITH EXTREME CAUTION - Only for system-level operations!
|
|
*
|
|
* @param fn - The function to execute without RLS
|
|
* @returns The result of the function
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Only use for system operations like migrations or admin cleanup
|
|
* const allTasks = await withoutRLS(async () => {
|
|
* return prisma.task.findMany();
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function withoutRLS<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
|
const prismaClient = getPrismaInstance();
|
|
return prismaClient.$transaction(async (tx) => {
|
|
await clearCurrentUser(tx as PrismaClient);
|
|
return fn(tx as PrismaClient);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Middleware factory for tRPC that automatically sets user context.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const authMiddleware = createAuthMiddleware();
|
|
*
|
|
* const protectedProcedure = publicProcedure.use(authMiddleware);
|
|
* ```
|
|
*/
|
|
export function createAuthMiddleware(client: PrismaClient) {
|
|
return async function authMiddleware(opts: {
|
|
ctx: { userId?: string };
|
|
next: () => Promise<unknown>;
|
|
}): Promise<unknown> {
|
|
const { userId } = opts.ctx;
|
|
if (!userId) {
|
|
throw new Error("User not authenticated");
|
|
}
|
|
|
|
// SEC-API-27: SET LOCAL must be called inside a transaction boundary.
|
|
// Without a transaction, SET LOCAL behaves as a session-level SET,
|
|
// which can leak RLS context to other requests via connection pooling.
|
|
return client.$transaction(async (tx) => {
|
|
await setCurrentUser(userId, tx as PrismaClient);
|
|
return opts.next();
|
|
});
|
|
};
|
|
}
|
|
|
|
export default {
|
|
setCurrentUser,
|
|
setCurrentWorkspace,
|
|
setWorkspaceContext,
|
|
clearCurrentUser,
|
|
clearWorkspaceContext,
|
|
withUserContext,
|
|
withUserTransaction,
|
|
withWorkspaceContext,
|
|
withAuth,
|
|
verifyWorkspaceAccess,
|
|
getUserWorkspaces,
|
|
isWorkspaceAdmin,
|
|
withoutRLS,
|
|
createAuthMiddleware,
|
|
};
|