要点: テナント間の情報漏えいは、高度な攻撃よりも一つの条件漏れから起きることが少なくありません。
この記事の用語: RLS:DBが行単位でアクセスを制限する仕組み。Fail closed:情報不足なら拒否する方針。
現場の課題
ログイン済みであることは、すべてのデータを見られるという意味ではありません。クエリからテナントIDが一つ抜けるだけで、別の顧客データを返す可能性があります。
単純な対策だけでは不十分な理由
認証は誰かを示しますが、どの資源へ何をしてよいかは別の判断です。APIだけでなく、ジョブ、キャッシュ、データ出力、ファイル保存でも同じテナント境界を再確認します。
処理フロー
ユーザーとサイト識別→認可処理
所属と権限のチェック→テナントクエリ
RLSとスコープ条件→ストレージ
サーバー生成キー
アーキテクチャ上の判断
サーバーでテナントコンテキストを確定する
認証済みPrincipalと要求サイトからMembershipを確認します。Form、JWTのCustom Field、オブジェクト Path由来のtenantIdは、現在の所属とResourceの所有権を確認するまで信頼しません。
DBでScopeを強制する
トランザクション単位でテナントコンテキストを設定し、Row-level セキュリティまたは必須のComposite条件を適用します。コンテキストが欠けたときは0件またはエラーとし、Scopeを勝手に広げません。
非同期処理にもアイデンティティを渡す
ジョブ Payloadにはテナント、Actor、権限 スナップショットまたは再認可キー、そしてCorrelation IDを含めます。ワーカーも、元のリクエストと同じDB コンテキストとStorage Prefixを設定します。
さらに深く考える
認可はBooleanではなくGraph
アクセスは、Organization Membership、サイト Role、Resourceの所有権、Delegation、現在のSubscriptionに依存します。これらの関係を、Reason コード付きの明示的なDecisionへ解決します。そのDecisionを、テナント・サイト条件が残ったResource クエリに適用します。オブジェクトをGlobalに読んでから確認するのは避けます。Timing、エラー Message、副作用で、存在がすでに漏れることがあるためです。List エンドポイントではSet-based クエリとして認可を表現します。不許可のRowをアプリケーション Memoryで除外する方法は、遅いうえにCount、データ出力、Pagination経由で簡単に漏れます。
各境界は独立して壊れ得るものとして扱う
RLSはRowを守りますが、オブジェクト Storage、検索インデックス、ログ、キャッシュ Entryは守りません。Storage キーはサーバー管理のテナントIDから生成し、短時間のSigned URLを発行し、アップロードされたオブジェクトのMetadataを発行前に検証します。Search ドキュメントは、Callerが省略できないWrapperでテナント Fieldを必須にします。ログにはAudit用にテナントとActorを含めつつ、シークレットと機密PayloadはRedactします。暗号化 キーは、強いLogical Isolationのもとで共有するか、より高い保証のためにテナントごとに分離できます。この選択は、ローテーション、復旧、コスト、そして障害対応の影響範囲に影響します。
実装手順
- 業務処理の前に、セッション内の利用者、サイト、テナントの関係を検証します。
- DB トランザクションへテナントコンテキストを設定し、コンテキストがなければクエリを拒否します。
- ファイルパスはサーバー側で生成し、短時間だけ有効なアップロード・ダウンロード URLを発行します。
コード例: トランザクション単位のテナントコンテキスト
BEGIN
SET LOCAL app.tenant_id = :tenantId
SET LOCAL app.actor_id = :actorId
SELECT * FROM documents
WHERE site_id = :siteId AND id = :documentId
-- RLS policyでも必須:
tenant_id = current_setting('app.tenant_id')
COMMIT接続プールが次のリクエストへコンテキストを持ち越さないよう、トランザクション-localな設定を使います。RLSをBypassするサービス Roleは最小限に絞り、分離し、必ず監査します。
想定しておく障害
- オブジェクトIDが全体で一意なので、IDだけでクエリしてしまいがちです。しかし一意性は認可ではありません。テナント Scopeは常に必要です。
- キャッシュキーにResource IDはあっても、テナントや権限 バージョンがありません。するとキャッシュがテナント間の読み取り経路になってしまいます。
- キュー投入時は認可済みでも、実行前に権限が変わることがあります。機密なジョブは実行時に再認可するか、短時間の明示的なGrantを使います。
監視すべきこと
| 指標 | 何が分かるか |
|---|---|
| テナント越境の拒否数 | 増加は、探索行為、壊れたLink、古いサイト コンテキストを送るクライアントのいずれかを示すことがあります。 |
| テナントコンテキストのないクエリ | DB Boundaryで計測し、1件でもセキュリティ Defectとして扱います。 |
| RLS ポリシーのテスト Coverage | テナント所有のテーブルごとに、正常系と越境系のテストが揃っているかを追跡します。 |
| Privileged Roleの利用 | 想定外のBypass Role利用は、優先度の高いAudit イベントにします。 |
設計の検証方法
- 2つのテナントにまたがり、担当者、Member、招待中の利用者、削除済み利用者、サービス Accountを網羅したアクセス Matrixを作ります。
- 各Resource エンドポイントについてテナント、サイト、オブジェクトIDを個別に置き換え、Fuzz試験を行います。
- 開始利用者のRoleを取り消したうえでBackground ジョブとデータ出力を実行し、選んだ再認可ポリシーを検証します。
- テスト中にキャッシュ、Search、オブジェクト Storageのキーを確認します。DB分離だけを証拠にはしません。
- 接続プールのStress テストで、トランザクション-localなRLS コンテキストが同時リクエスト間に漏れないことを証明します。
本番導入の進め方
新しい制約で拒否されるクエリを先に記録し、まだ遮断しません。呼び出し元を修正し、二つのテナントを使う越境テストを追加してから、低トラフィックのテーブルからRLSを有効化します。
本番前チェックリスト
- 複数レイヤーでの重複チェックは無駄ではなく、意図された多層防御(Defense in Depth)です。
- キャッシュキーやバックグラウンドジョブにおいても、HTTPリクエストと同等のテナント情報が必要です。
- テナント間アクセス検証をセキュリティテストの必須項目として組み込みます。
まとめ
テナント分離は連続した防御です。一か所でもテナントIDを忘れれば、重大な情報漏えいにつながります。
