エンタープライズSaaSに必須な「堅牢なテナント分離」の設計パターンとセキュリティ実装
テナント分離とは?
エンタープライズSaaSにおいて、 テナント分離(Tenant Isolation) は最も重要なセキュリティ要件の一つです。複数の顧客(テナント)が同じアプリケーションを利用する環境で、あるテナントのデータが別のテナントから閲覧・変更されることを防ぐ仕組みです。
2024年には大手SaaSサービスでもテナント分離の不備によるデータ漏洩事故が複数報告されており、エンタープライズ顧客の信頼を失う致命的な問題となっています。
本記事では、堅牢なテナント分離を実現するための設計パターンと、実装時に注意すべきセキュリティポイントを解説します。
まとめ
| 分離方式 | セキュリティ | コスト | 拡張性 | 適用シーン |
|---|---|---|---|---|
| データベース分離 | 🔴 最高 | 🔴 高 | 🟡 中 | エンタープライズ顧客向け |
| スキーマ分離 | 🟡 高 | 🟡 中 | 🟢 高 | 中規模顧客向け |
| 行レベル分離 | 🟡 中 | 🟢 低 | 🟢 高 | スタートアップ・小規模顧客向け |
| ハイブリッド | 🟢 柔軟 | 🟡 中 | 🟢 高 | 顧客規模に応じて使い分け |
なぜテナント分離が重要なのか?
データ漏洩のリスク
テナント分離が不十分な場合、以下のようなリスクが発生します:
- SQLインジェクション: 悪意あるクエリで他テナントのデータにアクセス
- APIの脆弱性: テナントIDの検証漏れによる不正アクセス
- キャッシュの汚染: 共有キャッシュから別テナントのデータが漏洩
- バグによる誤表示: コードの不具合で別テナントのデータが表示される
エンタープライズ要件
エンタープライズ顧客と契約する際、以下の要件が求められます:
- 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)
- 行レベル分離: インデックスサイズが大きくなる
既存システムから移行できますか?
可能です。段階的な移行を推奨します:
- Phase 1: 行レベル分離を導入
- Phase 2: 大口顧客をスキーマ分離に移行
- Phase 3: エンタープライズ顧客をDB分離に移行
おわりに
堅牢なテナント分離は、エンタープライズSaaSの信頼性を支える基盤です。セキュリティ要件とコストのバランスを考慮し、適切な分離方式を選択することが重要です。
重要なのは、 テナントIDの検証を徹底すること と、 監査ログで異常を検出できる仕組みを構築すること です。一度のデータ漏洩で失う信頼は、取り戻すことが困難です。
私たちShineosでは、マルチテナントSaaSのアーキテクチャ設計や、セキュリティ監査対応の支援を行っています。テナント分離の実装やSOC 2認証取得など、お気軽にご相談ください。