fix(api): fix RLS context, DTO validation, and error handling

- Wrap SET LOCAL in transactions for proper connection pooling
- Make workspaceId optional in query DTOs (derived from guards)
- Replace Error throws with UnauthorizedException in activity controller
- Update workspace guard to remove RLS context setting
- Document that services should use withUserContext/withUserTransaction
This commit is contained in:
Jason Woltje
2026-01-29 20:14:27 -06:00
parent 95833fb4ea
commit 26a0df835f
9 changed files with 63 additions and 50 deletions

View File

@@ -22,51 +22,58 @@ function getPrismaInstance(): PrismaClient {
}
/**
* Sets the current user ID for RLS policies.
* 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 - Optional Prisma client (defaults to global prisma)
* @param client - Prisma client (required - must be a transaction client)
*
* @example
* ```typescript
* await setCurrentUser(userId);
* const tasks = await prisma.task.findMany(); // Automatically filtered by RLS
* 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
client: PrismaClient
): Promise<void> {
const prismaClient = client || getPrismaInstance();
await prismaClient.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
}
/**
* Clears the current user context.
* Clears the current user context within a transaction.
* Use this to reset the session or when switching users.
*
* @param client - Optional Prisma client (defaults to global prisma)
* 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
client: PrismaClient
): Promise<void> {
const prismaClient = client || getPrismaInstance();
await prismaClient.$executeRaw`SET LOCAL app.current_user_id = NULL`;
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
}
/**
* Executes a function with the current user context set.
* Automatically sets and clears the user context.
* 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
* @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 () => {
* return prisma.task.findMany({
* const tasks = await withUserContext(userId, async (tx) => {
* return tx.task.findMany({
* where: { workspaceId }
* });
* });
@@ -74,16 +81,13 @@ export async function clearCurrentUser(
*/
export async function withUserContext<T>(
userId: string,
fn: () => Promise<T>
fn: (tx: any) => Promise<T>
): Promise<T> {
await setCurrentUser(userId);
try {
return await fn();
} finally {
// Note: LOCAL settings are automatically cleared at transaction end
// but we explicitly clear here for consistency
await clearCurrentUser();
}
const prismaClient = getPrismaInstance();
return prismaClient.$transaction(async (tx) => {
await setCurrentUser(userId, tx as PrismaClient);
return fn(tx);
});
}
/**
@@ -168,9 +172,8 @@ export async function verifyWorkspaceAccess(
userId: string,
workspaceId: string
): Promise<boolean> {
const prismaClient = getPrismaInstance();
return withUserContext(userId, async () => {
const member = await prismaClient.workspaceMember.findUnique({
return withUserContext(userId, async (tx) => {
const member = await tx.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
@@ -195,9 +198,8 @@ export async function verifyWorkspaceAccess(
* ```
*/
export async function getUserWorkspaces(userId: string) {
const prismaClient = getPrismaInstance();
return withUserContext(userId, async () => {
return prismaClient.workspace.findMany({
return withUserContext(userId, async (tx) => {
return tx.workspace.findMany({
include: {
members: {
where: { userId },
@@ -219,9 +221,8 @@ export async function isWorkspaceAdmin(
userId: string,
workspaceId: string
): Promise<boolean> {
const prismaClient = getPrismaInstance();
return withUserContext(userId, async () => {
const member = await prismaClient.workspaceMember.findUnique({
return withUserContext(userId, async (tx) => {
const member = await tx.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,