Skip to content
··3分で読めます

2日目にマルチテナンシーを構築した。67日目に作り直した

すべてのテーブルにorg_idを追加すれば完璧なマルチテナンシーだと思っていました。しかしセキュリティ監査で、エージェンシーがSMEテーブルに書き込んでいることが判明しました。バグではなく、設計によるものでした。

2025年10月27日、午後11時47分。STRAŦUMでルーティンのセキュリティ監査を実行中です。数週間すべて順調に動いていました — SMEには自分のデータ、エージェンシーにはそのデータ、マルチテナンシーは堅牢。

コーヒーが冷めています。監査スクリプトがログを処理しています。そして見えました:エージェンシーがSMEテーブルに書き込んでいた

バグではありません。セキュリティホールでもありません。アーキテクチャ自体を通じてです。

2ヶ月前、2日目からマルチテナントアーキテクチャを構築する決断をしました。動くAIエージェントが1つしかないソロファウンダーにとっての大胆な決断。すべてのテーブルにorg_idを追加し、RLSポリシーを書き、SMEとエージェンシー用に別々のルーティングを構築しました。動いていました — SMEにはキャンペーン、エージェンシーにはクライアント、データは正しい場所に流れていました。

そう思っていました。

スキーマを見つめたまま、恐らく20分座っていました。どうしてこれを見逃した?マルチテナントアーキテクチャの構築に数週間を費やし、83のRLSポリシーを書き、SMEとエージェンシー両方のアカウントでテストしました。すべてが_動いていました_。でも「動く」と「正しい」は同じではありません。

これはソフトウェアを構築すべきかどうか疑問に思わせる種類のバグです。タイポではない。見落としたエッジケースではない。アーキテクチャの素朴さです。

org_idフィルタリングがマルチテナント分離に十分だと思い込んでいました。十分ではありませんでした。

真のマルチテナント分離にはすべてのテーブルにorg_idを追加する以上のことが必要だと発見した話 — そしてそれを最終的に解決した48時間で33のマイグレーションの話です。

---

> 注記:このポストのSQL例はセキュリティのため汎用的なスキーマとテーブル名(tenant_bworkspace_entitiesentity_data)を使用しています。具体的な命名規則に関係なく、コンセプトは同じです。

---

問題:すべてのテナントが平等ではない

最初に構築したものはこちらです:

```sql
-- ブランドガイドラインテーブル(SMEとエージェンシーで共有)
CREATE TABLE brand_guidelines (
  id UUID PRIMARY KEY,
  org_id UUID REFERENCES organizations(id),
  name TEXT,
  guidelines JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLSポリシー(安全に見える)
CREATE POLICY brand_guidelines_org_isolation ON brand_guidelines
  FOR ALL TO authenticated
  USING (org_id = get_user_org_id());
```

SMEには完璧に動きます。各組織には独自のブランドガイドラインがあります。Row-Level Securityにより互いのデータが見えないことを保証します。(少なくともそう思っていました。その点については後ほど。)

しかしエージェンシーは違います。

エージェンシーにはブランドガイドラインが1セットだけではありません。クライアントごとに1つあります:

- クライアントAのブランドガイドライン(鮮やかなカラーパレット、大胆なタイポグラフィ、イノベーション重視のメッセージング)

- クライアントBのブランドガイドライン(落ち着いたカラーパレット、ミニマルデザイン、品質重視のポジショニング)

同じエージェンシー、異なるクライアント、全く異なるブランド。

素朴なソリューション(最初に構築したもの):

```sql
-- 共有テーブルにentity_idを追加
ALTER TABLE brand_guidelines ADD COLUMN entity_id UUID;

-- RLSポリシーを更新
CREATE POLICY brand_guidelines_isolation ON brand_guidelines
  FOR ALL TO authenticated
  USING (
    organization_id = get_user_org_id() AND
    (entity_id IS NULL OR entity_id = get_user_entity_id())
  );
```

問題:2つの異なるデータモデルを持つテーブルが作られました:

```
SME行:      organization_id='org-123',  entity_id=NULL,      guidelines={...}
Agency行:   organization_id='org-456',  entity_id='entity-a', guidelines={...}
Agency行:   organization_id='org-456',  entity_id='entity-b', guidelines={...}
```

クエリは複雑なNULLハンドリングで混乱し、すべての機能にアプリケーションコードで「if SME, else Agency」ロジックが必要になりました。

そしてはい、これらがより深い問題の症状だと気づく前に全部書きました。数週間の作業が、すべて同じ結論を指していました:アーキテクチャで自分を追い込んでいたのです。

すべての機能にカスタムロジックが必要でした:「SMEならこう。Agencyならこう。」

さらに悪いことに、アーキテクチャが間違った前提を置いていました:

- エージェンシーがentity_id=NULLで書き込むとSMEデータを汚染

- SMEがサブアカウントを持ちたくてもサブエンティティを持てない

- スキーマがnullableカラムだらけの「スイスチーズ」に

これはマルチテナントアーキテクチャではありませんでした。2つの異なるデータモデルに1つのテーブルで対応しようとしていたのです。

---

気づき:異なるテナントには異なるスキーマが必要

10月下旬までに、真実に気づきました:SMEとエージェンシーは同じデータモデルを共有しない

SMEデータモデル

```
organization → campaigns → agent_outputs
```

エージェンシーデータモデル

```
organization → workspace_entities → campaigns → agent_outputs
                   ↓
            entity_data (例:ブランドガイドライン、ペルソナ)
```

エージェンシーにはSMEにはないレイヤー全体(workspace entities)があります。また、SMEの世界には存在すべきでないエンティティ固有のインテリジェンスもあります。

ソリューション:データベーススキーマの分離。

```sql
-- SMEテーブル(publicスキーマ)
public.brand_guidelines
public.campaigns
public.outputs

-- エージェンシーテーブル(tenant_bスキーマ)
tenant_b.workspace_entities
tenant_b.entity_data  -- ブランドガイドライン、ペルソナなどを含む
tenant_b.campaigns
tenant_b.outputs
```

SMEとエージェンシーが完全に異なるテーブルを持つようになりました。共有スキーマなし。nullableのentity_id汚染なし。「if SME, else Agency」ロジックなし。

---

なぜこれが重要か:スキーマルーティングのビジネスケース

技術的な実装に入る前に、なぜこのアーキテクチャ決定が「きれいなコード」を超えて重要なのかについて話しましょう。

成長への将来の備え(たぶん)

スキーマルーティングは今日の問題を解決するだけではありません。予測もできない機会への扉を開けておくことです。

Private Alphaで15人のユーザーがいます。エンタープライズ顧客はいません。GDPR弁護士に相談したこともありません。しかし、STRAŦUMが成長した場合にスキーマルーティングが_可能にする_ことはこちらです:

国際展開:

- EUに展開する場合:別々のスキーマでEUサーバー上のeu_agencyスキーマにデータレジデンシーを実現可能

- 削除権がシンプルに:混合テーブルをフィルタリングではなく、1つのスキーマをクエリ

- 監査証跡:「クライアントXの全データを見せて」= 1つのスキーマクエリ

コンプライアンスの会話:

- いつか「データ分離をどう保証していますか?」と聞かれた時

- org_idフィルタリングの場合:「Row-Level Securityポリシーを使っています」(曖昧、検証しにくい)

- スキーマルーティングの場合:「各クライアントのデータは別々のデータベーススキーマにあります」(具体的、監査可能)

- これが重要かどうかまだ分かりません。でもそういう会話が来たら_重要になる可能性_があります。

正直なところ:

今はHIPAAやSOC 2コンプライアンスのために構築しているわけではありません。より良いマーケティング戦略を必要とするSMEと小規模エージェンシーのために構築しています。

でもスキーマルーティングがあれば、「ヘルスケアクライアントに対応できますか?」「データレジデンシーをサポートしていますか?」といつか聞かれた時、答えは「はい、アーキテクチャをお見せしましょう」であって「まずすべてを再構築させてください」ではありません。

デメリット(正直に)

スキーマルーティングはメリットばかりではありません。実際のコストはこちらです:

開発の複雑さ:

- すべてのWRITE操作にルーターファンクションが必要

- すべてのREAD操作にセキュリティビューが必要

- テストにSMEとエージェンシー両方のパスが必要

- Claude Code使用で:夕方に2日間の集中作業(2025年10月27-29日)

- AIツールなしなら:数週間かかっていたでしょう

マイグレーションリスク:

- 33のシーケンシャルマイグレーション = 33のタイプミスの機会

- 1つの間違ったALTER TABLE = 本番データの破損

- 本番に触る前にステージングで各マイグレーションを3回実行

- パラノイアは本物でした

クエリパフォーマンスのオーバーヘッド:

- UNION ALLを持つビュー = やや遅い読み取り

- ルーターファンクション = 書き込み時の追加ファンクションコール

- RLS + ビュー = より複雑なクエリプラン

- (実際には:まだスローダウンに気づいていませんが、アルファユーザー15人しかいません)

運用の複雑さ:

- スキーママイグレーションが2つ以上のスキーマに影響(public + agency)

- データベースバックアップにスキーマ対応のリストアが必要

- モニタリングクエリが複数スキーマをチェックする必要

- いつかこれに噛まれるでしょう、いつかは分かりません

それでもトレードオフを受け入れた理由

オプション価値は莫大かもしれない。もしくは全く重要でないかもしれない。

スキーマルーティングは、歩きたいかどうかさえ分からない扉を開けておきます:

- ホワイトラベルパートナーシップ:パートナーに独自のスキーマを与え、UIをリブランド

- リセラーの機会:エージェンシーが証明可能なデータ分離付きでリセール

- 異なる価格帯:「プレミアム」顧客に専用スキーマ

- 地理的展開:EUスキーマ、USスキーマ、APACスキーマ — 同じコードベース

ここが重要です:Private Alphaです。これらのどれかが重要になるか分かりません。ホワイトラベルのリクエストは一度も来ないかもしれません。地理的展開は何年も先かもしれません。ビジネス全体がピボットしてこのどれも関係なくなるかもしれません。

しかし分かっていること:スキーマルーティングがあれば、これらのオプションが存在するということです。org_idフィルタリングでは、ほとんどが完全なリライトを必要とするでしょう。

これが私の賭けです: 今2日余分に使って(Claude Codeと一緒に)後でオプションを残しておく。

正しい賭けか?1年後に聞いてください。

---

アーキテクチャ:スキーマルーティング

パターン1:スキーマ固有テーブル

一部のテーブルは1つのテナントタイプにのみ存在します:

```sql
-- 特化テナントスキーマ
CREATE SCHEMA tenant_b;

-- Workspace entities(このテナントタイプに固有)
CREATE TABLE tenant_b.workspace_entities (
  id UUID PRIMARY KEY,
  organization_id UUID,
  name TEXT,
  metadata JSONB
);

-- エンティティ固有データ
CREATE TABLE tenant_b.entity_data (
  id UUID PRIMARY KEY,
  organization_id UUID,
  entity_id UUID REFERENCES tenant_b.workspace_entities(id),
  data_type TEXT,
  content JSONB
);
```

SMEはこれらのテーブルに触れることはありません。publicスキーマには存在しません。

パターン2:データベースルーターファンクション

どうやって正しいスキーマに書き込むか?ルーターファンクションです。

コンセプト(簡略化):

```sql
CREATE FUNCTION save_resource_routed(params)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER  -- 昇格権限で実行
AS $$
BEGIN
  -- ステップ1:組織タイプを検出
  SELECT type INTO org_type FROM organizations WHERE id = p_org_id;

  -- ステップ2:タイプに基づいて正しいスキーマにルーティング
  IF org_type = 'TENANT_B' THEN
    INSERT INTO tenant_b.entity_data (...) VALUES (...);
  ELSE
    INSERT INTO public.brand_guidelines (...) VALUES (...);
  END IF;

  RETURN result;
END;
$$;
```

仕組み

1. 組織タイプを検出:organizationsテーブルをクエリしてテナントタイプを決定

2. 正しいスキーマにルーティング:タイプに基づいて適切なスキーマに書き込み

3. 結果を返す:デバッグ用にどのスキーマが使われたかを含む

アプリケーションコード(全テナントタイプで同じ):

```typescript
// ルーターファンクションを呼ぶだけ - テナント固有のロジックなし
const result = await supabase.rpc('save_resource_routed', {
  p_org_id: orgId,
  p_entity_id: entityId,  // シンプルテナントの場合はnull
  p_data: { ... }
});
```

アプリケーションコードにif/elseなし。データベースがルーティングします。

最初のルーターファンクションの作成に4時間。なぜ動かないかのデバッグに更に6時間。問題は?EXECUTE権限のグラントを忘れていました。典型的なソロファウンダーのエネルギー:アーキテクチャの素晴らしさ、権限の見落とし。 :P

パターン3:Security-Invokerビューによる読み取り

書き込みにはルーターファンクション。読み取りにはビュー

```sql
-- 両方のスキーマを結合する統一ビュー
CREATE VIEW resources_unified
WITH (security_invoker = on)  -- RLSポリシーを尊重
AS
  SELECT id, organization_id, NULL AS entity_id, data, 'public' AS source
  FROM public.brand_guidelines
UNION ALL
  SELECT id, organization_id, entity_id, content AS data, 'tenant_b' AS source
  FROM tenant_b.entity_data
  WHERE data_type = 'brand_guidelines';
```

アプリケーションコード(統一読み取り):

```typescript
// リソースを読む(全テナントタイプで動作)
const { data } = await supabase
  .from('resources_unified')
  .select('*')
  .eq('organization_id', orgId);

// RLSポリシーがソーススキーマに関係なく正しくフィルタリング
```

重要な詳細WITH (security_invoker = on)がRLSポリシーの強制を保証します。これがないと、ビューはRLSをバイパスします(セキュリティ災害)。

---

マイグレーション:48時間で33マイグレーション

スキーマルーティングの追加は単一のマイグレーションではありませんでした。旅でした。

33のデータベースマイグレーションを連続で書きながら、1つでもタイプミスがあれば本番データが壊れることを知っている感覚を知っていますか?「楽しい」は正しい言葉ではありません。「恐怖」がより正確です。本番に触る前にステージングで各マイグレーションを3回実行しました。

2025年10月27-29日:完全なスキーマルーティングのための33のシーケンシャルマイグレーション。

マイグレーションフェーズ

1. 特化スキーマの作成 — 適切な権限でtenant_bスキーマをセットアップ

2. スキーマ固有テーブルの作成 — 新スキーマに必要なテーブルをミラー

3. ルーターファンクションの構築 — ルーティングが必要な各リソースタイプに1つ

4. セキュリティビューの作成 — 読み取り用のUNION ALL統一ビュー

5. RLSポリシーの更新 — 両方のスキーマが適切な分離を持つことを保証

6. データマイグレーション — 既存データを正しいスキーマに移動

7. アプリケーション更新 — 直接クエリからルーターファンクション/ビューに切り替え

総労力:33マイグレーション、Claude Codeで2日間、100%価値がありました。

---

結果:真のマルチテナント分離

Before(org_idを持つ共有テーブル)

データモデル

```sql
public.brand_guidelines (organization_id, entity_id, guidelines)
```

問題

- ❌ 一方のテナントタイプのためのnullable entity_id(データモデルの混乱)

- ❌ NULLハンドリングの複雑なクエリ

- ❌ アプリケーションロジック:if (tenantTypeA) { ... } else { ... }

- ❌ クロスコンタミネーションのリスク

After(スキーマルーティング)

データモデル

```sql
public.brand_guidelines (organization_id, guidelines)    -- テナントタイプA
tenant_b.entity_data (organization_id, entity_id, data)  -- テナントタイプB
```

メリット

- ✅ クリーンなデータモデル(nullableな外部キーなし)

- ✅ 複雑なNULLハンドリングなしのシンプルなクエリ

- ✅ アプリケーションのif/elseなし(データベースがルーティング)

- ✅ スキーマ間コンタミネーションが不可能(物理的に分離)

セキュリティ改善

Before:nullable カラムと共有テーブルによるクロスコンタミネーションのリスク

After:ルーターファンクションが組織タイプに基づいて自動的に正しいスキーマに書き込みをルーティング。物理的なスキーマ分離によりクロスコンタミネーションが不可能。

分離レベル:データベースレベルで強制された分離。アプリケーションレベルのチェックではなく。

---

スキーマルーティング vs org_idフィルタリングの使い分け

すべてのマルチテナントアプリにスキーマルーティングが必要なわけではありません。この決定ツリーを使ってください:

org_idフィルタリング(シンプル)を使う場合:

すべてのテナントが同じデータモデル(例:Todoアプリ)

階層的テナンシーなし(組織内のサブエンティティなし)

シンプルなクエリWHERE org_id = Xがどこでも動く)

B2Cまたは小規模B2B(エンタープライズセールスなし、コンプライアンス要件なし)

MVPのスピードが重要(数ヶ月ではなく数週間で市場に)

スキーマルーティング(より複雑)を使う場合:

異なるテナントタイプに異なるデータモデルが必要

階層的テナンシー(organizations → workspace_entities → sub-entities)

エンタープライズセールスがロードマップに

規制コンプライアンスが必要(GDPR、HIPAA、SOC 2、FedRAMP)

ホワイトラベルまたはリセラーの可能性

国際展開の計画(データレジデンシー要件)

---

教訓

1. org_idは必要だが十分ではない

すべてのテーブルにorg_idを追加すれば行レベルフィルタリングが得られます。しかし異なるテナントタイプに異なるデータモデルが必要なら、スキーマルーティングが必要です。

2. アプリケーションロジック → データベースロジック

アプリケーションのすべてのif (tenantType === 'TYPE_B')はコードの匂いです。テナント対応ロジックをルーターファンクションでデータベースに移しましょう。

3. ビュー + RLS = 統一読み取り

複数スキーマからの読み取りは複雑です。ビュー + security_invoker = onで適切な分離を持つ統一読み取りが得られます。

4. Security Definerファンクションは強力

SECURITY DEFINERにより、ファンクションがRLSポリシーを尊重しながら昇格権限で実行できます。ルーターファンクションに不可欠です。

5. マイグレーションは価値がある(たぶん)

スキーマルーティングのための33マイグレーションは多く感じました。でも結果は?クリーンなアーキテクチャ、真の分離、クロステナントバグゼロ。

6. アーキテクチャの欠陥はコードバグより痛い

午後11時47分にnullポインター例外を見つけた?イライラする。マルチテナントアーキテクチャ全体が根本的に壊れていると分かった?夜も眠れなくなる発見です。

でも学んだこと:アーキテクチャのミスは修正可能です。高くつく、はい。時間がかかる、もちろん。しかし「一方のテナントタイプが偶発的に他方のデータを汚染できる」から「バイパス不可能なデータベース強制分離」に移行しました。

早く修正し、正しく修正すれば、よく眠れます。

7. アーキテクチャは戦略かもしれない(あるいはただのオーバーエンジニアリングかも)

スキーマルーティングの決定は「きれいなコード」だけではありませんでした。将来のオプションを残しておくことでした — ホワイトラベルパートナーシップ、エンタープライズセールス、国際展開。

でも正直なところ:Private Alphaで15人のユーザーがいます。エンタープライズ顧客はノックしていません。GDPRの質問は1つも来ていません。想像しているパートナーシップは実現しないかもしれません。

技術的な決定をしていると思っていました。ビジネス戦略の決定をしていたのかもしれません。あるいはデータベースアーキテクチャが面白いからオーバーエンジニアリングしていただけかもしれません。 :)

アーキテクチャの欠陥を発見したことはありますか — バグではなく設計のミスだった — どう再構築に対処しましたか?段階的に修正しましたか、それとも私のように全部引き剥がしましたか?

よろしくお願いします、Chandler

STRAŦUMアーキテクチャシリーズ: これはマルチテナンシーの旅のパート2です。2日目にマルチテナンシーを構築した話から始まりました。スキーマ再構築後、ナビゲーションコンテキストの喪失による31の空白画面を発見し、データベースが正しいが296倍遅いことが判明しました。

---

複雑な分離が必要なマルチテナントSaaSを構築していますか? STRAŦUMはスキーマルーティングを使って異なるテナントタイプに真のデータ分離を提供しています。https://stratum.chandlernguyen.com/request-invitationでアルファアクセスをリクエスト

---

「マルチテナント」には多くの分離レベルがあることを学び続けています。深夜にまだRLSポリシーをデバッグしています。2日目のアーキテクチャ決定をまだ疑問視しています(でも以前ほどではなくなりました)。 https://www.chandlernguyen.com/ でもっとデータベースの冒険を。

---

続きを読む

私の歩み
つながる
言語
設定