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:
Jason Woltje
2026-01-29 16:13:09 -06:00
parent 1edea83e38
commit 244e50c806
6 changed files with 1415 additions and 79 deletions

View 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,
};