vt-c-multi-tenancy¶
Patterns and validation for multi-tenant B2B SaaS applications. Activates when working on tenant isolation, data segregation, or cross-tenant access controls.
Plugin: core-standards
Category: Architecture
Command: /vt-c-multi-tenancy
Multi-Tenancy Patterns¶
This skill provides guidance for implementing secure multi-tenant architecture in B2B SaaS applications.
When This Skill Activates¶
- Working on authentication/authorization logic
- Implementing database queries that access tenant data
- Creating APIs that serve multiple tenants
- Reviewing code for tenant isolation
Core Principles¶
1. Tenant Isolation is Non-Negotiable¶
Every data access must be scoped to a tenant. There are no exceptions.
// ❌ NEVER - Unscoped query
const users = await db.user.findMany();
// ✅ ALWAYS - Tenant-scoped query
const users = await db.user.findMany({
where: { tenantId: currentTenant.id }
});
2. Tenant Context Propagation¶
The tenant context must flow through every layer:
// Request → Middleware → Service → Repository → Database
interface TenantContext {
tenantId: string;
userId: string;
permissions: string[];
}
// Middleware extracts and validates tenant
function tenantMiddleware(req: Request): TenantContext {
const tenantId = extractTenantFromJWT(req);
validateTenantAccess(tenantId, req.user);
return { tenantId, userId: req.user.id, permissions: req.user.permissions };
}
3. Database Strategies¶
Row-Level Security (PostgreSQL)
-- Enable RLS on tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Create policy
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Set tenant in transaction
SET LOCAL app.current_tenant = 'tenant-uuid';
Application-Level Filtering
// Base repository with tenant scoping
class TenantScopedRepository<T> {
constructor(private tenantId: string) {}
async findMany(where: Partial<T>): Promise<T[]> {
return this.db.findMany({
where: { ...where, tenantId: this.tenantId }
});
}
}
Schema-per-Tenant (for high isolation requirements)
// Dynamically select schema based on tenant
const schema = `tenant_${tenantId}`;
await db.$executeRaw`SET search_path TO ${schema}`;
Validation Checklist¶
When reviewing multi-tenant code:
Data Access¶
- [ ] All queries include tenant ID filter
- [ ] No raw SQL without tenant scoping
- [ ] Joins don't leak cross-tenant data
- [ ] Aggregations are tenant-scoped
APIs¶
- [ ] Tenant extracted from JWT/session, not URL params
- [ ] Tenant ID in URL validated against JWT tenant
- [ ] Error messages don't leak tenant information
- [ ] Rate limits are per-tenant
Background Jobs¶
- [ ] Jobs include tenant context
- [ ] Job results scoped to originating tenant
- [ ] Scheduled jobs iterate tenants safely
Caching¶
- [ ] Cache keys include tenant ID
- [ ] No shared cache entries across tenants
- [ ] Cache invalidation is tenant-aware
Anti-Patterns to Avoid¶
1. Trust Client-Provided Tenant ID¶
// ❌ NEVER
const tenantId = req.body.tenantId;
// ✅ ALWAYS - Extract from authenticated session
const tenantId = req.user.tenantId;
2. Global Queries in Multi-Tenant Context¶
// ❌ NEVER - Admin endpoint without proper scoping
app.get('/admin/all-users', async (req, res) => {
const users = await db.user.findMany(); // Leaks all tenants!
});
// ✅ ALWAYS - Even admin views should scope properly
app.get('/admin/users', requireSuperAdmin, async (req, res) => {
// Super admin explicitly queries across tenants with audit logging
await auditLog('cross_tenant_access', req.user);
const users = await db.user.findMany();
});
3. Tenant ID in URLs Without Validation¶
// ❌ DANGEROUS - URL tenant not validated against session
app.get('/api/tenants/:tenantId/users', async (req, res) => {
const users = await db.user.findMany({
where: { tenantId: req.params.tenantId }
});
});
// ✅ SAFE - Validate URL tenant matches session
app.get('/api/tenants/:tenantId/users', async (req, res) => {
if (req.params.tenantId !== req.user.tenantId) {
throw new ForbiddenError('Tenant mismatch');
}
// Now safe to use
});
Testing Multi-Tenancy¶
Required Tests¶
describe('Tenant Isolation', () => {
it('should not return data from other tenants', async () => {
// Create data in tenant A
const tenantA = await createTenant();
const userA = await createUser({ tenantId: tenantA.id });
// Query as tenant B
const tenantB = await createTenant();
const result = await asUser({ tenantId: tenantB.id })
.get('/api/users');
// Should NOT include tenant A's user
expect(result.users.map(u => u.id)).not.toContain(userA.id);
});
it('should reject cross-tenant access attempts', async () => {
const tenantA = await createTenant();
const resourceA = await createResource({ tenantId: tenantA.id });
const tenantB = await createTenant();
const result = await asUser({ tenantId: tenantB.id })
.get(`/api/resources/${resourceA.id}`);
expect(result.status).toBe(404); // Not 403 - don't reveal existence
});
});
Migration Patterns¶
When adding multi-tenancy to existing code:
- Add tenant_id column to all relevant tables
- Backfill existing data with default tenant
- Add NOT NULL constraint after backfill
- Enable RLS policies or add application filtering
- Update all queries to include tenant filter
- Add tests for tenant isolation
- Audit logging for any cross-tenant operations