Skip to main content
友田 陽大
Databases & RLS
マルチテナント
B2B SaaS
セキュリティ
アーキテクチャ設計
認可

Designing Data Isolation and Authorization for Multi-Tenant SaaS: Harden the Tenant Boundary, PII Protection, and BOLA Countermeasures with 'The Trust Boundary Is the Server'

A data-isolation and authorization design guide for never leaking another tenant's data or PII in a B2B multi-tenant SaaS. We explain — in real code faithful to AWS official and OWASP — the silo/pool/bridge isolation strategies, BOLA/IDOR and ID-enumeration countermeasures, PII scoping with a two-tier schema, industry/role authorization consolidated into the router layer, and the tests/penetration that prove isolation.

Published
Reading time
25 min read
Author
友田 陽大
Share

"Multiple companies use the same system" — as a B2B SaaS requirement, it's one line. But the moment you try to put it into production, the heaviest question stands up. "Can you prove that a company A user can absolutely never see company B's data (email, phone, corporate number)?"

If you can only answer "probably fine" to this, you must not sell that SaaS yet. The biggest risk of multi-tenant is not a functional bug but "the neighboring tenant being visible" — cross-tenant data/PII leakage. Once it happens, trust doesn't return.

This article is a design guide for hardening the tenant boundary, PII protection, and authorization at production quality in a B2B multi-tenant SaaS. As subject matter, I mix in design decisions from an invite-only B2B subscription SaaS I built (lumber-distribution DX / Lumber-Distribution DX B2B SaaS. A service where 7 industries — forestry → market → sawmill → precut → builder → maker — search for trading partners across companies. METI Minister's Award). Because this service searches for trading partners across companies, "how far to show, and from where to hide, the counterpart company's PII" became the business requirement itself.

The rules of this article: the concepts of isolation/authorization are based on AWS's SaaS documentation (SaaS Architecture Fundamentals / SaaS Tenant Isolation Strategies / Prescriptive Guidance) and OWASP API Security Top 10 (2023) (both as of June 2026. Since specs can be revised, always check the latest in the official documentation cited later before going to production). And this article's consistent principle is just one — place the trust boundary not on the client but on the server/DB. We trust neither the tenant_id nor the id that came from the client until verified.


0. Mental model: isolation is a "product"

First, let me fix the overall picture. Multi-tenant means "sharing one foundation among multiple tenants." Precisely because it's shared, leave it alone and the neighbor is visible. Safe multi-tenant is built on the product (AND) of 4 independent layers. Miss even one and it leaks.

  1. Where to divide (silo / pool / bridge) — where to draw the physical/logical boundary of isolation.
  2. Enforce the tenant condition on all queries — every data access carries the tenant scope without exception. Prevent leaks with a mechanism, not the grit of code review.
  3. Object-level authorization (BOLA countermeasure) — check every time whether the id the client passed is "really this person's property."
  4. Scope PII in the response (two-tier schema) — return only the attributes that may be returned. Don't dump everything with to_json().

This article hardens these 4 layers in order. First, an important distinction.

"Authentication/authorization" and "tenant isolation" are different things

Start design while confusing this, and you'll definitely have an accident. AWS's SaaS Architecture Fundamentals clearly states this.

"the fact that a tenant user is authenticated does not mean that your system has achieved isolation."

And going further, it also says:

"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."

That is — being able to log in (authentication), having the permission for a certain operation (authorization), and the neighboring tenant being invisible (isolation) are 3 separate guarantees. Just "verifying the JWT" or "checking the role" protects the tenant boundary not one millimeter. Tenant isolation is, in AWS's definition, "using the tenant context to limit the resources that can be accessed" — a mechanism that enforces the context of tenant_id against all resource access.

Relation to complementary articles: this article handles app-layer tenant isolation and authorization design. The story of defending the same boundary in depth at a lower layer is carved out into separate articles. For enforcement at the DB layer (not leaking even if the app forgets to write WHERE, with Postgres RLS), see Postgres RLS Design on the Premise of an Untrusted Client; for token verification at the entrance (correctly verifying the JWT, the origin of the tenant context), see Cognito JWT (RS256) Verification. The three are defense in depth that takes effect layered, and don't compete (RLS = the last bastion, JWT verification = the context's trustworthiness, this article = how to use that context).


1. Where to divide: silo / pool / bridge

The first step of isolation is deciding "at what granularity to draw the tenant boundary." Following AWS's organization, grasp the 3 models. As an important premise, AWS's Tenant Isolation Strategies drives this nail in: "isolation is not a resource-level structure."

"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."

That is, it's not the simple story that "divide the DB per tenant and it's safe, share it and it's dangerous." Even shared, you can logically isolate with run-time policies. The options for that granularity are silo / pool / bridge.

1.1 The definition of the 3 models

  • Silo: monopolize the whole resource stack per tenant. Tenants A and B have separate DBs / separate schemas / separate accounts. The boundary is the infrastructure itself.
  • Pool: all tenants share resources. All companies' data coexists in the same table of one DB, and you logically isolate with a tenant_id column and a run-time policy (e.g., row-level security).
  • Bridge: a mix of both. Some resources are shared (pool), some monopolized (silo). For example, "the app DB is pool, but only PII and payment info are isolated into a silo monopolized table."

1.2 Decision table: silo vs pool vs bridge

ViewpointSilo (tenant-monopolized)Pool (tenant-shared)Bridge (mixed)
Isolation degreeStrongest (the boundary is infrastructure)Logical isolation (the boundary is code/policy)Use by data sensitivity
Blast radius (the damage range on an accident)Limited to 1 tenantA design mistake can ripple to all tenantsSensitive data is limited, general data is shared
CostHigh (resources increase by the number of tenants)Low (share resources)Medium
Operation/scaleProvisioning load per tenant increaseAdding a tenant is cheap and immediateThe complexity of operating two systems
Cross-tenant searchDifficult (can't span since DBs are separate)Easy (narrow the same table with WHERE)Possible only on the shared side
Suited forHigh compliance (medical/finance requiring "a DB per tenant"), large tenantsMany SMBs, low unit price, services where search/aggregation spansGeneral B2B wanting to isolate only PII

AWS's Tenant Isolation Strategies also explicitly states that the domain constrains the isolation method.

"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."

1.3 My choice: why it was pool + industry-based authorization

The lumber-distribution DX's core feature is "searching for trading partners across companies." A forestry company searches for sawmills, a builder for makers, across industries. This structurally doesn't hold with silo — because if the DB is divided per tenant, cross-company search is impossible.

So I chose pool (all companies share the same table) and drew the tenant boundary logically with code and policy. But pool's weakness is "a design mistake ripples to all tenants (the blast radius is large)." That's exactly why I needed to layer the later 2nd–4th layers (enforcing the tenant condition on all queries, BOLA countermeasures, the two-tier schema) in multiple layers. "If you share, guarantee the logical isolation with a mechanism" — this is the responsibility you shoulder the moment you choose pool (KISS: choose the minimal structure for the functional requirement, and thicken the safety measures accordingly).


2. Enforce the tenant condition on all queries

Once you choose pool, the biggest enemy is "forgetting to write WHERE tenant_id = ?." Mix in a bare query in even one place, and all tenants leak from there. AWS's Tenant Isolation Strategies clearly writes that you must not rely on human attention for this.

"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)."

"Being careful" doesn't protect isolation. Enforce with a mechanism. Let me show the app-layer implementation pattern in 3 stages.

2.1 Establish the tenant context in one place

First, establish the tenant context per request from the verified JWT and fix it to the request scope. We absolutely never use the tenant_id parameter the client sent (the trust boundary is the server).

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"],
    )

The point is to fix the origin of tenant_id to the JWT alone (Single Source of Truth). Don't create a path that trusts a query parameter like ?tenant_id= or a tenant_id in the request body. The moment you create the path, it becomes the attack surface.

2.2 A repository that enforces the tenant scope with "types"

Next, consolidate all data access behind a repository that requires the tenant context. The aim is to make a bare session.query(User) uncallable from app code.

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))

TenantScopedRepository can't even be instantiated without a TenantContext. This physically eliminates "a bare query that forgot the tenant condition" from the app layer. This is the app-layer version of what AWS calls "a shared mechanism that applies isolation rules outside the developer's view."

2.3 Place the last bastion at the DB layer (RLS)

But a repository too is human-written code, so "a direct query not via the repository" could mix in the future. So place the last bastion at the DB. With Postgres's row-level security (RLS), return only the rows matching the session variable's tenant ID. AWS also says RLS is required in the pool model.

-- 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>' する。

The point is the double layering of the app layer (enforced by types) and the DB layer (enforced by RLS). The RLS implementation details I leave to Postgres RLS Design on the Premise of an Untrusted Client to avoid overlap. This article concentrates on the layer of "enforcing the tenant condition at the app layer."


3. Object-level authorization: BOLA / IDOR countermeasures

Even after carrying the tenant condition on all queries, there's still a hole. It's the case where, within the same tenant, or with an ID guessed across the tenant boundary, an object you don't have access rights to gets grabbed. This is #1 of the OWASP API Security Top 10, API1:2023 Broken Object Level Authorization (BOLA).

3.1 What is BOLA

OWASP's definition is this. BOLA occurs because the server relies on the client-supplied object ID and decides access without appropriately verifying the user's permission.

"attackers can exploit API endpoints that are vulnerable to broken object-level authorization by manipulating the ID of an object"

This is the most common and most impactful API vulnerability. OWASP states the impact can be "data disclosure to unauthorized parties, data loss, or data manipulation," and even "full account takeover." IDOR (Insecure Direct Object Reference) is a type of this.

OWASP's attack scenario is vivid. An attacker who notices a pattern like /shops/{shopName}/revenue_data.json swaps the shop name and extracts the sales data of thousands of shops. Even if you carry tenant_id on the WHERE, if the ownership check of another object ID like {shopName} is missing, it leaks from there.

3.2 Countermeasure 1: do an ownership check every time

The core of OWASP's prevention is this (original text).

"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."

The keyword is "in every function." Don't make an exception. In implementation, thoroughly do "fetch by ID → confirm the fetched result's tenant/owner matches the current context → reject if mismatched." The earlier TenantScopedRepository.get_user satisfies this in the form of "don't return other tenants' rows in the first place" by putting tenant_id in the WHERE clause. Rather than rejecting after fetch with if obj.tenant_id != ctx.tenant_id, narrowing at the fetch stage is harder to leak and faster.

3.3 Countermeasure 2: don't let IDs be guessed or enumerated

OWASP's other prevention.

"Prefer the use of random and unpredictable values as GUIDs for records' IDs."

A sequential integer ID allows brute force (enumeration attack) of /users/1001/users/1002. Miss the ownership check in even one place, and it's all extracted at once by enumeration. So use a hard-to-guess ID (UUIDv4, etc.) to raise the cost of enumeration.

But beware — a random ID is not "a reason not to do an ownership check." It's merely one layer of defense in depth. OWASP's stance is clearly to fix the authorization mechanism itself, not to rely on the un-guessability of IDs.

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

A trick to not leak existence: when another tenant's ID is hit, returning 403 Forbidden (= "it exists but you don't have permission") tells them the very fact that that ID exists. Lean an object you don't own to 404 Not Found (don't even say whether it exists) to dull the attack of probing for "existing IDs" by enumeration. On the other hand, the distinction of making an industry/role mismatch (= function-level insufficient permission) 403 takes effect in practice (described later).


4. Consolidate authorization into the router layer: industry/role authorization

If BOLA is about "is this object this person's (object level)," what's handled here is the authorization of "is this person a role/industry that may do this operation (function level)." In the lumber-distribution DX, 7 industries + viewer/manager roles had different permissions per feature.

4.1 Don't scatter the authorization logic (SRP and "separation of the decision")

AWS Prescriptive Guidance clearly rejects the conventional method of embedding authorization logic in app code.

"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."

And what it recommends is centralization and separation of the authorization logic.

"Authorization logic can be centralized ... and abstracted from the application code and can be applied as a repeatable pattern to all APIs"

AWS organizes this with the structure of PEP (Policy Enforcement Point: the point that enforces the decision) / PDP (Policy Decision Point: the point that decides) / PAP (Policy Administration Point: the point that manages policies). At large scale, it's a design that puts a dedicated engine like Amazon Verified Permissions (Cedar) or OPA (Rego) as the PDP.

But YAGNI — if it's invite-only B2B, the role is on the JWT, and the number of industries is finite, an external policy engine is over-spec. I chose the lightweight version of "consolidate the PEP into the router layer, and hold the decision as a whitelist." The essence (= don't scatter authorization, enforce it centrally at the entrance) is the same.

4.2 Bind industries with a frozenset whitelist

Declaratively define "which industries are permitted" per feature with a frozenset (immutable set). Gather the permit list in one place as code data and consolidate the decision into the router layer.

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 にマップする。"""

There are two reasons to choose frozenset. (1) Being immutable, it doesn't get rewritten at runtime (the authorization rules aren't contaminated), and (2) the in check is O(1) and fast. A list would be O(n) and mutable. It's a small choice, but in a place like authorization that "needs both safety and speed," the type choice takes effect (type safety × performance).

4.3 Enforce it centrally at the router layer (mismatch is 403)

Consolidate the decision into one decorator / dependency, and have all routes pass through it. This is gathering AWS's PEP (enforcement point) into one place.

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 (deny by default) is the crux. Treat an action not defined in the whitelist as "a missing definition = deny everyone." Return an empty set with get(action, frozenset()) and no one passes. Make it the reverse fail open (pass if undefined), and the moment you add a route and forget to write the authorization definition, a hole opens. Add a new feature and it won't work unless you also add authorization — this friction is exactly the safety device.

Why the router layer: write authorization in the handler body or the service layer, and the same decision scatters (a DRY violation), and miss fixing one place and it becomes a hole. Consolidate at the entrance (the router), and you can visually confirm in code review that "all routes always pass through authorization." This is SRP (authorization is consolidated into authorization's responsibility), and the implementation of AWS's "apply a repeatable pattern to all APIs." And rejecting a mismatch with 403 also has the effect of removing the foothold for an ID-enumeration attack (a feature you don't have permission for returns nothing even if you hit it).


5. Scope PII in the response: the two-tier schema

By this point, "who can do which operation on which object" is hardened. The last layer is "given that you can access it, up to which 'attribute' do you return." Get this wrong, and you return excessive attributes (PII) to a legitimate user who passed authorization. OWASP's API3:2023 Broken Object Property Level Authorization is exactly this.

5.1 Excessive Data Exposure

API3:2023 integrates the 2019 edition's "Excessive Data Exposure" and "Mass Assignment." OWASP explains it like this. The read-side vulnerability is returning, in the API response, object attributes the user doesn't have view permission for. The root cause is the API exposing, without attribute-level authorization, "endpoints that return all object's properties."

OWASP's attack scenario is symbolic. A GraphQL endpoint, which should only return status/message, also returns the reported party's recentLocation and fullName. This is exactly the same structure as "in a cross-company search, the counterpart company's PII (email, phone, corporate number) leaks."

5.2 Countermeasure: a two-tier schema (public and detail)

The core of OWASP's prevention is this (original text).

"Always make sure that the user should have access to the object's properties you expose."

And,

"Avoid using generic methods such as to_json() and to_string(). Instead, cherry-pick specific object properties"

"Keep returned data structures to the bare minimum, according to the business/functional requirements."

I implemented this as a two-tier schema. Even for the same User/Company, use 2 kinds of output schema by context.

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)

The point is that the public schema "doesn't have" PII fields. Rather than "dump everything with to_json() and erase only the PII later," re-pack into a type that doesn't have PII from the start. It embodies, with types, OWASP's "avoid to_json(), cherry-pick attributes." On a path that returns UserPublicSchema, trying to leak email is impossible at the compile/validation stage because that field isn't in the type (using type safety for defense).

5.3 Show the detail "only when there's a mutual trade relationship"

So when do you return UserDetailSchema (with PII)? The answer in the lumber-distribution DX is clear: "only to a party with whom a mutual trade relationship is established." At the search stage, all companies are UserPublicSchema (no PII), and contact info is disclosed only after trade begins. This is the digital version of "exchanging business cards."

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 を除外(既定)

Here too, it's important that the default is the safe side (no PII). It's promoted to the detail tier only when has_mutual_trade_relationship returns true. Mistake the conditional branch, and the fallback falls to the side that doesn't emit PII (fail safe). And the trade-relationship judgment is done with server-side data — we don't trust the client's claim of "we are in a trade relationship" (the trust boundary is the server).

Don't forget the write side (Mass Assignment): API3:2023 covers not only reads but writes. OWASP says it's a vulnerability where a user can arbitrarily add/change, in the request, attributes they don't have change permission for (e.g., injecting "blocked": false or "total_price": "$1" into the body). The countermeasure, as OWASP says, is "Allow changes only to the object's properties that should be updated by the client"define a separate input schema too and accept only the changeable attributes. Don't reuse the output UserDetailSchema as-is for input. Prepare a UserUpdateInput (changeable fields only) for input, and don't include privileged fields like tenant_id or role in the input schema (don't include them, and they're ignored even if injected).


6. "Prove" the isolation: tests and penetration

Even after implementing the 4 layers so far, it's meaningless unless you "prove it's not leaking." "Looks like it's working" is not proof. OWASP also writes this at the end of the BOLA prevention.

"Write tests to evaluate the vulnerability of the authorization mechanism."

6.1 Test both allow and deny

The common failure in authorization testing is being satisfied writing only the "allow case." A company A user can see company A's data — this is a functional test, not an isolation test. Isolation can only be proven by the "deny case."

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

Write deny cases exhaustively — "hit another tenant's ID," "PII doesn't mix into the search results," "operate with an out-of-permission industry." Keep running in CI that all of these return deny. The allow-case tests are proof of function, the deny-case tests are proof of isolation. Both are needed.

6.2 Hit it with "actual roles" in a third-party penetration test

Automated tests can only verify within the range of your own assumptions. Finally, have a third-party penetration test hit outside the assumptions. In the lumber-distribution DX, I conducted a third-party penetration test using 15 actual roles.

As a result, the initial assessment detected 1 cross-tenant PII leak (B-1, HIGH). Even "thinking I implemented the two-tier schema," on a certain path the detail tier was leaking — this is the difference between "looks like it's working" and "proven." I fixed it the same day and re-assessed, confirming 0. Together, I proved 0 missing-authorization findings across all 221 endpoints.

There's no need to hide that 1 was found. Rather, the fact that "I had a third party hit it with actual roles, plugged the found hole the same day, and proved 0 on re-assessment" is exactly the grounds for trust. Tests and penetration are a mechanism that proves not "it's not leaking" but "if it were leaking, we can detect and plug it." Isolation is guaranteed not by declaration but by verification.


7. Summary: the multi-tenant isolation cheat sheet

A quick reference for when you're unsure. The safety of multi-tenant is decided by the product of 4 layers.

  • Where to divide: if search/aggregation spans companies, pool (shared + logical isolation). High-compliance / large tenants, silo (monopolized). To isolate only PII, bridge. "Divide the DB and it's safe" is not it — isolation is a logical structure guaranteed by run-time policy (AWS).
  • Enforce the tenant condition on all queries: the origin of tenant_id is the verified JWT alone. Enforce it as a type with TenantScopedRepository, and the last bastion is the DB's RLS. Don't rely on the developer's attention (AWS: don't leave isolation enforcement to developers).
  • Object-level authorization (BOLA): ownership-check the client's id every time. Narrow tenant_id at the fetch stage. Seal enumeration with a hard-to-guess ID, and conceal existence with 404 for what you don't own (OWASP API1).
  • Consolidate function authorization into the router layer: declare industry/role with a frozenset whitelist, and fail closed. Mismatch is 403. Don't scatter authorization into handlers (AWS: centralize and abstract authorization).
  • Scope PII (two-tier schema): cross-company is the PII-less UserPublicSchema, the detail is UserDetailSchema only when there's a mutual trade relationship. Don't dump everything with to_json(); cherry-pick attributes. The default is the safe side (OWASP API3).
  • Prove it: test both allow and deny. The deny case is the proof of isolation. Finally, a third-party penetration test with actual roles. "Looks like it works" ≠ "proven."

And the one principle running through all the layers — place the trust boundary not on the client but on the server/DB. Trust neither the tenant_id nor the id the client sent, nor the claim of "we have a trade relationship," until verified.


Multi-tenant isolation can't be sold with "probably fine." Only when you can prove "company A can't see company B's PII" with tests and penetration can you put it into production. In an invite-only B2B SaaS (lumber-distribution DX), I designed and implemented industry-based authorization and a two-tier schema, and proved that the cross-tenant PII leak detected by a third-party penetration test (15 actual roles) was fixed the same day, and 0 on re-assessment, together with 0 missing-authorization findings across all 221 endpoints (METI Minister's Award). While accelerating development with generative AI (Claude Code) as a partner, I always place a human verification gate on boundaries that "end you if you get them wrong," like isolation, authorization, and PII — this is my way of solo development.

"I want to build, for my own multi-tenant SaaS, a design that can prove the neighboring tenant isn't visible and PII doesn't leak" — from organizing those requirements to selecting, implementing, and designing the tests/penetration for the isolation strategy, I accompany you end-to-end. Feel free to consult us, even from the early design stage.


Reference (Official Documentation)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading