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,40 @@
-- CreateEnum
CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
-- CreateTable
CREATE TABLE "teams" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "team_members" (
"team_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"role" "TeamMemberRole" NOT NULL DEFAULT 'MEMBER',
"joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "team_members_pkey" PRIMARY KEY ("team_id","user_id")
);
-- CreateIndex
CREATE INDEX "teams_workspace_id_idx" ON "teams"("workspace_id");
-- CreateIndex
CREATE INDEX "team_members_user_id_idx" ON "team_members"("user_id");
-- AddForeignKey
ALTER TABLE "teams" ADD CONSTRAINT "teams_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,319 @@
-- Row-Level Security (RLS) for Multi-Tenant Isolation
-- This migration enables RLS on all tenant-scoped tables and creates policies
-- to ensure users can only access data within their authorized workspaces.
-- =============================================================================
-- ENABLE RLS ON TENANT-SCOPED TABLES
-- =============================================================================
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE activity_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE memory_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE domains ENABLE ROW LEVEL SECURITY;
ALTER TABLE ideas ENABLE ROW LEVEL SECURITY;
ALTER TABLE relationships ENABLE ROW LEVEL SECURITY;
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_layouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entry_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_links ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entry_versions ENABLE ROW LEVEL SECURITY;
-- =============================================================================
-- HELPER FUNCTION: Check if user is workspace member
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_member(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- HELPER FUNCTION: Check if user is workspace owner/admin
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
AND role IN ('OWNER', 'ADMIN')
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- HELPER FUNCTION: Get current user ID from session variable
-- =============================================================================
-- Usage in API: SET LOCAL app.current_user_id = 'user-uuid';
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_user_id', TRUE), '')::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
-- =============================================================================
-- WORKSPACES: Users can only see workspaces they're members of
-- =============================================================================
CREATE POLICY workspace_member_access ON workspaces
FOR ALL
USING (
id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = current_user_id()
)
);
-- =============================================================================
-- WORKSPACE_MEMBERS: Users can see members of their workspaces
-- =============================================================================
CREATE POLICY workspace_members_access ON workspace_members
FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = current_user_id()
)
);
-- =============================================================================
-- TEAMS: Users can see teams in their workspaces
-- =============================================================================
CREATE POLICY teams_workspace_access ON teams
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- TEAM_MEMBERS: Users can see team members in their workspaces
-- =============================================================================
CREATE POLICY team_members_access ON team_members
FOR ALL
USING (
team_id IN (
SELECT id FROM teams
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- TASKS: Users can only see tasks in their workspaces
-- =============================================================================
CREATE POLICY tasks_workspace_access ON tasks
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- EVENTS: Users can only see events in their workspaces
-- =============================================================================
CREATE POLICY events_workspace_access ON events
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- PROJECTS: Users can only see projects in their workspaces
-- =============================================================================
CREATE POLICY projects_workspace_access ON projects
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- ACTIVITY_LOGS: Users can only see activity in their workspaces
-- =============================================================================
CREATE POLICY activity_logs_workspace_access ON activity_logs
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- MEMORY_EMBEDDINGS: Users can only see embeddings in their workspaces
-- =============================================================================
CREATE POLICY memory_embeddings_workspace_access ON memory_embeddings
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- DOMAINS: Users can only see domains in their workspaces
-- =============================================================================
CREATE POLICY domains_workspace_access ON domains
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- IDEAS: Users can only see ideas in their workspaces
-- =============================================================================
CREATE POLICY ideas_workspace_access ON ideas
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- RELATIONSHIPS: Users can only see relationships in their workspaces
-- =============================================================================
CREATE POLICY relationships_workspace_access ON relationships
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- AGENTS: Users can only see agents in their workspaces
-- =============================================================================
CREATE POLICY agents_workspace_access ON agents
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- AGENT_SESSIONS: Users can only see agent sessions in their workspaces
-- =============================================================================
CREATE POLICY agent_sessions_workspace_access ON agent_sessions
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- USER_LAYOUTS: Users can only see their own layouts in their workspaces
-- =============================================================================
CREATE POLICY user_layouts_workspace_access ON user_layouts
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
AND user_id = current_user_id()
);
-- =============================================================================
-- KNOWLEDGE_ENTRIES: Users can only see entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entries_workspace_access ON knowledge_entries
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- KNOWLEDGE_TAGS: Users can only see tags in their workspaces
-- =============================================================================
CREATE POLICY knowledge_tags_workspace_access ON knowledge_tags
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- KNOWLEDGE_ENTRY_TAGS: Users can see tags for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entry_tags_access ON knowledge_entry_tags
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_LINKS: Users can see links between entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_links_access ON knowledge_links
FOR ALL
USING (
source_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_EMBEDDINGS: Users can see embeddings for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_embeddings_access ON knowledge_embeddings
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_ENTRY_VERSIONS: Users can see versions for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entry_versions_access ON knowledge_entry_versions
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- GRANT USAGE TO APPLICATION ROLE
-- =============================================================================
-- The application should connect with a role that has appropriate permissions.
-- By default, we assume the owner of the database has full access.
-- In production, create a dedicated role with limited permissions.
-- Example (uncomment and customize for production):
-- GRANT USAGE ON SCHEMA public TO mosaic_app;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mosaic_app;
-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO mosaic_app;

View File

@@ -45,6 +45,12 @@ enum WorkspaceMemberRole {
GUEST
}
enum TeamMemberRole {
OWNER
ADMIN
MEMBER
}
enum ActivityAction {
CREATED
UPDATED
@@ -126,6 +132,7 @@ model User {
// Relations
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[]
teamMemberships TeamMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
createdEvents Event[] @relation("EventCreator")
@@ -150,21 +157,22 @@ model Workspace {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)
members WorkspaceMember[]
tasks Task[]
events Event[]
projects Project[]
activityLogs ActivityLog[]
memoryEmbeddings MemoryEmbedding[]
domains Domain[]
ideas Idea[]
relationships Relationship[]
agents Agent[]
agentSessions AgentSession[]
userLayouts UserLayout[]
knowledgeEntries KnowledgeEntry[]
knowledgeTags KnowledgeTag[]
owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)
members WorkspaceMember[]
teams Team[]
tasks Task[]
events Event[]
projects Project[]
activityLogs ActivityLog[]
memoryEmbeddings MemoryEmbedding[]
domains Domain[]
ideas Idea[]
relationships Relationship[]
agents Agent[]
agentSessions AgentSession[]
userLayouts UserLayout[]
knowledgeEntries KnowledgeEntry[]
knowledgeTags KnowledgeTag[]
@@index([ownerId])
@@map("workspaces")
@@ -185,6 +193,38 @@ model WorkspaceMember {
@@map("workspace_members")
}
model Team {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
name String
description String? @db.Text
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
members TeamMember[]
@@index([workspaceId])
@@map("teams")
}
model TeamMember {
teamId String @map("team_id") @db.Uuid
userId String @map("user_id") @db.Uuid
role TeamMemberRole @default(MEMBER)
joinedAt DateTime @default(now()) @map("joined_at") @db.Timestamptz
// Relations
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([teamId, userId])
@@index([userId])
@@map("team_members")
}
model Task {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
@@ -625,36 +665,36 @@ model Verification {
// ============================================
model KnowledgeEntry {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
// Identity
slug String
title String
slug String
title String
// Content
content String @db.Text
contentHtml String? @map("content_html") @db.Text
content String @db.Text
contentHtml String? @map("content_html") @db.Text
summary String?
// Status
status EntryStatus @default(DRAFT)
visibility Visibility @default(PRIVATE)
status EntryStatus @default(DRAFT)
visibility Visibility @default(PRIVATE)
// Audit
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid
updatedBy String @map("updated_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid
updatedBy String @map("updated_by") @db.Uuid
// Relations
tags KnowledgeEntryTag[]
outgoingLinks KnowledgeLink[] @relation("SourceEntry")
incomingLinks KnowledgeLink[] @relation("TargetEntry")
outgoingLinks KnowledgeLink[] @relation("SourceEntry")
incomingLinks KnowledgeLink[] @relation("TargetEntry")
versions KnowledgeEntryVersion[]
embedding KnowledgeEmbedding?
@@unique([workspaceId, slug])
@@index([workspaceId, status])
@@index([workspaceId, updatedAt])
@@ -664,39 +704,39 @@ model KnowledgeEntry {
}
model KnowledgeEntryVersion {
id String @id @default(uuid()) @db.Uuid
entryId String @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
version Int
title String
content String @db.Text
summary String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid
changeNote String? @map("change_note")
id String @id @default(uuid()) @db.Uuid
entryId String @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
version Int
title String
content String @db.Text
summary String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid
changeNote String? @map("change_note")
@@unique([entryId, version])
@@index([entryId, version])
@@map("knowledge_entry_versions")
}
model KnowledgeLink {
id String @id @default(uuid()) @db.Uuid
sourceId String @map("source_id") @db.Uuid
source KnowledgeEntry @relation("SourceEntry", fields: [sourceId], references: [id], onDelete: Cascade)
targetId String @map("target_id") @db.Uuid
target KnowledgeEntry @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
id String @id @default(uuid()) @db.Uuid
sourceId String @map("source_id") @db.Uuid
source KnowledgeEntry @relation("SourceEntry", fields: [sourceId], references: [id], onDelete: Cascade)
targetId String @map("target_id") @db.Uuid
target KnowledgeEntry @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
// Link metadata
linkText String @map("link_text")
context String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
linkText String @map("link_text")
context String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@unique([sourceId, targetId])
@@index([sourceId])
@@index([targetId])
@@ -704,17 +744,17 @@ model KnowledgeLink {
}
model KnowledgeTag {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
name String
slug String
color String?
description String?
entries KnowledgeEntryTag[]
entries KnowledgeEntryTag[]
@@unique([workspaceId, slug])
@@index([workspaceId])
@@map("knowledge_tags")
@@ -723,10 +763,10 @@ model KnowledgeTag {
model KnowledgeEntryTag {
entryId String @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
tagId String @map("tag_id") @db.Uuid
tag KnowledgeTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId String @map("tag_id") @db.Uuid
tag KnowledgeTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([entryId, tagId])
@@index([entryId])
@@index([tagId])
@@ -734,16 +774,16 @@ model KnowledgeEntryTag {
}
model KnowledgeEmbedding {
id String @id @default(uuid()) @db.Uuid
entryId String @unique @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
id String @id @default(uuid()) @db.Uuid
entryId String @unique @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
embedding Unsupported("vector(1536)")
model String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@index([entryId])
@@map("knowledge_embeddings")
}

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