S
Shineos Tech Blog
エンタープライズSaaSに必須な「堅牢なテナント分離」の設計パターンとセキュリティ実装

エンタープライズSaaSに必須な「堅牢なテナント分離」の設計パターンとセキュリティ実装

| Shineos Dev Team
Share:

テナント分離とは?

エンタープライズSaaSにおいて、 テナント分離(Tenant Isolation) は最も重要なセキュリティ要件の一つです。複数の顧客(テナント)が同じアプリケーションを利用する環境で、あるテナントのデータが別のテナントから閲覧・変更されることを防ぐ仕組みです。

2024年には大手SaaSサービスでもテナント分離の不備によるデータ漏洩事故が複数報告されており、エンタープライズ顧客の信頼を失う致命的な問題となっています。

本記事では、堅牢なテナント分離を実現するための設計パターンと、実装時に注意すべきセキュリティポイントを解説します。

まとめ

分離方式セキュリティコスト拡張性適用シーン
データベース分離🔴 最高🔴 高🟡 中エンタープライズ顧客向け
スキーマ分離🟡 高🟡 中🟢 高中規模顧客向け
行レベル分離🟡 中🟢 低🟢 高スタートアップ・小規模顧客向け
ハイブリッド🟢 柔軟🟡 中🟢 高顧客規模に応じて使い分け

なぜテナント分離が重要なのか?

データ漏洩のリスク

テナント分離が不十分な場合、以下のようなリスクが発生します:

  1. SQLインジェクション: 悪意あるクエリで他テナントのデータにアクセス
  2. APIの脆弱性: テナントIDの検証漏れによる不正アクセス
  3. キャッシュの汚染: 共有キャッシュから別テナントのデータが漏洩
  4. バグによる誤表示: コードの不具合で別テナントのデータが表示される

エンタープライズ要件

エンタープライズ顧客と契約する際、以下の要件が求められます:

  • SOC 2 Type II 認証: テナント分離の監査証跡
  • GDPR対応: データの物理的分離の証明
  • SLA保証: 他テナントの影響を受けない性能保証

テナント分離の3つの設計パターン

テナント分離パターン比較

パターン1:データベース分離(Database per Tenant)

各テナントに専用のデータベースを割り当てる方式です。

メリット

  • 最高レベルのセキュリティ: 物理的に完全分離
  • パフォーマンス保証: 他テナントの影響を受けない
  • データ移行が容易: テナント単位でのバックアップ・復元

デメリット

  • 運用コストが高い: データベース数に比例してコスト増
  • スキーマ変更が複雑: すべてのDBに変更を適用する必要

実装例

// データベース分離の実装
import { PrismaClient } from '@prisma/client';

// テナントごとにデータベース接続を管理
class TenantDatabaseManager {
  private connections: Map<string, PrismaClient> = new Map();
  
  /**
   * テナント専用のDB接続を取得
   */
  async getConnection(tenantId: string): Promise<PrismaClient> {
    // 既存の接続があれば再利用
    if (this.connections.has(tenantId)) {
      return this.connections.get(tenantId)!;
    }
    
    // テナント用のデータベースURLを構築
    const databaseUrl = this.buildDatabaseUrl(tenantId);
    
    // 新しい接続を作成
    const prisma = new PrismaClient({
      datasources: {
        db: {
          url: databaseUrl
        }
      }
    });
    
    this.connections.set(tenantId, prisma);
    return prisma;
  }
  
  private buildDatabaseUrl(tenantId: string): string {
    const baseUrl = process.env.DATABASE_BASE_URL;
    // テナントIDに基づいたDB名を生成
    return `${baseUrl}/tenant_${tenantId}`;
  }
  
  /**
   * 接続プールのクリーンアップ
   */
  async cleanup(): Promise<void> {
    const disconnectPromises = Array.from(this.connections.values())
      .map(client => client.$disconnect());
    
    await Promise.all(disconnectPromises);
    this.connections.clear();
  }
}

// 使用例
const dbManager = new TenantDatabaseManager();

async function getUserData(tenantId: string, userId: string) {
  const db = await dbManager.getConnection(tenantId);
  
  // このクエリはテナント専用DBに対して実行される
  const user = await db.user.findUnique({
    where: { id: userId }
  });
  
  return user;
}

パターン2:スキーマ分離(Schema per Tenant)

1つのデータベース内で、テナントごとに専用スキーマを作成する方式です。

メリット

  • コストと分離のバランス: DB数を抑えつつ論理的に分離
  • 管理の効率化: 1つのDBインスタンスで複数テナントを管理

デメリット

  • パフォーマンスへの影響: リソースを共有するため他テナントの影響あり
  • スキーマ管理の複雑さ: マイグレーション時の注意が必要

実装例(PostgreSQL)

// スキーマ分離の実装
import { Pool } from 'pg';

class SchemaBasedTenantManager {
  private pool: Pool;
  
  constructor() {
    this.pool = new Pool({
      connectionString: process.env.DATABASE_URL,
      max: 20 // 接続プールサイズ
    });
  }
  
  /**
   * テナント専用スキーマでクエリを実行
   */
  async executeInTenantSchema<T>(
    tenantId: string,
    query: string,
    params: any[] = []
  ): Promise<T> {
    const client = await this.pool.connect();
    
    try {
      // スキーマをテナント専用に設定
      await client.query(`SET search_path TO tenant_${tenantId}, public`);
      
      // クエリを実行
      const result = await client.query(query, params);
      return result.rows as T;
    } finally {
      // スキーマをリセット
      await client.query('RESET search_path');
      client.release();
    }
  }
  
  /**
   * 新しいテナント用のスキーマを作成
   */
  async createTenantSchema(tenantId: string): Promise<void> {
    const client = await this.pool.connect();
    
    try {
      const schemaName = `tenant_${tenantId}`;
      
      // スキーマを作成
      await client.query(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
      
      // テーブルを作成(例)
      await client.query(`
        CREATE TABLE IF NOT EXISTS ${schemaName}.users (
          id UUID PRIMARY KEY,
          email VARCHAR(255) NOT NULL UNIQUE,
          name VARCHAR(255) NOT NULL,
          created_at TIMESTAMP DEFAULT NOW()
        )
      `);
      
      console.log(`✅ Schema created for tenant: ${tenantId}`);
    } catch (error) {
      console.error(`❌ Failed to create schema for tenant ${tenantId}:`, error);
      throw error;
    } finally {
      client.release();
    }
  }
}

// 使用例
const schemaManager = new SchemaBasedTenantManager();

async function getTenantUsers(tenantId: string) {
  const users = await schemaManager.executeInTenantSchema(
    tenantId,
    'SELECT * FROM users WHERE active = $1',
    [true]
  );
  
  return users;
}

パターン3:行レベル分離(Row-Level Isolation)

すべてのテーブルに tenant_id カラムを追加し、クエリ時にフィルタリングする方式です。

メリット

  • コストが最も低い: 1つのDBで全テナントを管理
  • スケーラビリティ: 新しいテナント追加が容易
  • クロステナント分析: 全体のデータ分析が可能

デメリット

  • セキュリティリスク: クエリミスでデータ漏洩の可能性
  • パフォーマンス: テーブルサイズが大きくなるとインデックス効率低下

実装例(Row Level Security)

// 行レベル分離の実装(Prisma + PostgreSQL RLS)

// 1. Prismaスキーマ定義
/*
model User {
  id        String   @id @default(uuid())
  tenantId  String   @map("tenant_id") // 必須フィールド
  email     String   @unique
  name      String
  createdAt DateTime @default(now()) @map("created_at")
  
  @@index([tenantId])
  @@map("users")
}
*/

// 2. PostgreSQLのRow Level Security設定
async function setupRowLevelSecurity(db: any) {
  await db.$executeRaw`
    -- RLSを有効化
    ALTER TABLE users ENABLE ROW LEVEL SECURITY;
    
    -- テナントIDに基づくポリシーを作成
    CREATE POLICY tenant_isolation ON users
      USING (tenant_id = current_setting('app.current_tenant')::text);
    
    -- アプリケーションロールを作成
    CREATE ROLE app_user;
    GRANT SELECT, INSERT, UPDATE, DELETE ON users TO app_user;
  `;
}

// 3. ミドルウェアでテナントIDを設定
class TenantContextMiddleware {
  async setTenantContext(
    tenantId: string,
    prisma: PrismaClient
  ): Promise<void> {
    // PostgreSQLのセッション変数にテナントIDを設定
    await prisma.$executeRaw`
      SET LOCAL app.current_tenant = ${tenantId}
    `;
  }
}

// 4. テナント対応のリポジトリ
class TenantAwareRepository {
  constructor(
    private prisma: PrismaClient,
    private tenantId: string
  ) {}
  
  /**
   * ユーザー作成(テナントID自動付与)
   */
  async createUser(data: { email: string; name: string }) {
    // テナントIDを必ず含める
    return await this.prisma.user.create({
      data: {
        ...data,
        tenantId: this.tenantId
      }
    });
  }
  
  /**
   * ユーザー取得(テナントID自動フィルタ)
   */
  async getUsers() {
    // WHERE句に必ずtenantIdを含める
    return await this.prisma.user.findMany({
      where: {
        tenantId: this.tenantId
      }
    });
  }
  
  /**
   * ユーザー更新(テナント検証)
   */
  async updateUser(userId: string, data: { name: string }) {
    // 更新前にテナント所属を検証
    const user = await this.prisma.user.findFirst({
      where: {
        id: userId,
        tenantId: this.tenantId // ✅ 必須の検証
      }
    });
    
    if (!user) {
      throw new Error('User not found or access denied');
    }
    
    return await this.prisma.user.update({
      where: { id: userId },
      data
    });
  }
}

// 5. API実装例
import { Request, Response } from 'express';

async function getUsersHandler(req: Request, res: Response) {
  const tenantId = req.headers['x-tenant-id'] as string;
  
  if (!tenantId) {
    return res.status(400).json({ error: 'Tenant ID required' });
  }
  
  const repository = new TenantAwareRepository(prisma, tenantId);
  const users = await repository.getUsers();
  
  res.json({ users });
}

セキュリティ実装のベストプラクティス

1. テナントIDの検証を徹底する

// ❌ 危険な実装
async function getOrder(orderId: string) {
  return await db.order.findUnique({
    where: { id: orderId }
  });
}

// ✅ 安全な実装
async function getOrder(tenantId: string, orderId: string) {
  const order = await db.order.findFirst({
    where: {
      id: orderId,
      tenantId: tenantId // 必須の検証
    }
  });
  
  if (!order) {
    throw new Error('Order not found or access denied');
  }
  
  return order;
}

2. ミドルウェアでテナントコンテキストを管理

// Express ミドルウェア例
import { Request, Response, NextFunction } from 'express';

interface TenantRequest extends Request {
  tenantId?: string;
}

async function tenantMiddleware(
  req: TenantRequest,
  res: Response,
  next: NextFunction
) {
  // JWTトークンからテナントIDを抽出
  const token = req.headers.authorization?.replace('Bearer ', '');
  
  if (!token) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  try {
    const decoded = verifyToken(token);
    req.tenantId = decoded.tenantId;
    
    // テナントの存在確認
    const tenant = await db.tenant.findUnique({
      where: { id: req.tenantId }
    });
    
    if (!tenant || !tenant.active) {
      return res.status(403).json({ error: 'Tenant not found or inactive' });
    }
    
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// 使用例
app.use('/api', tenantMiddleware);

app.get('/api/users', async (req: TenantRequest, res) => {
  const users = await getUsersForTenant(req.tenantId!);
  res.json({ users });
});

3. データベースレベルの制約を追加

-- テナントIDにNOT NULL制約を追加
ALTER TABLE users 
  ALTER COLUMN tenant_id SET NOT NULL;

-- 複合ユニーク制約でテナント内の一意性を保証
ALTER TABLE users
  ADD CONSTRAINT unique_email_per_tenant 
  UNIQUE (tenant_id, email);

-- テナントIDのインデックスを作成(パフォーマンス向上)
CREATE INDEX idx_users_tenant_id ON users(tenant_id);

-- 外部キー制約でテナントの存在を保証
ALTER TABLE users
  ADD CONSTRAINT fk_users_tenant
  FOREIGN KEY (tenant_id) REFERENCES tenants(id)
  ON DELETE CASCADE;

実際のビジネスシーンでの活用例

ケース1:エンタープライズ顧客の要求に対応

状況: 大手企業から「データを物理的に分離してほしい」という要求

解決策: ハイブリッドアプローチを採用

// テナント設定に基づいて分離方式を切り替え
class AdaptiveTenantManager {
  async getRepository(tenantId: string) {
    const tenant = await this.getTenantConfig(tenantId);
    
    switch (tenant.isolationLevel) {
      case 'DATABASE':
        // エンタープライズ顧客は専用DB
        return new DatabaseIsolatedRepository(tenantId);
      
      case 'SCHEMA':
        // 中規模顧客は専用スキーマ
        return new SchemaIsolatedRepository(tenantId);
      
      case 'ROW':
        // 小規模顧客は行レベル分離
        return new RowLevelRepository(tenantId);
      
      default:
        throw new Error(`Unknown isolation level: ${tenant.isolationLevel}`);
    }
  }
  
  private async getTenantConfig(tenantId: string) {
    return await db.tenant.findUnique({
      where: { id: tenantId },
      select: {
        id: true,
        isolationLevel: true,
        databaseUrl: true
      }
    });
  }
}

ケース2:監査ログでテナント間アクセスを検出

// 監査ログの実装
class AuditLogger {
  async logDataAccess(params: {
    tenantId: string;
    userId: string;
    action: string;
    resourceType: string;
    resourceId: string;
    success: boolean;
  }) {
    await db.auditLog.create({
      data: {
        ...params,
        timestamp: new Date(),
        ipAddress: this.getClientIp()
      }
    });
    
    // 異常なアクセスパターンを検出
    await this.detectAnomalies(params);
  }
  
  private async detectAnomalies(params: any) {
    // 過去5分間に同一ユーザーが複数テナントにアクセスしていないか確認
    const recentAccesses = await db.auditLog.findMany({
      where: {
        userId: params.userId,
        timestamp: {
          gte: new Date(Date.now() - 5 * 60 * 1000)
        }
      },
      distinct: ['tenantId']
    });
    
    if (recentAccesses.length > 1) {
      console.warn('🚨 Potential cross-tenant access detected:', {
        userId: params.userId,
        tenants: recentAccesses.map(a => a.tenantId)
      });
      
      // アラートを送信
      await this.sendSecurityAlert(params.userId, recentAccesses);
    }
  }
}

ケース3:テナント分離のテスト自動化

// テナント分離のE2Eテスト
describe('Tenant Isolation Tests', () => {
  let tenant1: Tenant;
  let tenant2: Tenant;
  
  beforeEach(async () => {
    tenant1 = await createTenant({ name: 'Tenant 1' });
    tenant2 = await createTenant({ name: 'Tenant 2' });
  });
  
  it('should not allow cross-tenant data access', async () => {
    // Tenant 1にユーザーを作成
    const user1 = await createUser({
      tenantId: tenant1.id,
      email: 'user1@tenant1.com'
    });
    
    // Tenant 2のコンテキストでTenant 1のユーザーにアクセス試行
    const repository = new TenantAwareRepository(prisma, tenant2.id);
    
    const user = await repository.getUser(user1.id);
    
    // アクセスできないことを確認
    expect(user).toBeNull();
  });
  
  it('should enforce tenant_id in all queries', async () => {
    // クエリログを監視
    const queryLog: string[] = [];
    prisma.$use(async (params, next) => {
      queryLog.push(JSON.stringify(params));
      return next(params);
    });
    
    // ユーザー取得
    const repository = new TenantAwareRepository(prisma, tenant1.id);
    await repository.getUsers();
    
    // すべてのクエリにtenant_idフィルタが含まれることを確認
    const allQueriesHaveTenantId = queryLog.every(log => 
      log.includes('tenant_id') || log.includes('tenantId')
    );
    
    expect(allQueriesHaveTenantId).toBe(true);
  });
});

よくある質問

どの分離方式を選べばよいですか?

以下の基準で選択してください:

  • 年商1億円以上のエンタープライズ顧客が多い: データベース分離
  • 中小企業が中心で、一部大手顧客がいる: ハイブリッド(スキーマ + DB分離)
  • スタートアップ向けで顧客数が多い: 行レベル分離

パフォーマンスへの影響はありますか?

はい、分離方式によって影響があります:

  • データベース分離: 接続プールの管理が複雑になる
  • スキーマ分離: スキーマ切り替えのオーバーヘッド(数ms)
  • 行レベル分離: インデックスサイズが大きくなる

既存システムから移行できますか?

可能です。段階的な移行を推奨します:

  1. Phase 1: 行レベル分離を導入
  2. Phase 2: 大口顧客をスキーマ分離に移行
  3. Phase 3: エンタープライズ顧客をDB分離に移行

おわりに

堅牢なテナント分離は、エンタープライズSaaSの信頼性を支える基盤です。セキュリティ要件とコストのバランスを考慮し、適切な分離方式を選択することが重要です。

重要なのは、 テナントIDの検証を徹底すること と、 監査ログで異常を検出できる仕組みを構築すること です。一度のデータ漏洩で失う信頼は、取り戻すことが困難です。

私たちShineosでは、マルチテナントSaaSのアーキテクチャ設計や、セキュリティ監査対応の支援を行っています。テナント分離の実装やSOC 2認証取得など、お気軽にご相談ください。

参考リンク