マルチテナントパターン
このガイドでは、適切なデータ分離、共有リソース、テナント間操作を備えたマルチテナントアプリケーションの実装パターンについて説明します。
このガイドを使用するタイミング
以下の場合にこのガイドを使用してください:
- テナント(顧客/組織)間でデータを分離する
- すべてのテナントで共通データを共有する
- ユーザーが複数のテナントに所属できるようにする
- テナント固有の設定を実装する
- テナント間でデータを同期する
マルチテナントアーキテクチャ
┌─────────────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Tenant A │ │ Tenant B │ │ Common │ │
│ │ PK: X#A │ │ PK: X#B │ │ PK: X#common│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DynamoDB Table │ │
│ │ PK: ENTITY#tenantCode | SK: identifier │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
テナントコンテキスト
テナントコードの正規化
getUserContext() が返すすべてのテナントコードは小文字に正規化されます。つまり、TenantA、TENANTA、tenanta はすべて tenanta として扱われます。CognitoのカスタムクレームやHTTPヘッダーでテナントコードを定義する際、大文字小文字は問いません。内部的には一貫したマッチングのため常に小文字になります。
テナントコンテキストの抽出
呼び出しコンテキストからテナント情報を抽出するヘルパーを作成します:
// helpers/context.ts
import { IInvoke, getUserContext, getAuthorizerClaims } from '@mbc-cqrs-serverless/core';
export interface CustomUserContext {
tenantCode: string;
userCode: string;
userId: string;
email?: string;
role?: string;
}
/**
* Get custom user context from invoke context (呼び出しコンテキストからカスタムユーザーコンテキストを取得)
*/
export function getCustomUserContext(invokeContext: IInvoke): CustomUserContext {
const userContext = getUserContext(invokeContext);
const claims = getAuthorizerClaims(invokeContext);
return {
tenantCode: userContext.tenantCode || DEFAULT_TENANT_CODE,
userCode: claims['custom:userCode'] || userContext.userId || '',
userId: userContext.userId || '',
email: claims.email,
role: claims['custom:role'],
};
}
/**
* Tenant code for shared/common data across all tenants (全テナント共通/共有データ用のテナントコード)
* Use this for master data, settings, and resources shared by all tenants (マスターデータ、設定、全テナント共有リソースに使用)
*/
export const TENANT_COMMON = 'common';
/**
* Default tenant code when no tenant is specified (テナントが指定されていない場合のデフォルトテナントコード)
* Used in single-tenant mode or when tenant context is not available (シングルテナントモードまたはテナントコンテキストが利用できない場合に使用)
*/
export const DEFAULT_TENANT_CODE = 'single';
MasterモジュールとTenantモジュールの一貫したテナントコード
@mbc-cqrs-serverless/masterと@mbc-cqrs-serverless/tenantパッケージはSettingTypeEnum.TENANT_COMMON = 'common'(小文字)を使用しており、getUserContext()の正規化と一貫しています。
組み込みメソッド(例:createCommonTenantSetting、createCommonTenant)を使用する場合、データは'common'テナントコードで保存され、正規化された小文字のテナントコードを使用してクエリできます。
// In @mbc-cqrs-serverless/master and @mbc-cqrs-serverless/tenant (@mbc-cqrs-serverless/masterと@mbc-cqrs-serverless/tenantで)
export enum SettingTypeEnum {
TENANT_COMMON = 'common', // Common tenant code (lowercase) (共通テナントコード(小文字))
}
/**
* Check if user has access to tenant (ユーザーがテナントへのアクセス権を持っているか確認)
*/
export function hasTenantAccess(
userContext: CustomUserContext,
targetTenantCode: string,
): boolean {
// System admin can access all tenants (システム管理者は全テナントにアクセス可能)
if (userContext.role === 'SYSTEM_ADMIN') {
return true;
}
// User can only access their own tenant (ユーザーは自分のテナントのみアクセス可能)
return userContext.tenantCode === targetTenantCode;
}
テナントガード
テナントアクセスを強制するガードを実装します:
// guards/tenant.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { getCustomUserContext, hasTenantAccess } from '../helpers/context';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const invokeContext = request.invokeContext;
const userContext = getCustomUserContext(invokeContext);
// Get target tenant from path or body (パスまたはボディからターゲットテナントを取得)
const targetTenant = request.params.tenantCode ||
request.body?.tenantCode ||
this.extractTenantFromPk(request.body?.pk);
if (!targetTenant) {
return true; // テナント未指定のため、ユーザーのテナントを使用
}
if (!hasTenantAccess(userContext, targetTenant)) {
throw new ForbiddenException(
`Access denied to tenant: ${targetTenant}`,
);
}
return true;
}
private extractTenantFromPk(pk: string | undefined): string | undefined {
if (!pk) return undefined;
const parts = pk.split('#');
return parts.length >= 2 ? parts[1] : undefined;
}
}
データ分離パターン
パターン1: パーティションキーにテナントを含める
完全な分離のためにパーティションキーにテナントコードを含めます:
// Standard tenant isolation pattern (標準的なテナント分離パターン)
const PRODUCT_PK_PREFIX = 'PRODUCT';
function generateProductPk(tenantCode: string): string {
return `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`;
}
// Example keys: (キー例:)
// PK: PRODUCT#tenant-a
// SK: 01HX7MBJK3V9WQBZ7XNDK5ZT2M
// Query all products for a tenant (テナントの全商品を取得)
async function listProductsByTenant(tenantCode: string) {
const pk = generateProductPk(tenantCode);
return dataService.listItemsByPk(pk);
}
パターン2: 共有データ用の共通テナント
すべてのテナントで共有されるデータには共通のテナントコードを使用します:
// Shared data pattern for master data and configurations (共有データパターン:マスターデータ、設定)
const COMMON_TENANT = 'common';
// System-wide settings (システム全体の設定)
const settingsPk = `SETTINGS${KEY_SEPARATOR}${COMMON_TENANT}`;
// User data (users can belong to multiple tenants) (ユーザーデータ(ユーザーは複数のテナントに属することができる))
const userPk = `USER${KEY_SEPARATOR}${COMMON_TENANT}`;
// Example: Get system-wide email templates (例: システム全体のメールテンプレートを取得)
async function getEmailTemplates() {
return dataService.listItemsByPk(`TEMPLATE${KEY_SEPARATOR}${COMMON_TENANT}`);
}
パターン3: ユーザー・テナント関連付け
複数のテナントに所属するユーザーを処理します:
// user/dto/user-tenant.dto.ts
export interface UserTenantAssociation {
pk: string; // USER_TENANT#common
sk: string; // {tenantCode}#{userCode}
tenantCode: string; // オーナーテナント(COMMON_TENANT)
attributes: {
userCode: string;
tenantCode: string; // 関連するテナントコード
role: string; // このテナント内のロール
isDefault: boolean; // ユーザーのデフォルトテナント
};
}
// user/user.service.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { CommandService, DataService, IInvoke, KEY_SEPARATOR, generateId, VERSION_FIRST } from '@mbc-cqrs-serverless/core';
import { AuthService } from '../auth/auth.service';
import { UserTenantAssociation } from './dto/user-tenant.dto';
const COMMON_TENANT = 'common';
@Injectable()
export class UserService {
constructor(
private readonly commandService: CommandService,
private readonly dataService: DataService,
private readonly authService: AuthService,
) {}
/**
* Get all tenants a user belongs to (ユーザーが所属する全テナントを取得)
*/
async getUserTenants(userCode: string): Promise<UserTenantAssociation[]> {
const pk = `USER_TENANT${KEY_SEPARATOR}${COMMON_TENANT}`;
// List all items under the PK, then filter by user code (PK配下の全アイテムを取得し、ユーザーコードで絞り込み)
const result = await this.dataService.listItemsByPk(pk);
return result.items.filter(item =>
item.sk.endsWith(`${KEY_SEPARATOR}${userCode}`),
);
}
/**
* Add user to tenant (ユーザーをテナントに追加)
*/
async addUserToTenant(
userCode: string,
tenantCode: string,
role: string,
invokeContext: IInvoke,
): Promise<void> {
const pk = `USER_TENANT${KEY_SEPARATOR}${COMMON_TENANT}`;
const sk = `${tenantCode}${KEY_SEPARATOR}${userCode}`;
await this.commandService.publishSync({
pk,
sk,
id: generateId(pk, sk),
tenantCode: COMMON_TENANT,
code: `${tenantCode}-${userCode}`,
name: `User ${userCode} in ${tenantCode}`,
type: 'USER_TENANT',
version: VERSION_FIRST,
attributes: {
userCode,
tenantCode,
role,
isDefault: false,
},
}, { invokeContext });
}
/**
* Switch user's active tenant (ユーザーのアクティブテナントを切り替え)
*/
async switchTenant(
userCode: string,
newTenantCode: string,
invokeContext: IInvoke,
): Promise<{ token: string }> {
// Verify user belongs to tenant (ユーザーがテナントに属することを確認)
const associations = await this.getUserTenants(userCode);
const association = associations.find(a =>
a.attributes.tenantCode === newTenantCode,
);
if (!association) {
throw new ForbiddenException(
`User does not belong to tenant: ${newTenantCode}`,
);
}
// Generate new token with updated tenant context (更新されたテナントコンテキストで新しいトークンを生成)
return this.authService.generateToken({
userCode,
tenantCode: newTenantCode,
role: association.attributes.role,
});
}
}