マルチテナントパターン
このガイドでは、適切なデータ分離、共有リソース、テナント間操作を備えたマルチテナントアプリケーションの実装パターンについて説明します。
このガイドを使用するタイミング
以下の場合にこのガイドを使用してください:
- テナント(顧客/組織)間でデータを分離する
- すべてのテナントで共通データを共有する
- ユーザーが複数のテナントに所属できるようにする
- テナント固有の設定を実装する
- テナント間でデータを同期する
マルチテナントアーキテクチャ
┌───────────────────────────────────────────── ────────────────────┐
│ 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 } 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);
return {
tenantCode: userContext.tenantCode || DEFAULT_TENANT_CODE,
userCode: userContext.userCode || '',
userId: userContext.userId || '',
email: userContext.email,
role: userContext['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モジュールのCOMMON定数
@mbc-cqrs-serverless/masterと@mbc-cqrs-serverless/tenantパッケージでは、共通テナントコード用の内部定数として'COMMON'(大文字)を定義しています。
これらのモジュールの組み込みメソッド(例:createCommonTenantSetting、createCommonTenant)を使用する場合、データ保存時に内部的にこの'COMMON'値が自動的に使用されます。
// @mbc-cqrs-serverless/masterと@mbc-cqrs-serverless/tenantで
export enum SettingTypeEnum {
TENANT_COMMON = 'COMMON', // Internal constant for data storage (データ保存用の内部定数)
}
注意: 保存される値は'COMMON'ですが、HTTPヘッダーでx-tenant-code: COMMONを送信すると、正規化によりgetUserContext()は'common'(小文字)を返します。カスタム実装では、正規化されたテナントコードとの一貫性のため小文字の'common'を使用してください。
/**
* 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; // No tenant specified, will use user's tenant
}
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;
}
}