mcpField
M
multi-tenancy

multi-tenancy

github.com/dadbodgeoff開発79252 回インストール10 週間前に更新
リポジトリ

multi-tenancy について

複数組織向けの SaaS アプリを構築する開発者向けガイドです。テナント分離モデルを比較し、テナントコンテキスト、ミドルウェア、スコープ付きクエリ、設定管理の TypeScript と Python の例を紹介します。

主な機能

  1. 013 種類の分離モデルを比較
  2. 02テナントコンテキストとミドルウェア
  3. 03テナント別クエリの例
  4. 04テナントごとの設定管理
  5. 05TypeScript と Python の例

活用シーン

  1. 01B2B SaaS アプリの構築
  2. 02ホワイトラベル基盤で組織データを分離
  3. 03企業向けソフトウェアのテナント設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dadbodgeoff/drift --skill multi-tenancy

インストール後、Claude Code で/multi-tenancyを入力して読み込まれたか確認します。

詳細

マルチテナンシー

複数の組織に安全にサービスを提供する SaaS アプリを構築します。

このスキルを使用する場面

  • B2B SaaS アプリケーション
  • ホワイトラベルプラットフォーム
  • エンタープライズソフトウェア
  • 複数の組織にサービスを提供するあらゆるアプリ

分離モデル

1. 共有データベース、共有スキーマ(ほとんどの場合に推奨)
┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  users: id, tenant_id, email, ...                   │
│  orders: id, tenant_id, user_id, ...                │
│  products: id, tenant_id, name, ...                 │
│                                                     │
│  All tables have tenant_id column                   │
└─────────────────────────────────────────────────────┘
2. 共有データベース、テナントごとのスキーマ
┌─────────────────────────────────────────────────────┐
│                   Database                           │
│                                                     │
│  tenant_acme.users                                  │
│  tenant_acme.orders                                 │
│  tenant_globex.users                                │
│  tenant_globex.orders                               │
└─────────────────────────────────────────────────────┘
3. テナントごとのデータベース(エンタープライズ)
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  acme_db     │  │  globex_db   │  │  initech_db  │
│              │  │              │  │              │
│  users       │  │  users       │  │  users       │
│  orders      │  │  orders      │  │  orders      │
└──────────────┘  └──────────────┘  └──────────────┘

TypeScript の実装

テナントコンテキスト
// 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.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-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: { ... } });
テナントごとの設定
// 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);
  }
}

データベーススキーマ

-- 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 の実装

# 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 ミドルウェア
# 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)

ベストプラクティス

  1. 必ず tenant_id で絞り込む - クライアントから提供された ID だけを信頼しないでください
  2. ミドルウェアを使用する - テナントの解決を一元化します
  3. tenant_id にインデックスを付ける - すべてのテナントスコープ付きテーブルにこのインデックスが必要です
  4. RLS を検討する - PostgreSQL に保護レイヤーを追加します
  5. テナント設定をキャッシュする - 繰り返しの参照を避けます

よくある間違い

  • クエリでテナントフィルターを忘れる(データ漏えいにつながります!)
  • ユーザーのテナントアクセス権を検証しない
  • テナント固有のロジックをハードコードする
  • tenant_id カラムにインデックスがない
  • テナント間の参照を許可する

コメント