メインコンテンツへスキップ
友田 陽大
データベース・RLS
マルチテナント
B2B SaaS
セキュリティ
アーキテクチャ設計
認可

マルチテナント SaaS のデータ分離と認可を設計する:テナント境界・PII保護・BOLA対策を『信頼境界はサーバー』で固める

B2Bマルチテナント SaaS で他テナントのデータ・PIIを絶対に漏らさないためのデータ分離と認可設計ガイド。silo/pool/bridge の分離戦略、BOLA/IDOR・ID列挙対策、二層スキーマでのPIIスコープ、ルーター層に一元化した業種/ロール認可、そして分離を証明するテスト/ペネトレまでを、AWS公式とOWASPに忠実に実コードで解説します。

公開日
読了時間
29分
著者
友田 陽大
シェア
目次

「複数の会社が同じシステムを使う」——B2B SaaS の要件としては一行です。けれど本番に載せようとした瞬間、最も重い問いが立ちます。「A社のユーザーが、B社のデータ(メール・電話・法人番号)を絶対に見られない、と証明できますか?」

これに「たぶん大丈夫」としか答えられないなら、その SaaS はまだ売ってはいけません。マルチテナントの最大のリスクは機能バグではなく、『隣のテナントが見える』——クロステナントのデータ・PII漏洩です。一度起きれば信頼は戻りません。

この記事は、B2B マルチテナント SaaS でテナント境界・PII保護・認可を本番品質で固めるための設計ガイドです。題材として、私が構築した招待制 B2B サブスクリプション SaaS(木材流通DX/木材流通DXのB2B SaaS。林業→市場→製材所→プレカット→工務店→メーカーの7業種が企業をまたいで取引相手を探すサービス。経済産業大臣賞受賞)での設計判断も交えます。このサービスは企業横断で取引相手を探すため、「相手企業のPIIをどこまで見せ、どこから隠すか」がそのまま事業要件になりました。

この記事のルール:分離・認可の概念は AWS の SaaS ドキュメント(SaaS Architecture Fundamentals / SaaS Tenant Isolation Strategies / Prescriptive Guidance)OWASP API Security Top 10 (2023) に基づきます(いずれも2026年6月時点。仕様は改定され得るため、本番投入前に必ず後掲の公式ドキュメントで最新を確認してください)。そして本記事の一貫した原則はただ一つ——信頼境界はクライアントではなく、サーバー/DBに置く。クライアントから来た tenant_idid も、検証するまで一切信用しません。


0. メンタルモデル:分離は「積」である

最初に全体像を固めます。マルチテナントとは**「1つの基盤を複数テナントで共有する」こと。共有しているからこそ、放っておけば隣が見えます。安全なマルチテナントは、独立した4つの層の積(AND)**で成り立ちます。どれか1つでも欠ければ漏れます。

  1. どこで分けるか(silo / pool / bridge) — 分離の物理/論理境界をどこに引くか。
  2. 全クエリにテナント条件を強制する — どのデータアクセスにも例外なくテナントスコープが乗る。漏れをコードレビューの根性ではなく仕組みで防ぐ。
  3. オブジェクト単位の認可(BOLA対策) — クライアントが渡してきた id が「本当にこの人の所有物か」を毎回チェックする。
  4. レスポンスでPIIをスコープする(二層スキーマ) — 返していい属性だけを返す。to_json() で全部出さない。

この記事はこの4層を順に固めていきます。先に重要な区別を一つ。

「認証・認可」と「テナント分離」は別物

ここを混同したまま設計を始めると、必ず事故ります。AWS の SaaS Architecture Fundamentals は明確にこう述べています。

"the fact that a tenant user is authenticated does not mean that your system has achieved isolation."(テナントユーザーが認証済みであることは、システムが分離を達成したことを意味しない)

さらに踏み込んで、こうも言います。

"a user could be authenticated and authorized, and still access the resources of another tenant. Nothing about authentication and authorization will necessarily block this access."(ユーザーが認証され、かつ認可されていても、なお別テナントのリソースにアクセスし得る。認証と認可は、このアクセスを必ずしもブロックしない)

つまり——ログインできること(認証)と、ある操作の権限を持つこと(認可)と、隣のテナントが見えないこと(分離)は、3つの別の保証です。「JWTを検証している」「ロールをチェックしている」だけでは、テナント境界は1ミリも守られていません。テナント分離とは、AWS の定義では**「テナントコンテキストを使って、アクセスできるリソースを限定すること」**——tenant_id という文脈を、すべてのリソースアクセスに対して強制する仕組みのことです。

補完記事との関係:本記事はアプリ層のテナント分離と認可設計を扱います。同じ境界をより下の層で多重防御する話は別記事に切り出してあります。**DB層での強制(Postgres RLSで「アプリがWHEREを書き忘れても」漏らさない)**は 信頼できないクライアント前提のPostgres RLS設計 を、入口でのトークン検証(テナントコンテキストの出所であるJWTを正しく検証する)Cognito JWT(RS256)検証 を参照してください。三者は重ねて効く多重防御であり、競合しません(RLS=最後の砦、JWT検証=文脈の信頼性、本記事=その文脈の使い方)。


1. どこで分けるか:silo / pool / bridge

分離の第一歩は「テナント境界をどの粒度で引くか」を決めることです。AWS の整理に沿って、3つのモデルを押さえます。重要な前提として、AWS の Tenant Isolation Strategies は**「分離はリソースレベルの構造ではない」**と釘を刺します。

"isolation can be a logical construct that is enforced by run-time applied policies. The key point here is that isolation should not be equated to having siloed resources."(分離は、実行時に適用されるポリシーによって強制される論理的な構造になり得る。要点は、分離をリソースのサイロ化と同一視すべきではない、ということだ)

つまり**「テナントごとにDBを分ければ安全、共有なら危険」という単純な話ではない**。共有していても、実行時ポリシーで論理的に分離できます。その粒度の選択肢が silo / pool / bridge です。

1.1 三つのモデルの定義

  • Silo(サイロ):テナントごとにリソースのスタックを丸ごと専有する。テナントAとBで別のDB/別のスキーマ/別のアカウントを持つ。境界がインフラそのもの。
  • Pool(プール):全テナントがリソースを共有する。1つのDBの同じテーブルに全社のデータが同居し、tenant_id カラムと**実行時ポリシー(例:行レベルセキュリティ)**で論理分離する。
  • Bridge(ブリッジ):両者の混在。一部のリソースは共有(pool)、一部は専有(silo)。例えば「アプリDBは pool だが、PIIや決済情報だけ silo の専有テーブルに隔離する」など。

1.2 決定表:silo vs pool vs bridge

観点Silo(テナント専有)Pool(テナント共有)Bridge(混在)
分離度最強(境界がインフラ)論理分離(境界がコード/ポリシー)データの機微度で使い分け
ブラストラジウス(事故時の被害範囲)1テナントに限定設計ミスは全テナントに波及し得る機微データは限定、一般データは共有
コスト高い(テナント数だけリソースが増える)低い(リソースを共有)
運用・スケールテナント増ごとにプロビジョニング負荷テナント追加が安価・即時二系統を運用する複雑さ
クロステナント検索困難(DBが別なので跨げない)容易(同一テーブルを WHERE で絞る)共有側でのみ可能
向く場面高コンプライアンス(医療・金融で「テナントごとDB」必須)、大口テナントSMB大量・低単価、検索/集計が跨ぐサービスPIIだけ隔離したい一般的なB2B

AWS の Tenant Isolation Strategies も、ドメインが分離方式を縛ることを明言しています。

"Some high compliance industries, for example, will require that every tenant have its own database. In these cases, the shared, policy-based approaches to isolation may not be adequate."(例えば高コンプライアンス業界では、すべてのテナントが自身のDBを持つことを要求する。この場合、共有のポリシーベースの分離では不十分なことがある)

1.3 私の選択:なぜ pool+業種ベース認可だったか

木材流通DXは**「企業をまたいで取引相手を探す」**のが中核機能です。林業の会社が製材所を、工務店がメーカーを、業種をまたいで検索する。これは構造的に silo では成立しません——テナントごとにDBが分かれていたら、企業横断の検索ができないからです。

だから pool(全社が同じテーブルを共有) を選び、テナント境界はコードとポリシーで論理的に引きました。ただし pool の弱点は「設計ミスが全テナントに波及する(ブラストラジウスが大きい)」こと。だからこそ後述の2〜4層(全クエリへのテナント条件強制・BOLA対策・二層スキーマ)を多重に重ねる必要がありました。「共有するなら、論理分離を仕組みで担保する」——これが pool を選んだ瞬間に背負う責任です(KISS:機能要件に対して最小の構造を選び、その分の安全策を厚くする)。


2. 全クエリにテナント条件を強制する

pool を選んだら、最大の敵は**「WHERE tenant_id = ? の書き忘れ」です。1箇所でも素のクエリが混ざれば、そこから全テナントが漏れます。AWS の Tenant Isolation Strategies は、これを人間の注意力に頼ってはいけない**とはっきり書いています。

"Isolation enforcement should not be left to service developers ... it's unrealistic to expect that they will never unintentionally cross a tenant boundary. To mitigate this, scoping of access to resources should be controlled through some shared mechanism that is responsible for applying isolation rules (outside the view of developers)."(分離の強制を開発者任せにすべきではない……開発者が決してテナント境界を越えないと期待するのは非現実的だ。これを緩和するため、リソースへのアクセスのスコープ制御は、分離ルールの適用に責任を持つ共有された仕組みによって、開発者の視界の外で制御されるべきだ)

「気をつける」では分離は守れません。仕組みで強制します。アプリ層での実装パターンを3段階で示します。

2.1 テナントコンテキストを一箇所で確立する

まず、リクエストごとにテナントコンテキストを検証済みのJWTから確立し、リクエストスコープに固定します。クライアントが送ってきた tenant_id パラメータは絶対に使いません(信頼境界はサーバー)。

from dataclasses import dataclass
from fastapi import Depends, HTTPException, Request

@dataclass(frozen=True)
class TenantContext:
    """検証済みJWTから導出した、このリクエストのテナント文脈。
    クライアント由来のパラメータは一切混ぜない(信頼境界 = サーバー)。"""
    tenant_id: str          # 所属企業ID(= テナント境界)
    user_id: str
    industry: str           # 業種コード(後述の認可で使用)
    role: str               # 閲覧 / 管理 など

def get_tenant_context(request: Request) -> TenantContext:
    # JWT検証は入口(ミドルウェア)で完了済み。claims は検証済みのものだけを使う。
    # 検証の詳細は別記事「Cognito JWT(RS256)検証」を参照。
    claims = request.state.verified_claims  # 改ざん検証済みでなければここに来ない
    return TenantContext(
        tenant_id=claims["custom:tenant_id"],  # トークンの主張のみが真実
        user_id=claims["sub"],
        industry=claims["custom:industry"],
        role=claims["custom:role"],
    )

ポイントは tenant_id の出所をJWTただ一つに固定すること(Single Source of Truth)。?tenant_id= のようなクエリパラメータや、リクエストボディの tenant_id信用する経路を作らない。経路を作った瞬間、それが攻撃面になります。

2.2 テナントスコープを「型」で強制するリポジトリ

次に、全データアクセスをテナントコンテキストを要求するリポジトリの背後に集約します。素の session.query(User) をアプリコードから呼べなくするのが狙いです。

from sqlalchemy import select
from sqlalchemy.orm import Session

class TenantScopedRepository:
    """全クエリに tenant_id 条件を自動付与する。
    アプリ層は「テナント条件の付け忘れ」を構造的に起こせない(DRY/SRP)。"""

    def __init__(self, session: Session, ctx: TenantContext) -> None:
        self._session = session
        self._tenant_id = ctx.tenant_id  # コンテキスト経由でしか作れない

    def get_user(self, user_id: str) -> User | None:
        # tenant_id は呼び出し側が指定できない。常にコンテキストのものが乗る。
        stmt = select(User).where(
            User.id == user_id,
            User.tenant_id == self._tenant_id,  # ← 例外なく必ず付く
        )
        return self._session.scalar(stmt)

    def list_companies(self, *, limit: int, offset: int) -> list[Company]:
        stmt = (
            select(Company)
            .where(Company.tenant_id == self._tenant_id)
            .limit(limit)
            .offset(offset)
        )
        return list(self._session.scalars(stmt))

TenantScopedRepositoryTenantContext がなければインスタンス化すらできません。これで「テナント条件を書き忘れた素のクエリ」がアプリ層から物理的に消えます。これは AWS が言う**「開発者の視界の外で分離ルールを適用する共有された仕組み」**のアプリ層版です。

2.3 最後の砦はDB層(RLS)に置く

ただし、リポジトリも人間が書くコードなので「リポジトリを経由しない直クエリ」が将来混入し得ます。だから最後の砦はDBに置きます。Postgres の行レベルセキュリティ(RLS)で、セッション変数のテナントIDに一致する行しか返さないようにする。AWS も pool モデルでは RLS が必須だとしています。

-- pool モデルでのDB層分離(アプリが WHERE を書き忘れても漏れない最終防衛線)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant', true));
-- アプリは接続ごとに SET app.current_tenant = '<検証済みtenant_id>' する。

アプリ層(型で強制)とDB層(RLSで強制)の二重化が要点です。RLSの実装詳細は重複を避けて 信頼できないクライアント前提のPostgres RLS設計 に譲ります。本記事は「アプリ層でテナント条件を強制する」レイヤーに集中します。


3. オブジェクト単位の認可:BOLA / IDOR 対策

テナント条件を全クエリに乗せても、まだ穴があります。同じテナント内、あるいはテナント境界を越えて推測されたIDで、本来アクセス権のないオブジェクトを掴まれるケースです。これが OWASP API Security Top 10 の第1位、**API1:2023 Broken Object Level Authorization(BOLA)**です。

3.1 BOLAとは何か

OWASP の定義はこうです。BOLAは、サーバーがクライアントの提供したオブジェクトIDに依存して、ユーザーの権限を適切に検証せずにアクセスを決めてしまうことで発生します。

"attackers can exploit API endpoints that are vulnerable to broken object-level authorization by manipulating the ID of an object"(攻撃者は、リクエストで送られるオブジェクトのIDを操作することで、オブジェクトレベル認可の不備があるAPIエンドポイントを悪用できる)

これが最も一般的で、最も影響が大きいAPI脆弱性です。OWASPは影響を "data disclosure to unauthorized parties, data loss, or data manipulation"(権限のない第三者へのデータ漏洩、データ消失、データ改ざん)、さらには "full account takeover"(完全なアカウント乗っ取り)まであり得ると述べています。IDOR(Insecure Direct Object Reference)はこの一種です。

OWASPの攻撃シナリオは生々しい。/shops/{shopName}/revenue_data.json というパターンに気づいた攻撃者が、店舗名を差し替えて数千店舗の売上データを抜くtenant_idWHERE に乗せていても、{shopName} のような別のオブジェクトIDの所有チェックが抜けていれば、そこから漏れます。

3.2 対策その1:毎回、所有チェックをする

OWASPの予防策の核心はこれです(原文)。

"Use the authorization mechanism to check if the logged-in user has access to perform the requested action on the record in every function that uses an input from the client to access a record in the database."(クライアントからの入力を使ってDBのレコードにアクセスするすべての関数で、ログイン中のユーザーがそのレコードに対し要求された操作を行う権限を持つかを、認可機構を使って確認せよ)

キーワードは "in every function"(すべての関数で)。例外を作らない。実装上は、**「IDで取得 → 取得結果のテナント/所有者が、現在のコンテキストと一致するか確認 → 不一致なら拒否」**を徹底します。先ほどの TenantScopedRepository.get_user は、WHERE 句に tenant_id を入れることで「他テナントの行はそもそも返さない」形でこれを満たしています。取得後に if obj.tenant_id != ctx.tenant_id で弾くのではなく、取得段階で絞る方が、漏れにくく速い。

3.3 対策その2:IDを推測・列挙させない

OWASPのもう一つの予防策。

"Prefer the use of random and unpredictable values as GUIDs for records' IDs."(レコードIDには、ランダムで予測不可能な値(GUID)を使うことを推奨する)

連番の整数IDは /users/1001/users/1002 と総当たり(列挙攻撃)を許します。所有チェックが1箇所でも抜ければ、列挙で一気に抜かれる。だから**推測しにくいID(UUIDv4 等)**を使い、列挙のコストを上げます。

ただし注意——ランダムIDは「所有チェックをしなくていい理由」にはなりません。あくまで多重防御の一枚です。OWASPも明確に、IDの推測困難性に頼るのではなく認可機構そのものを正せ、という立場です。

import uuid

def new_object_id() -> str:
    """予測不可能なID。列挙攻撃のコストを上げる(所有チェックの代替ではない)。"""
    return str(uuid.uuid4())  # 連番にしない = /resource/1,2,3... での総当たりを封じる

def get_deal(deal_id: str, ctx: TenantContext, repo: DealRepository) -> Deal:
    deal = repo.find_by_id(deal_id)  # find_by_id は内部で tenant_id を絞る
    # 取得段階で他テナントは None。存在秘匿のため 404 を返し、403 と区別させない。
    if deal is None:
        raise HTTPException(status_code=404)
    return deal

存在を漏らさない小技:他テナントのIDを叩かれたとき、403 Forbidden(=「在るが権限がない」)を返すと、そのIDが存在すること自体を教えてしまいます。所有していないオブジェクトは 404 Not Found(在るかどうかも言わない) に寄せると、列挙で「存在するID」を探る攻撃を鈍らせます。一方、業種/ロールの不一致(=機能レベルの権限不足)は 403 にする、という使い分けが実務では効きます(後述)。


4. 認可をルーター層に一元化する:業種/ロール認可

BOLAが「このオブジェクトはこの人のものか(オブジェクト単位)」の話なら、ここで扱うのは「この人はこの操作をしていいロール/業種か(機能単位)」の認可です。木材流通DXでは、7業種+閲覧/管理ロールが機能ごとに異なる権限を持ちました。

4.1 認可ロジックを散らさない(SRPと「決定の分離」)

AWS Prescriptive Guidance は、認可ロジックをアプリコードに埋め込む従来手法を明確に否定しています。

"Traditionally, API access control and authorization were handled by custom logic in the application code. This approach was error prone and not secure, because developers ... could accidentally or deliberately change authorization logic, which could result in unauthorized access."(従来、API アクセス制御と認可はアプリコード内のカスタムロジックで扱われた。この手法は誤りやすく安全でない。開発者が認可ロジックを偶発的に、あるいは意図的に変更し、不正アクセスを招き得るからだ)

そして推奨するのは認可ロジックの一元化と分離です。

"Authorization logic can be centralized ... and abstracted from the application code and can be applied as a repeatable pattern to all APIs"(認可ロジックは一元化でき……アプリコードから抽象化され、すべてのAPIに反復可能なパターンとして適用できる)

AWSはこれを PEP(Policy Enforcement Point:判定を強制する点)/ PDP(Policy Decision Point:判定する点)/ PAP(Policy Administration Point:ポリシーを管理する点) という構造で整理します。大規模なら Amazon Verified Permissions(Cedar)や OPA(Rego)といった専用エンジンをPDPに据える設計です。

ただしYAGNI——招待制B2BでロールがJWTに乗っており、業種数も有限なら、外部ポリシーエンジンは過剰です。私は**「PEPをルーター層に一元化し、判定はホワイトリストで持つ」**という軽量版を選びました。本質(=認可を散らさず、入口で一元的に強制する)は同じです。

4.2 frozenset ホワイトリストで業種を縛る

機能ごとに「どの業種が許可か」を frozenset(不変集合)で宣言的に定義します。許可リストをコードのデータとして一箇所に集め、判定をルーター層に集約します。

from typing import Final

# 機能 → 許可する業種コードのホワイトリスト(不変・一箇所に集約 = SRP/DRY)。
# 「許可されたものだけ通す」allowlist 方式。denylist にしない(追加漏れ = 穴)。
INDUSTRY_WHITELIST: Final[dict[str, frozenset[str]]] = {
    "deal.create":     frozenset({"forestry", "market", "sawmill", "precut", "builder", "maker"}),
    "inventory.write": frozenset({"sawmill", "precut", "maker"}),  # 在庫を持つ業種のみ
    "admin.invite":    frozenset({"market"}),  # 招待権限は市場業種のみ
}

ROLE_WHITELIST: Final[dict[str, frozenset[str]]] = {
    "deal.create":     frozenset({"admin"}),          # 取引作成は管理ロールのみ
    "inventory.write": frozenset({"admin"}),
    "company.read":    frozenset({"viewer", "admin"}),  # 閲覧は両ロール可
}

class AuthorizationError(Exception):
    """機能レベルの権限不足。HTTP では 403 にマップする。"""

frozenset を選ぶ理由は二つ。(1) 不変なので実行時に書き換わらない(認可ルールが汚染されない)、(2) in 判定が O(1) で速い。list だと O(n) かつ可変です。小さな選択ですが、認可のような「安全と速度の両方が要る」場所では型の選択が効きます(型安全 × 性能)。

4.3 ルーター層で一元的に強制する(不一致は403)

判定を1つのデコレータ/依存性に集約し、全ルートがそれを通る形にします。これが AWS の言う PEP(強制点)を一箇所に集める、ということです。

from functools import wraps
from fastapi import HTTPException

def require(action: str):
    """ルーター層に一元化した認可の強制点(PEP)。
    業種・ロールの両ホワイトリストを満たさなければ 403。判定はここにしかない。"""
    def decorator(handler):
        @wraps(handler)
        async def wrapper(*args, ctx: TenantContext, **kwargs):
            industries = INDUSTRY_WHITELIST.get(action, frozenset())
            roles = ROLE_WHITELIST.get(action, frozenset())
            # 「定義がない=誰も許可されていない」とみなす(fail closed)。
            if ctx.industry not in industries or ctx.role not in roles:
                # 機能レベルの権限不足は 403。ID列挙の手がかりも与えない。
                raise HTTPException(status_code=403, detail="forbidden")
            return await handler(*args, ctx=ctx, **kwargs)
        return wrapper
    return decorator

@router.post("/deals")
@require("deal.create")  # ← 認可はここで完結。ハンドラ本体に if 文を散らさない。
async def create_deal(payload: DealCreate, ctx: TenantContext = Depends(get_tenant_context)):
    ...  # ここに到達した時点で、業種・ロールは検証済み

fail closed(既定は拒否) が肝です。ホワイトリストに定義のないアクションは、**「定義漏れ=全員拒否」**として扱う。get(action, frozenset()) で空集合を返せば、誰も通りません。逆の fail open(定義がなければ通す)にすると、ルートを追加して認可定義を書き忘れた瞬間に穴が開きます。新機能を足したら認可も足さないと動かない——この摩擦こそが安全装置です。

なぜルーター層なのか:認可をハンドラ本体やサービス層に書くと、同じ判定が散らばり(DRY違反)、一箇所直し忘れれば穴になります。入口(ルーター)に一元化すれば、「全ルートが必ず認可を通る」ことをコードレビューで目視確認できる。これは SRP(認可は認可の責務に集約)であり、AWS が言う「反復可能なパターンを全APIに適用」の実装です。そして不一致を 403 で弾くことは、ID列挙攻撃の足場を削る効果も持ちます(権限のない機能は、そもそも叩いても何も返らない)。


5. レスポンスでPIIをスコープする:二層スキーマ

ここまでで「誰がどのオブジェクトに、どの操作をできるか」は固まりました。最後の層は**「アクセスできるとして、どの“属性”まで返すか」です。これを誤ると、認可を通った正規ユーザーに対して過剰な属性(PII)を返してしまう**。OWASPのAPI3:2023 Broken Object Property Level Authorization がまさにこれです。

5.1 過剰なデータ露出(Excessive Data Exposure)

API3:2023 は、2019年版の「Excessive Data Exposure(過剰なデータ露出)」と「Mass Assignment(一括代入)」を統合したものです。OWASPはこう説明します。読み取り側の脆弱性は、ユーザーが閲覧権限を持たないオブジェクト属性を、APIレスポンスで返してしまうこと。根本原因は、APIが "endpoints that return all object's properties"(オブジェクトの全属性を返すエンドポイント)を、属性レベルの認可なしに公開してしまうことです。

OWASPの攻撃シナリオが象徴的です。GraphQLエンドポイントが、本来 status/message だけ返せばいいのに、通報された相手の recentLocation(最近の位置)や fullName(氏名) まで返してしまう。これがまさに**「企業横断の検索で、相手企業のPII(メール・電話・法人番号)が漏れる」**のと同じ構造です。

5.2 対策:二層スキーマ(public と detail)

OWASPの予防策の核心はこれです(原文)。

"Always make sure that the user should have access to the object's properties you expose."(公開する属性に対してユーザーがアクセス権を持つことを、常に確認せよ)

そして、

"Avoid using generic methods such as to_json() and to_string(). Instead, cherry-pick specific object properties"(to_json()to_string() のような汎用メソッドを避け、特定の属性を選び取れ)

"Keep returned data structures to the bare minimum, according to the business/functional requirements."(返すデータ構造は、ビジネス/機能要件に従って必要最小限に保て)

私はこれを**二層スキーマ(two-tier schema)**として実装しました。同じ User/Company でも、文脈に応じて2種類の出力スキーマを使い分ける

from pydantic import BaseModel, EmailStr

class UserPublicSchema(BaseModel):
    """企業横断の検索・閲覧で返す“公開層”。PIIを構造的に含まない。
    モデルにPIIフィールドが存在しないので、誤って漏らすことが型レベルで不可能。"""
    id: str
    display_name: str       # 表示名(PIIではない)
    industry: str
    company_name: str
    # email / phone / corporate_number は「定義されていない」= 出しようがない

class UserDetailSchema(BaseModel):
    """取引関係のある相手にだけ返す“詳細層”。PIIを含む。"""
    id: str
    display_name: str
    industry: str
    company_name: str
    email: EmailStr         # PII
    phone: str              # PII
    corporate_number: str   # 法人番号(PII)

ポイントは、公開スキーマに PII フィールドが「存在しない」こと。「to_json() で全部出して、PIIだけ後から消す」のではなく、最初から PII を持たない型に詰め替える。OWASPが言う「to_json() を避け、属性を cherry-pick せよ」を型で体現しています。UserPublicSchema を返す経路では、email を漏らそうとしてもそのフィールドが型に無いのでコンパイル/バリデーション段階で不可能です(型安全を防御に使う)。

5.3 詳細を出すのは「相互に取引関係がある場合のみ」

では UserDetailSchema(PII入り)をいつ返すか。木材流通DXでの答えは明確で、**「相互に取引関係が成立している相手にだけ」**です。検索段階では全社が UserPublicSchema(PIIなし)で、取引が始まって初めて連絡先が開示される。これは「名刺交換」のデジタル版です。

def get_company_view(target_id: str, ctx: TenantContext, repo) -> BaseModel:
    """文脈で出力スキーマを切り替える。デフォルトは PII を含まない public。"""
    target = repo.find_company(target_id)  # 内部でテナント可視性を担保
    if target is None:
        raise HTTPException(status_code=404)

    # 相互の取引関係を「サーバー側で」判定する。クライアントの主張は信じない。
    if repo.has_mutual_trade_relationship(ctx.tenant_id, target.tenant_id):
        return UserDetailSchema.model_validate(target)   # PII を開示
    return UserPublicSchema.model_validate(target)       # PII を除外(既定)

ここでもデフォルトが安全側(PIIなし)であることが重要です。has_mutual_trade_relationship が真を返したときだけ詳細層に昇格する。条件分岐を間違えても、フォールバックはPIIを出さない方に倒れます(fail safe)。そして取引関係の判定はサーバー側のデータで行う——「私たちは取引関係にあります」というクライアントの主張は信用しません(信頼境界はサーバー)。

書き込み側(Mass Assignment)も忘れない:API3:2023 は読み取りだけでなく書き込みも対象です。OWASP曰く、ユーザーが変更権限を持たない属性を、リクエストで勝手に追加/変更できてしまう脆弱性(例:"blocked": false"total_price": "$1" をボディに注入)。対策はOWASPの言う通り "Allow changes only to the object's properties that should be updated by the client"——入力スキーマも別に定義し、変更可能な属性だけを受け付ける。出力の UserDetailSchema をそのまま入力に使い回さない。入力には UserUpdateInput(変更可フィールドのみ)を用意し、tenant_idrole のような特権フィールドは入力スキーマに含めない(含めなければ注入されても無視される)。


6. 分離を「証明する」:テストとペネトレーション

ここまでの4層を実装しても、「漏れていないことを証明」しなければ意味がありません。「動いているように見える」は証明ではない。OWASPもBOLA予防策の最後にこう書いています。

"Write tests to evaluate the vulnerability of the authorization mechanism."(認可機構の脆弱性を評価するテストを書け)

6.1 許可と拒否の両方をテストする

認可テストでありがちな失敗は、「許可ケース」だけ書いて満足すること。A社のユーザーがA社のデータを見られる——これは機能テストであって、分離テストではありません。分離は「拒否ケース」でしか証明できない

import pytest

class TestTenantIsolation:
    """分離は「拒否」を証明して初めて担保される。許可ケースだけでは不十分。"""

    def test_cannot_read_other_tenant_object(self, client, tenant_a, tenant_b):
        """A社ユーザーが、B社のオブジェクトIDを直接叩いても見られない(BOLA)。"""
        b_deal = create_deal(owner=tenant_b)
        resp = client.get(f"/deals/{b_deal.id}", auth=tenant_a)
        # 403 ではなく 404(存在自体を秘匿。ID列挙の手がかりを与えない)。
        assert resp.status_code == 404

    def test_search_excludes_pii_across_tenants(self, client, tenant_a, tenant_b):
        """企業横断検索のレスポンスに、相手企業のPIIが含まれない(API3対策)。"""
        resp = client.get("/companies/search?q=sawmill", auth=tenant_a)
        body = resp.json()
        for company in body["results"]:
            assert "email" not in company           # PII は public スキーマに無い
            assert "phone" not in company
            assert "corporate_number" not in company

    def test_pii_revealed_only_with_mutual_trade(self, client, tenant_a, tenant_b):
        """取引関係が無い間はPIIなし、成立後にPIIが開示される(昇格の境界)。"""
        target = company_of(tenant_b)
        before = client.get(f"/companies/{target.id}", auth=tenant_a).json()
        assert "email" not in before               # 関係前: 公開層

        establish_mutual_trade(tenant_a, tenant_b)
        after = client.get(f"/companies/{target.id}", auth=tenant_a).json()
        assert "email" in after                    # 関係後: 詳細層に昇格

    def test_industry_forbidden_action_returns_403(self, client, viewer_user):
        """ホワイトリスト外の業種/ロールは 403(機能レベル認可)。"""
        resp = client.post("/deals", json={...}, auth=viewer_user)
        assert resp.status_code == 403            # 機能の権限不足は 403

拒否ケースを網羅的に書く——「他テナントのIDを叩く」「検索結果にPIIが混ざらない」「権限外の業種で操作する」。これらが全部 deny で返ることを、CIで回し続ける。許可ケースのテストは機能の証明、拒否ケースのテストは分離の証明です。両方が要ります。

6.2 第三者ペネトレーションで「実際のロール」で叩く

自動テストは自分の想定の範囲しか検証できません。最後は第三者ペネトレーションで、想定の外を叩いてもらいます。木材流通DXでは、実在する15ロールを使った第三者ペネトレーションを実施しました。

結果、初回診断でクロステナントのPII漏洩(B-1, HIGH)が1件検出されました。「二層スキーマを実装したつもり」でも、ある経路で詳細層が漏れていた——これが**「動いているように見える」と「証明された」の差です。これを即日修正・再診断し、0件を確認。あわせて全221エンドポイントの認証欠落0件**を実証しました。

1件見つかったことを隠す必要はありません。むしろ**「第三者に実ロールで叩かせ、見つかった穴を即日塞ぎ、再診断で0を証明した」**という事実こそが信頼の根拠です。テストとペネトレは「漏れていないこと」ではなく「漏れていたら検知して塞げること」を証明する仕組みです。分離は宣言ではなく、検証で担保します。


7. まとめ:マルチテナント分離チートシート

迷ったときの早見表です。マルチテナントの安全は、4層ので決まります。

  • どこで分けるか:検索/集計が企業を跨ぐなら pool(共有+論理分離)。高コンプラ・大口は silo(専有)。PIIだけ隔離したいなら bridge。「DBを分ければ安全」ではない——分離は実行時ポリシーで担保する論理構造(AWS)。
  • 全クエリにテナント条件を強制tenant_id の出所は検証済みJWTただ一つTenantScopedRepository で型として強制し、最後の砦はDBのRLS。開発者の注意力に頼らない(AWS:分離強制を開発者任せにするな)。
  • オブジェクト単位の認可(BOLA):クライアントの id毎回、所有チェック。取得段階で tenant_id を絞る。推測しにくいIDで列挙を封じ、所有しないものは 404 で存在を秘匿(OWASP API1)。
  • 機能の認可をルーター層に一元化:業種/ロールを frozenset ホワイトリストで宣言し、fail closed。不一致は 403。認可をハンドラに散らさない(AWS:認可を一元化・抽象化)。
  • PIIをスコープ(二層スキーマ):企業横断は PIIなしの UserPublicSchema、詳細は相互取引関係があるときだけ UserDetailSchemato_json() で全部出さず属性を cherry-pick。デフォルトは安全側(OWASP API3)。
  • 証明する許可と拒否の両方をテスト。拒否ケースが分離の証明。最後は第三者ペネトレを実ロールで。「動くように見える」≠「証明された」。

そして全層を貫く一本の原則——信頼境界はクライアントではなく、サーバー/DBに置く。クライアントが送ってきた tenant_idid も「取引関係がある」という主張も、検証するまで一切信用しない。


マルチテナントの分離は、「たぶん大丈夫」では売れません。「A社がB社のPIIを見られないこと」を、テストとペネトレで証明できて初めて、本番に載せられる。私は招待制B2B SaaS(木材流通DX)で、業種ベース認可と二層スキーマを設計・実装し、第三者ペネトレーション(実在15ロール)で検出されたクロステナントPII漏洩を即日修正、再診断で0件を、全221エンドポイントの認証欠落0件とともに実証しました(経済産業大臣賞受賞)。生成AI(Claude Code)を相棒に開発を加速しつつ、分離・認可・PIIといった「間違えたら終わる」境界には人間の検証ゲートを必ず置く——これが私の一人開発のやり方です。

「自社のマルチテナント SaaS で、隣のテナントが見えない・PIIが漏れないと証明できる設計を組みたい」——その要件整理から、分離戦略の選定・実装・テスト/ペネトレ設計まで、一気通貫で伴走します。 設計の初期段階からでも、お気軽にご相談ください。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通DXのB2B SaaS(二層スキーマ+業種ベース認可でクロステナントPII漏洩0件を実証)

ケーススタディを見る