feat(multi-tenant): add Team model and RLS policies
Implements #9, #10 - Team model with workspace membership - TeamMember model with role-based access (OWNER, ADMIN, MEMBER) - Row-Level Security policies for tenant isolation on 19 tables - Helper functions: current_user_id(), is_workspace_member(), is_workspace_admin() - Developer utilities in src/lib/db-context.ts for easy RLS integration - Comprehensive documentation in docs/design/multi-tenant-rls.md Database migrations: - 20260129220941_add_team_model: Adds Team and TeamMember tables - 20260129221004_add_rls_policies: Enables RLS and creates policies Security features: - Complete database-level tenant isolation - Automatic query filtering based on workspace membership - Defense-in-depth security with application and database layers - Performance-optimized with indexes on workspace_id
This commit is contained in:
275
apps/api/src/lib/db-context.ts
Normal file
275
apps/api/src/lib/db-context.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 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 { prisma } from '@mosaic/database';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Sets the current user ID for RLS policies.
|
||||
* Must be called before executing any queries that rely on RLS.
|
||||
*
|
||||
* @param userId - The UUID of the current user
|
||||
* @param client - Optional Prisma client (defaults to global prisma)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await setCurrentUser(userId);
|
||||
* const tasks = await prisma.task.findMany(); // Automatically filtered by RLS
|
||||
* ```
|
||||
*/
|
||||
export async function setCurrentUser(
|
||||
userId: string,
|
||||
client: PrismaClient = prisma
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current user context.
|
||||
* Use this to reset the session or when switching users.
|
||||
*
|
||||
* @param client - Optional Prisma client (defaults to global prisma)
|
||||
*/
|
||||
export async function clearCurrentUser(
|
||||
client: PrismaClient = prisma
|
||||
): Promise<void> {
|
||||
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.
|
||||
*
|
||||
* @param userId - The UUID of the current user
|
||||
* @param fn - The function to execute with user context
|
||||
* @returns The result of the function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tasks = await withUserContext(userId, async () => {
|
||||
* return prisma.task.findMany({
|
||||
* where: { workspaceId }
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function withUserContext<T>(
|
||||
userId: string,
|
||||
fn: () => 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await setCurrentUser(userId, tx);
|
||||
return fn(tx);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
const member = await prisma.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 () => {
|
||||
return prisma.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 () => {
|
||||
const member = await prisma.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: () => Promise<T>): Promise<T> {
|
||||
// Clear any existing user context
|
||||
await clearCurrentUser();
|
||||
return fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory for tRPC that automatically sets user context.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const authMiddleware = createAuthMiddleware();
|
||||
*
|
||||
* const protectedProcedure = publicProcedure.use(authMiddleware);
|
||||
* ```
|
||||
*/
|
||||
export function createAuthMiddleware() {
|
||||
return async function authMiddleware<TContext extends { userId?: string }>(
|
||||
opts: { ctx: TContext; next: () => Promise<any> }
|
||||
) {
|
||||
if (!opts.ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
await setCurrentUser(opts.ctx.userId);
|
||||
return opts.next();
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
setCurrentUser,
|
||||
clearCurrentUser,
|
||||
withUserContext,
|
||||
withUserTransaction,
|
||||
withAuth,
|
||||
verifyWorkspaceAccess,
|
||||
getUserWorkspaces,
|
||||
isWorkspaceAdmin,
|
||||
withoutRLS,
|
||||
createAuthMiddleware,
|
||||
};
|
||||
Reference in New Issue
Block a user