Skip to content

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:

  1. Add tenant_id column to all relevant tables
  2. Backfill existing data with default tenant
  3. Add NOT NULL constraint after backfill
  4. Enable RLS policies or add application filtering
  5. Update all queries to include tenant filter
  6. Add tests for tenant isolation
  7. Audit logging for any cross-tenant operations