dadbodgeoff

multi-tenancy

Implement multi-tenant architecture with tenant isolation, data separation, and per-tenant configuration. Supports shared database and schema-per-tenant models.

dadbodgeoff 784 62 Updated 6mo ago
GitHub

Install

npx skillscat add dadbodgeoff/drift/multi-tenancy

Install via the SkillsCat registry.

About this skill

We need to produce a 2-3 sentence plain-text summary, objective, factual, no marketing language, no superlatives, no calls to action. Must be at most 60 words. No quotes, no markdown, no bullet points, no headings. Just plain text. We need to explain what the skill does, what problem it solves, when to use it. From description: Implement multi-tenant architecture with tenant isolation, data separation, per-tenant configuration. Supports shared database and schema-per-tenant models. Problem: need to serve multiple organizations securely, avoid data leakage, manage separate configs.

SKILL.md

Multi-Tenancy

Build SaaS apps that serve multiple organizations securely.

When to Use This Skill

  • B2B SaaS applications
  • White-label platforms
  • Enterprise software
  • Any app serving multiple organizations

Isolation Models

1. Shared Database, Shared Schema (Recommended for most)

┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  users: id, tenant_id, email, ...                   │
│  orders: id, tenant_id, user_id, ...                │
│  products: id, tenant_id, name, ...                 │
│                                                     │
│  All tables have tenant_id column                   │
└─────────────────────────────────────────────────────┘

2. Shared Database, Schema per Tenant

┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  tenant_acme.users                                  │
│  tenant_acme.orders                                 │
│  tenant_globex.users                                │
│  tenant_globex.orders                               │
└─────────────────────────────────────────────────────┘

3. Database per Tenant (Enterprise)

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  acme_db     │  │  globex_db   │  │  initech_db  │
│              │  │              │  │              │
│  users       │  │  users       │  │  users       │
│  orders      │  │  orders      │  │  orders      │
└──────────────┘  └──────────────┘  └──────────────┘

TypeScript Implementation

Tenant Context

// tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';

interface TenantContext {
  tenantId: string;
  tenantSlug: string;
  plan: 'free' | 'pro' | 'enterprise';
  features: string[];
}

const tenantStorage = new AsyncLocalStorage<TenantContext>();

export function getTenant(): TenantContext {
  const tenant = tenantStorage.getStore();
  if (!tenant) {
    throw new Error('No tenant context available');
  }
  return tenant;
}

export function runWithTenant<T>(tenant: TenantContext, fn: () => T): T {
  return tenantStorage.run(tenant, fn);
}

export { tenantStorage, TenantContext };

Tenant Middleware

// tenant-middleware.ts
import { Request, Response, NextFunction } from 'express';
import { runWithTenant, TenantContext } from './tenant-context';

interface TenantMiddlewareOptions {
  headerName?: string;
  subdomainExtract?: boolean;
  pathExtract?: boolean;
}

export function tenantMiddleware(options: TenantMiddlewareOptions = {}) {
  const { headerName = 'x-tenant-id', subdomainExtract = true } = options;

  return async (req: Request, res: Response, next: NextFunction) => {
    let tenantId: string | undefined;

    // Strategy 1: Header
    tenantId = req.headers[headerName.toLowerCase()] as string;

    // Strategy 2: Subdomain (acme.yourapp.com)
    if (!tenantId && subdomainExtract) {
      const host = req.hostname;
      const subdomain = host.split('.')[0];
      if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
        tenantId = subdomain;
      }
    }

    // Strategy 3: Path (/t/acme/dashboard)
    if (!tenantId && options.pathExtract) {
      const match = req.path.match(/^\/t\/([^/]+)/);
      if (match) {
        tenantId = match[1];
      }
    }

    // Strategy 4: User's default tenant (from JWT)
    if (!tenantId && req.user?.defaultTenantId) {
      tenantId = req.user.defaultTenantId;
    }

    if (!tenantId) {
      return res.status(400).json({ error: 'Tenant not specified' });
    }

    // Load tenant from database
    const tenant = await db.tenants.findUnique({
      where: { id: tenantId },
      select: { id: true, slug: true, plan: true, features: true },
    });

    if (!tenant) {
      return res.status(404).json({ error: 'Tenant not found' });
    }

    // Check user has access to tenant
    if (req.user) {
      const membership = await db.tenantMemberships.findFirst({
        where: { userId: req.user.id, tenantId: tenant.id },
      });
      if (!membership) {
        return res.status(403).json({ error: 'Access denied to tenant' });
      }
      req.userRole = membership.role;
    }

    // Run request with tenant context
    runWithTenant(
      {
        tenantId: tenant.id,
        tenantSlug: tenant.slug,
        plan: tenant.plan,
        features: tenant.features,
      },
      () => next()
    );
  };
}

Tenant-Scoped Queries

// tenant-prisma.ts
import { PrismaClient } from '@prisma/client';
import { getTenant } from './tenant-context';

// Extend Prisma with automatic tenant filtering
export function createTenantPrisma(prisma: PrismaClient) {
  return prisma.$extends({
    query: {
      $allModels: {
        async findMany({ model, operation, args, query }) {
          // Auto-add tenant filter
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async findFirst({ model, operation, args, query }) {
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async findUnique({ model, operation, args, query }) {
          // For unique queries, verify tenant after fetch
          const result = await query(args);
          if (result && result.tenantId !== getTenant().tenantId) {
            return null; // Hide cross-tenant data
          }
          return result;
        },
        async create({ model, operation, args, query }) {
          // Auto-set tenant on create
          args.data = { ...args.data, tenantId: getTenant().tenantId };
          return query(args);
        },
        async update({ model, operation, args, query }) {
          // Ensure update is scoped to tenant
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
        async delete({ model, operation, args, query }) {
          args.where = { ...args.where, tenantId: getTenant().tenantId };
          return query(args);
        },
      },
    },
  });
}

// Usage
const tenantDb = createTenantPrisma(prisma);

// These are automatically scoped to current tenant
const users = await tenantDb.user.findMany();
const order = await tenantDb.order.create({ data: { ... } });

Per-Tenant Configuration

// tenant-config.ts
interface TenantConfig {
  branding: {
    logo?: string;
    primaryColor?: string;
    companyName?: string;
  };
  features: {
    maxUsers: number;
    maxStorage: number;
    apiAccess: boolean;
    sso: boolean;
  };
  integrations: {
    slack?: { webhookUrl: string };
    stripe?: { customerId: string };
  };
}

class TenantConfigService {
  private cache = new Map<string, TenantConfig>();

  async getConfig(tenantId: string): Promise<TenantConfig> {
    if (this.cache.has(tenantId)) {
      return this.cache.get(tenantId)!;
    }

    const tenant = await db.tenants.findUnique({
      where: { id: tenantId },
      include: { config: true },
    });

    const config = this.buildConfig(tenant);
    this.cache.set(tenantId, config);
    return config;
  }

  private buildConfig(tenant: Tenant): TenantConfig {
    // Merge plan defaults with tenant overrides
    const planDefaults = PLAN_CONFIGS[tenant.plan];
    return {
      branding: { ...tenant.config?.branding },
      features: { ...planDefaults.features, ...tenant.config?.features },
      integrations: { ...tenant.config?.integrations },
    };
  }

  invalidateCache(tenantId: string) {
    this.cache.delete(tenantId);
  }
}

Database Schema

-- Tenants table
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug VARCHAR(50) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  plan VARCHAR(50) DEFAULT 'free',
  features TEXT[] DEFAULT '{}',
  config JSONB DEFAULT '{}',
  created_at TIMESTAMP DEFAULT NOW()
);

-- Users belong to tenants via memberships
CREATE TABLE tenant_memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
  role VARCHAR(50) DEFAULT 'member',
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(user_id, tenant_id)
);

-- All data tables have tenant_id
CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  user_id UUID REFERENCES users(id),
  -- ... other columns
  created_at TIMESTAMP DEFAULT NOW()
);

-- Index for tenant queries
CREATE INDEX idx_orders_tenant ON orders(tenant_id);

-- Row Level Security (optional, extra protection)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

Python Implementation

# tenant_context.py
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Optional

@dataclass
class TenantContext:
    tenant_id: str
    tenant_slug: str
    plan: str
    features: list[str]

_tenant_context: ContextVar[Optional[TenantContext]] = ContextVar(
    "tenant_context", default=None
)

def get_tenant() -> TenantContext:
    tenant = _tenant_context.get()
    if not tenant:
        raise RuntimeError("No tenant context")
    return tenant

def set_tenant(tenant: TenantContext):
    return _tenant_context.set(tenant)

FastAPI Middleware

# tenant_middleware.py
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware

class TenantMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        tenant_id = request.headers.get("x-tenant-id")
        
        if not tenant_id:
            # Try subdomain
            host = request.headers.get("host", "")
            subdomain = host.split(".")[0]
            if subdomain not in ["www", "app", "api"]:
                tenant_id = subdomain

        if not tenant_id:
            raise HTTPException(400, "Tenant not specified")

        tenant = await db.tenants.find_unique(where={"id": tenant_id})
        if not tenant:
            raise HTTPException(404, "Tenant not found")

        token = set_tenant(TenantContext(
            tenant_id=tenant.id,
            tenant_slug=tenant.slug,
            plan=tenant.plan,
            features=tenant.features,
        ))

        try:
            response = await call_next(request)
            return response
        finally:
            _tenant_context.reset(token)

Best Practices

  1. Always filter by tenant_id - Never trust client-provided IDs alone
  2. Use middleware - Centralize tenant resolution
  3. Index tenant_id - Every tenant-scoped table needs this index
  4. Consider RLS - Extra protection layer in PostgreSQL
  5. Cache tenant config - Avoid repeated lookups

Common Mistakes

  • Forgetting tenant filter on queries (data leak!)
  • Not validating user's tenant access
  • Hardcoding tenant-specific logic
  • No index on tenant_id columns
  • Allowing cross-tenant references