6ヶ月という短い期間ではありましたが、エンジニア3年目にして新規SaaSプロジェクトのインフラからバックエンドまでの設計・実装を任されました。

MVP(Minimum Viable Product)フェーズからのスタートということもあり、コストや運用負荷、リソース効率の観点を重視し、各テナントで同じリソースを共有する「プールモデル」を軸にアーキテクチャを検討し始めました。
しかし、プールモデルにおいて絶対に許されないのが他テナントへのデータ混入です。設計の当初から「ここを妥協したら終わり」というラインは明確でした。

設計にあたり、O’Reillyの書籍『マルチテナントSaaSアーキテクチャの構築―原則、ベストプラクティス、AWSアーキテクチャパターン』や、AWSが提供するオープンソースのSaaS Builder Toolkit for AWS(SBT)などを学習し実際に検証などを行いました。そこで示されている論理分離の概念はとても洗練されていましたが、プロジェクトの要件にそのまま当てはめるにはいくつかのトレードオフが存在しました。

そこで、AWSのリファレンスから概念などを参考にしつつ、プロジェクトの要件に合わせた独自のフルサーバーレス・アーキテクチャを構築するアプローチをとりました。結果として、認証からデータベースの行レベルに至るまで、複数のレイヤーで段階的にアクセスを制限し、データ分離を確実に行う堅牢なシステムが完成しました。

本稿では、私たちが構築したデータ分離の実装アプローチについて解説します。

■ 本記事における主な使用技術・AWSサービス
* インフラストラクチャ(IaC): AWS CDK (TypeScript)
* 認証・認可: Amazon Cognito, AWS IAM (STS, ABAC)
* コンピューティング: AWS Lambda, Amazon API Gateway
* データベース・ストレージ: Amazon Aurora PostgreSQL (Data API), Amazon DynamoDB, Amazon S3

1. AWSリファレンスからの学びと、独自実装への判断

AWSのリファレンスで特に参考になったのは、システムをControl Plane(管理領域)とApplication Plane(アプリ実行領域)に論理分離するという考え方です。この構成は、最終的なCDKのインフラ設計にも取り入れています。


出典:SaaS Architecture Fundamentals

一方で、標準的なSaaS向けライブラリ(SBTなど)をそのまま採用せず、独自に実装を行ったのには明確な理由があります。

  • ブラックボックス化の回避とコントロールの担保: データ分離というシステムの最もクリティカルな部分を外部ライブラリの隠蔽された処理に依存せず、自分たちで完全にコントロールできる状態を保ちたかった。
  • シンプルなプールモデルへの最適化: リファレンスではテナント追加時に非同期の動的プロビジョニングを行う構成が一般的ですが、全テナントがリソースを共有するシンプルなプールモデルでは、そのオーバーヘッドは不要と判断しました。
  • 複雑な階層や、関連を持つデータモデルへの対応: NoSQLを中心としたサンプルが多い中、プロジェクトのドメインは複雑な関連を持つため、Aurora PostgreSQLを主軸に置く必要がありました。

これらの要件を満たすため、アーキテクチャの骨格はAWSの思想を踏襲しつつ、内部の実装は完全にコントロール可能な独自構成で組み上げました。

2. 4レイヤーにおける論理的なデータ分離の実装

データ漏洩のリスクを最小限に抑えるため、単一の防御策に頼るのではなく、各レイヤーで確実にアクセスを制限する多層的な設計を行いました。

① 【認証層】Cognito JWT へのテナントコンテキスト注入

まず認証の時点で、テナントIDを「改ざん不可能な状態」でJWTに封じ込めます。

Cognito User Poolのカスタム属性 custom:tenantIdmutable: false(書き換え不可)で定義し、ログイン時に起動する Pre-Token Generation V2 Lambda でDBからテナント情報を取得してIDトークンへ注入します。

// カスタム属性定義:ユーザー自身による書き換えを禁止
customAttributes: {
  tenantId: new cognito.StringAttribute({ mutable: false }),
  role:     new cognito.StringAttribute({ mutable: true  }),
},

// Pre-Token Generation V2 トリガーをアタッチ
this.userPool.addTrigger(
  cognito.UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG,
  preTokenFn,
  cognito.LambdaVersion.V2_0,
);

このLambda内では、トランザクションを開始してRLSコンテキストをセットしたうえでDBクエリを実行し、所属情報などをIDトークンへ動的に書き込みます。これにより、以降のすべてのAPIリクエストにおいて、トークン1本から安全にテナントコンテキストが復元可能になります。

② 【コンピューティング層】Lambda Tenant Isolation Mode × API Gateway

プールモデルで頭を悩ませたのが、Lambdaのウォームコンテナ再利用によるグローバル変数の状態リークリスクです。この問題に対して、AWS Lambdaが提供するTenant Isolation Modeを採用しました。

// CDK: テナント分離モードの有効化
this.apiLambda = createManagedNodejsFunction(this, 'ApiFunction', {
  tenancyConfig: lambda.TenancyConfig.PER_TENANT, // ★ テナントごとに実行コンテナを分離
});

Lambda側で必要なのはこの1行だけです。ただし、Lambda単体では「どのテナントのリクエストか」を判別できないため、API Gatewayの統合設定で、Cognito Authorizerが検証したJWTクレームを X-Amz-Tenant-Id ヘッダーとして強制注入します。

// API Gateway Integration: JWTクレームをヘッダーにマッピング
new apigw.LambdaIntegration(this.apiLambda, {
  requestParameters: {
    // Cognitoが検証した値のみをLambdaに渡す(クライアントから偽装不可)
    'integration.request.header.X-Amz-Tenant-Id':
      'context.authorizer.claims.custom:tenantId',
  },
});

クライアント側が同ヘッダーを自前で指定しても、API Gatewayが上書きするため偽装は成立しません。この連携に気づくまでに少し時間がかかりましたが、「CDK数行で実行環境レベルの完全な隔離が手に入る」という強力なアプローチです。

③ 【IAM認可層】STS セッションタグ × ABAC によるS3・DynamoDB分離

テナントのアセット(S3)やリアルタイム状態(DynamoDB)の分離には、AWS IAMの機能を利用します。STS AssumeRole 時にテナント識別子をセッションタグとして付与することで、IAMポリシー変数がそのタグに展開され、テナントごとのデータ範囲のみに操作を制限した一時認証情報を発行します。

テナント数が増えてもIAMロールやポリシーを追加する必要がない、スケーラブルなABAC(属性ベースのアクセス制御)方式です。

S3アセットバケットでは、IAMポリシー変数 ${aws:PrincipalTag/tenantId} でパスを絞り込みます。

// リソースパスをセッションタグで動的に制限
resources: [bucket.arnForObjects('assets/${aws:PrincipalTag/tenantId}/*')],

// Trust Policy: tenantId タグなしの AssumeRole は一切拒否
conditions: {
  'ForAllValues:StringEquals': { 'aws:TagKeys': ['tenantId'] },
  Null: { 'aws:RequestTag/tenantId': 'false' }, // タグ必須
},

DynamoDBでも同様に dynamodb:LeadingKeys 条件を用い、パーティションキーのプレフィックスをセッションタグの値で制限しています。

アプリ側ではリクエストごとにテナントIDをタグとして付与しながらSTSへ AssumeRole を呼び出し、発行された一時認証情報(有効期限15分)はLambdaの実行コンテキスト内でキャッシュします。
Trust Policyの Null 条件を正しく記述するまでに何度かIAMの仕様に苦しめられましたが、「タグが必ず存在することをPolicy側で保証する」という考え方が腑に落ちてからは、IAMのセキュリティモデルの精緻さに感銘を受けました。

④ 【RDB層】PostgreSQL RLS によるフェイルセーフなデータ保護

リレーショナルデータへのアクセスにおける最終的な砦は、PostgreSQL(Aurora)のRLS(Row-Level Security)です。

今回はLambdaからAuroraへのアクセスにData APIを活用しています。Data APIのステートレスな接続においてRLSを確実に機能させるため、トランザクション開始時にテナントIDをセッションにバインドし、該当テナントの行のみを参照・更新可能にするフィルタをデータベースエンジン内部で強制しています。

さらに、異常な値(不正なUUIDなど)が渡された場合はエラーにせずNULLを返し、RLSによって安全に全行へのアクセスを遮断するフェイルセーフな設計を採用しました。アプリ層ではDBアクセスを共通のラッパー関数経由に強制しているため、ビジネスロジック内でテナント指定を書き忘れてもデータ混入が起きない構造になっています。

3. 今回のインフラ構築で得られた手応え

構築したアーキテクチャがもたらした具体的な成果は2つです。

  • テナント追加時のオーバーヘッド排除: IAMのタグベース(ABAC)やDBのRLSを用いた論理分離に寄せたことで、新規テナント追加時に専用インフラをデプロイする待ち時間がなくなりました。APIの呼び出し一つで瞬時にオンボーディングが完了する身軽な運用を実現しています。
  • セキュリティの認知負荷の軽減: 複数のデータ分離機能がインフラ・共通ミドルウェア層で完結しているため、機能開発の担当者はセキュリティの実装詳細を意識する必要がありません。実装ミスが構造的に起きにくい設計になっています。

4. まとめ

AWSが提供するマルチテナントのベストプラクティスは、プロジェクトの要件に合わせてアーキテクチャを検討するための非常に参考になりました。

Control PlaneとApplication Planeを分離する構成を基本としつつ、そこに Lambda Tenant Isolation ModeSTS × IAM ABAC、そしてData APIを活用した PostgreSQL RLS を組み合わせることで、ビジネスロジックを汚染しない強固なデータ分離を実現できました。

ベストプラクティスの概念を理解し、トレードオフを評価して自分たちの要件に合わせて仕組みを考えた経験は、設計力を鍛える非常に良い機会になったと感じています。同じようにマルチテナントの設計に向き合うエンジニアの皆さんの参考になれば幸いです。

参考文献・リンク集