/** * 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 withUserTransaction 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 { await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`; } /** * 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 { await client.$executeRaw`SET LOCAL app.current_user_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( userId: string, fn: (tx: PrismaClient) => Promise ): Promise { 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( userId: string, fn: (tx: PrismaClient) => Promise ): Promise { const prismaClient = getPrismaInstance(); return prismaClient.$transaction(async (tx) => { await setCurrentUser(userId, 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( handler: (args: TArgs) => Promise ) { return async (args: TArgs): Promise => { 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 { 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 { 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(fn: (client: PrismaClient) => Promise): Promise { 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; }): Promise { if (!opts.ctx.userId) { throw new Error("User not authenticated"); } await setCurrentUser(opts.ctx.userId, client); return opts.next(); }; } export default { setCurrentUser, clearCurrentUser, withUserContext, withUserTransaction, withAuth, verifyWorkspaceAccess, getUserWorkspaces, isWorkspaceAdmin, withoutRLS, createAuthMiddleware, };