# Multi-Tenant Row-Level Security (RLS) ## Overview Mosaic Stack implements multi-tenancy using PostgreSQL Row-Level Security (RLS) to ensure complete data isolation between workspaces at the database level. This provides defense-in-depth security, preventing data leakage even if application-level checks fail. ## Architecture ### Core Concepts 1. **Workspaces**: Top-level tenant containers 2. **Teams**: Sub-groups within workspaces for collaboration 3. **Workspace Members**: Users associated with workspaces (OWNER, ADMIN, MEMBER, GUEST roles) 4. **Team Members**: Users associated with teams (OWNER, ADMIN, MEMBER roles) ### Database Schema ``` User ├── WorkspaceMember (role: OWNER, ADMIN, MEMBER, GUEST) │ └── Workspace │ ├── Team │ │ └── TeamMember (role: OWNER, ADMIN, MEMBER) │ ├── Task, Event, Project, etc. │ └── All tenant-scoped data ``` ## RLS Implementation ### Tables with RLS Enabled All tenant-scoped tables have RLS enabled: - `workspaces` - `workspace_members` - `teams` - `team_members` - `tasks` - `events` - `projects` - `activity_logs` - `memory_embeddings` - `domains` - `ideas` - `relationships` - `agents` - `agent_sessions` - `user_layouts` - `knowledge_entries` - `knowledge_tags` - `knowledge_entry_tags` - `knowledge_links` - `knowledge_embeddings` - `knowledge_entry_versions` ### Helper Functions The RLS implementation uses several helper functions: #### `current_user_id()` Returns the current user's UUID from the session variable `app.current_user_id`. ```sql SELECT current_user_id(); -- Returns UUID or NULL ``` #### `is_workspace_member(workspace_uuid, user_uuid)` Checks if a user is a member of a workspace. ```sql SELECT is_workspace_member('workspace-uuid', 'user-uuid'); -- Returns BOOLEAN ``` #### `is_workspace_admin(workspace_uuid, user_uuid)` Checks if a user is an owner or admin of a workspace. ```sql SELECT is_workspace_admin('workspace-uuid', 'user-uuid'); -- Returns BOOLEAN ``` ### Policy Pattern All RLS policies follow a consistent pattern: ```sql CREATE POLICY _workspace_access ON
FOR ALL USING ( is_workspace_member(workspace_id, current_user_id()) ); ``` For tables without direct `workspace_id`, policies join through parent tables: ```sql 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()) ) ); ``` ## API Integration ### Setting the Current User Before executing any queries, the API **must** set the current user ID: ```typescript import { prisma } from '@mosaic/database'; async function withUserContext( userId: string, fn: () => Promise ): Promise { await prisma.$executeRaw`SET LOCAL app.current_user_id = ${userId}`; return fn(); } ``` ### Example Usage in API Routes ```typescript import { withUserContext } from '@/lib/db-context'; // In a tRPC procedure or API route export async function getTasks(userId: string, workspaceId: string) { return withUserContext(userId, async () => { // RLS automatically filters to workspaces the user can access const tasks = await prisma.task.findMany({ where: { workspaceId, }, }); return tasks; }); } ``` ### Middleware Pattern For tRPC or Next.js API routes, use middleware to automatically set the user context: ```typescript // middleware/auth.ts export async function withAuth(userId: string, handler: () => Promise) { await prisma.$executeRaw`SET LOCAL app.current_user_id = ${userId}`; return handler(); } // In tRPC procedure .query(async ({ ctx }) => { return withAuth(ctx.user.id, async () => { // All queries here are automatically scoped to the user's workspaces return prisma.workspace.findMany(); }); }); ``` ### Transaction Pattern For transactions, set the user context within the transaction: ```typescript await prisma.$transaction(async (tx) => { await tx.$executeRaw`SET LOCAL app.current_user_id = ${userId}`; // All queries in this transaction are scoped to the user 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; }); ``` ## Security Considerations ### Defense in Depth RLS provides **database-level** security, but should not be the only security layer: 1. **Application-level validation**: Always validate workspace access in your API 2. **RLS policies**: Prevent data leakage at the database level 3. **API authentication**: Verify user identity before setting `app.current_user_id` ### Important Notes - **RLS does not replace application logic**: Application code should still check permissions - **Performance**: RLS policies use indexes on `workspace_id` for efficiency - **Bypass for admin operations**: System-level operations may need to bypass RLS using a privileged connection - **Testing**: Always test RLS policies with different user roles ### Admin/System Operations For system-level operations (migrations, admin tasks), use a separate connection or temporarily disable RLS: ```sql -- Disable RLS for superuser (use with caution) SET SESSION AUTHORIZATION postgres; -- Or use a connection with a superuser role ``` ## Testing RLS ### Manual Testing ```sql -- Set user context SET app.current_user_id = 'user-uuid-here'; -- Try to query another workspace (should return empty) SELECT * FROM tasks WHERE workspace_id = 'other-workspace-uuid'; -- Query your own workspace (should return data) SELECT * FROM tasks WHERE workspace_id = 'my-workspace-uuid'; ``` ### Automated Tests ```typescript import { prisma } from '@mosaic/database'; describe('RLS Policies', () => { it('should prevent cross-workspace access', async () => { const user1Id = 'user-1-uuid'; const user2Id = 'user-2-uuid'; const workspace1Id = 'workspace-1-uuid'; const workspace2Id = 'workspace-2-uuid'; // Set context as user 1 await prisma.$executeRaw`SET LOCAL app.current_user_id = ${user1Id}`; // Should only see workspace 1's tasks const tasks = await prisma.task.findMany(); expect(tasks.every(t => t.workspaceId === workspace1Id)).toBe(true); }); }); ``` ## Migration Strategy ### Existing Data If migrating from a non-RLS setup: 1. Enable RLS on tables (already done in migration `20260129221004_add_rls_policies`) 2. Create policies (already done) 3. Update application code to set `app.current_user_id` 4. Test thoroughly with different user roles ### Rolling Back RLS If needed, RLS can be disabled per table: ```sql ALTER TABLE DISABLE ROW LEVEL SECURITY; ``` Or policies can be dropped: ```sql DROP POLICY ON ; ``` ## Performance Optimization ### Indexes All tenant-scoped tables have indexes on `workspace_id`: ```sql CREATE INDEX tasks_workspace_id_idx ON tasks(workspace_id); ``` ### Function Optimization Helper functions are marked as `STABLE` and `SECURITY DEFINER` for optimal performance: ```sql CREATE OR REPLACE FUNCTION is_workspace_member(workspace_uuid UUID, user_uuid UUID) RETURNS BOOLEAN AS $$ ... $$ LANGUAGE plpgsql STABLE SECURITY DEFINER; ``` ### Query Planning Check query plans to ensure RLS policies are efficient: ```sql EXPLAIN ANALYZE SELECT * FROM tasks WHERE workspace_id = 'workspace-uuid'; ``` ## Future Enhancements ### Team-Level Permissions Currently, RLS ensures workspace-level isolation. Future enhancements could include: - Team-specific data visibility - Project-level permissions - Fine-grained role-based access control (RBAC) ### Audit Logging RLS policies could be extended to automatically log all data access: ```sql CREATE POLICY tasks_audit ON tasks FOR ALL USING ( is_workspace_member(workspace_id, current_user_id()) AND log_access('tasks', id, current_user_id()) IS NOT NULL ); ``` ## References - [PostgreSQL Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - [Prisma Raw Database Access](https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access) - [Multi-Tenancy Patterns](https://docs.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenancy-models) ## Migration Files - `20260129220941_add_team_model` - Adds Team and TeamMember models - `20260129221004_add_rls_policies` - Enables RLS and creates policies ## Summary Row-Level Security in Mosaic Stack provides: ✅ **Database-level tenant isolation** ✅ **Defense in depth security** ✅ **Automatic filtering of all queries** ✅ **Performance-optimized with indexes** ✅ **Extensible for future RBAC features** Always remember: **Set `app.current_user_id` before executing queries!**