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
8.8 KiB
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
- Workspaces: Top-level tenant containers
- Teams: Sub-groups within workspaces for collaboration
- Workspace Members: Users associated with workspaces (OWNER, ADMIN, MEMBER, GUEST roles)
- 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:
workspacesworkspace_membersteamsteam_memberstaskseventsprojectsactivity_logsmemory_embeddingsdomainsideasrelationshipsagentsagent_sessionsuser_layoutsknowledge_entriesknowledge_tagsknowledge_entry_tagsknowledge_linksknowledge_embeddingsknowledge_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.
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.
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.
SELECT is_workspace_admin('workspace-uuid', 'user-uuid'); -- Returns BOOLEAN
Policy Pattern
All RLS policies follow a consistent pattern:
CREATE POLICY <table>_workspace_access ON <table>
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
For tables without direct workspace_id, policies join through parent tables:
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:
import { prisma } from '@mosaic/database';
async function withUserContext<T>(
userId: string,
fn: () => Promise<T>
): Promise<T> {
await prisma.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
return fn();
}
Example Usage in API Routes
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:
// middleware/auth.ts
export async function withAuth(userId: string, handler: () => Promise<any>) {
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:
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:
- Application-level validation: Always validate workspace access in your API
- RLS policies: Prevent data leakage at the database level
- 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_idfor 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:
-- Disable RLS for superuser (use with caution)
SET SESSION AUTHORIZATION postgres;
-- Or use a connection with a superuser role
Testing RLS
Manual Testing
-- 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
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:
- Enable RLS on tables (already done in migration
20260129221004_add_rls_policies) - Create policies (already done)
- Update application code to set
app.current_user_id - Test thoroughly with different user roles
Rolling Back RLS
If needed, RLS can be disabled per table:
ALTER TABLE <table_name> DISABLE ROW LEVEL SECURITY;
Or policies can be dropped:
DROP POLICY <policy_name> ON <table_name>;
Performance Optimization
Indexes
All tenant-scoped tables have indexes on workspace_id:
CREATE INDEX tasks_workspace_id_idx ON tasks(workspace_id);
Function Optimization
Helper functions are marked as STABLE and SECURITY DEFINER for optimal performance:
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:
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:
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
Migration Files
20260129220941_add_team_model- Adds Team and TeamMember models20260129221004_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!