この記事で得られること
- マルチテナントSaaSにおけるデータ分離パターンの比較
- PostgreSQL Row-Level Security(RLS)の実践的な使い方
- 4階層(System/Provider/Reseller/Consumer)のRLSポリシー設計
- Go + pgx でRLSコンテキストを設定する方法
- テストでRLS漏れを検知する手法
はじめに
第1回で紹介した通り、Saruは4階層のアカウント構造を持つマルチテナントSaaSだ。
System Admin(SMSプラットフォーム全体を管理)
└── Provider(サービスを提供)
├── Reseller(サービスを販売)
│ └── Consumer(購入・管理)
└── Consumer(直販)
この構造では、データの分離が極めて重要になる。
- Provider A の顧客データを Provider B が見てはいけない
- Reseller A の販売実績を Reseller B が見てはいけない
- Consumer A のサブスクリプション情報を Consumer B が見てはいけない
これをアプリケーション層で完全に防ぐのは難しい。WHERE句の書き忘れや権限チェックの漏れは、ソロ開発では特に起こりやすい。
そこで PostgreSQL Row-Level Security(RLS) を採用した。
1. マルチテナントの分離パターン比較
マルチテナントのデータ分離には主に3つのアプローチがある。
パターン比較表
| パターン | 分離レベル | 実装コスト | 運用コスト | スケーラビリティ |
|---|---|---|---|---|
| Database per Tenant | 最高(物理分離) | 高い | 高い | 性能分離◎、運用面△ |
| Schema per Tenant | 高い(論理分離) | 中程度 | 中程度(自動化必須) | 中程度 |
| Shared Schema + RLS | 高い(設計次第) | 低い | 低い | 高い(設計次第) |
Database per Tenant
テナントごとに独立したデータベースを持つ。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Provider A │ │ Provider B │ │ Provider C │
│ Database │ │ Database │ │ Database │
└─────────────┘ └─────────────┘ └─────────────┘
メリット: 完全分離、テナント単位のバックアップ・リストアが容易
デメリット: テナント数に応じてDBインスタンスが増加。ソロ開発では管理しきれない。
Schema per Tenant
1つのデータベース内でテナントごとにスキーマを分ける。
┌────────────────────────────────────┐
│ Single Database │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ A. │ │ B. │ │ C. │ │
│ │schema│ │schema│ │schema│ │
│ └──────┘ └──────┘ └──────┘ │
└────────────────────────────────────┘
メリット: 論理的分離、テナント単位の操作が可能
デメリット: テナント追加時にスキーマ作成・マイグレーションの自動化が必須。運用が複雑化しやすい。
Shared Schema + RLS(Saruの選択)
全テナントが同一スキーマを共有し、RLSでアクセス制御。
┌────────────────────────────────────┐
│ Single Schema │
│ ┌──────────────────────────────┐ │
│ │ accounts, products, │ │
│ │ subscriptions, ... │ │
│ │ + RLS Policies │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
メリット: シンプルな運用、マイグレーションが容易、テナント追加は行挿入のみ
デメリット: RLSポリシーの設計・テストが重要。テナント単位の復旧は難しい。
2. なぜRLSを選んだか
ソロ開発者の現実
| 課題 | RLSによる解決 |
|---|---|
| WHERE句の書き忘れ | DBレベルで自動フィルタリング |
| 権限チェックの漏れ | ポリシー違反はデータが見えない |
| 新規開発者の参入 | ポリシーが定義されていれば自動的に分離 |
| テナント追加 | 新規行を挿入するだけ |
最大のメリット:防御の最終ライン
アプリケーションコードにバグがあっても、RLSがデータ漏洩を防ぐ。
| |
前提条件:
- セッション変数(例:
SET app.account_id = '...')でテナントコンテキストが設定されていること - 対象テーブルにRLSポリシーが定義されていること
例外:
- スーパーユーザーや
BYPASSRLS権限を持つロールはRLSを迂回できる - DB管理用ロールは別途保護が必要
それでも、「アプリのバグで漏れない」 設計は、ソロ開発で特に心強い。
3. 4階層のRLSポリシー設計
3.1 RLSコンテキスト変数
PostgreSQLの SET LOCAL を使って、リクエストごとにコンテキストを設定する。
| 変数名 | 説明 | 例 |
|---|---|---|
app.account_id | 現在のアカウントID | 550e8400-e29b-... |
app.account_type | アカウント種別 | provider, consumer |
app.bypass_rls | RLS迂回フラグ(Systemのみ) | true, false |
重要: SET LOCAL はトランザクション内でのみ有効。トランザクション終了時に自動でリセットされる。
3.2 accountsテーブルのポリシー
4階層それぞれに対応したポリシーを定義する。
| |
注意点:
current_setting(..., true)は未設定時にNULLを返す(エラーにならない)NULLとの比較はNULLになり、RLSでは拒否扱いになるFORCE ROW LEVEL SECURITYでもBYPASSRLS権限を持つロールは迂回可能
3.3 ポリシーの動作イメージ
Provider A でログイン(app.account_type = 'provider', app.account_id = A)
├── Provider A のデータ: ✓ 見える
├── Reseller A1 のデータ: ✓ 見える(provider_id = A)
├── Consumer A1 のデータ: ✓ 見える(provider_id = A)
├── Provider B のデータ: ✗ 見えない
└── Consumer B1 のデータ: ✗ 見えない
3.4 WITH CHECKを使ったINSERT/UPDATE制御
USING は読み取り時のフィルタだが、書き込み時には WITH CHECK も必要。
Saruでは api_keys テーブルで使用している。
| |
現状の課題:
accountsテーブルにはWITH CHECKを定義していない。 外部キー制約とアプリケーション層で制御しているが、今後追加を検討。
4. Go + pgx での実装
4.1 テナントコンテキスト構造体
| |
4.2 RLSコンテキスト設定関数
トランザクション内で SET LOCAL を実行する。
| |
今後の改善点:
fmt.Sprintfでの文字列組み立ては、値に特殊文字が含まれる場合にリスクがある。 テストコードではset_config($1, $2, true)形式を使用しており、本番コードも同様に改善予定。
1 2 3 4 5// より安全な実装 _, err := tx.Exec(ctx, "SELECT set_config('app.account_id', $1, true)", tc.AccountID.String(), )
4.3 コンテキストビルダー
アカウント種別ごとにコンテキストを生成する。
| |
5. テストでRLS漏れを検知する
5.1 統合テストの設計方針
RLSのテストは 「見えるべきものが見えて、見えないべきものが見えない」 を検証する。
| |
5.2 テストヘルパー
CountWithTenant はRLSが有効なセッションでクエリを実行するヘルパー。
| |
5.3 テストで検証すべきケース
| テストケース | 検証内容 |
|---|---|
| 同一階層の分離 | Provider A は Provider B を見れない |
| 親子関係 | Provider は配下の Reseller/Consumer を見れる |
| 階層横断 | Reseller A は Reseller B 配下の Consumer を見れない |
| System Admin | 全データにアクセス可能 |
今後の追加: JOINを含むクエリでの漏れテスト、集計クエリでの検証
6. RLS設計で学んだこと
6.1 FORCE ROW LEVEL SECURITYが必要
| |
開発中に「あれ、全データ見えてしまう」と思ったら FORCE を確認。
例外: スーパーユーザーや BYPASSRLS 属性を持つロールは、FORCE でも RLS を迂回できる。
6.2 current_setting の第2引数
| |
RLSポリシーでは true を指定して、未設定時はフォールバック動作させる。
NULL との比較は NULL になり、RLSでは拒否扱いになる。
6.3 SET LOCAL はトランザクション内のみ
| |
SET LOCAL はトランザクション終了時に自動でリセットされる。
pgxの注意点: コネクションプールから借りた接続は BEGIN〜COMMIT で同一コネクション内に閉じる必要がある。
7. RLSのパフォーマンス
一般的な傾向
RLSのオーバーヘッドは、データ量・クエリ形態・ポリシーの複雑さによって大きく変わる。 外部の公開ベンチマークでは以下のような報告があるが、条件次第で結果は大きく異なる。
| ソース | 条件 | 報告値 |
|---|---|---|
| Supabase | 単純なポリシー+インデックスあり | 数%〜15%程度 |
| AntStack | インデックスなし・大量データ | 10倍以上悪化 |
注意: 結合(JOIN)が多いクエリでは、単純なポリシーでもコストが増大することがある。
Saruでの対応
現時点ではデータ量が少なく、本格的なベンチマークは実施していない。 ただし、パフォーマンス劣化を防ぐため、設計段階で以下を意識した。
provider_id,owner_id等のRLS対象カラムにインデックス- ポリシーは単純な等価比較のみ(サブクエリ・関数呼び出しなし)
- 比較値を列の型(UUID)に合わせてキャスト(列側にキャストをかけない)
今後の計画
データ量の増加に伴い、以下を実施予定。
EXPLAIN ANALYZE での定期確認
- RLSポリシーがインデックスを使えているか
- 結合クエリでプランが悪化していないか
代表的なクエリでの比較測定
- 単純SELECT / JOIN多用 / 集計クエリ
- テナントごとの行数偏りがある場合の影響
必要に応じた最適化
current_setting()を(SELECT current_setting(...))でラップしてinitPlan化- セキュリティバリアビューの検討
8. 今後の改善点
本記事で紹介した実装には、以下の改善余地がある。
| 項目 | 現状 | 改善案 |
|---|---|---|
| accounts の WITH CHECK | 未定義 | INSERT/UPDATE 時の制約を追加 |
| SET LOCAL の実装 | fmt.Sprintf | set_config($1, $2, true) 形式に変更 |
| bypass_rls の保護 | アプリ層で制御 | DBロール権限で SET を制限 |
| JOINテスト | 未実装 | 関連テーブル結合時の漏れテスト追加 |
ソロ開発では、完璧を目指すより動くものを作って改善するアプローチを取っている。 RLSという防御層があることで、安心して段階的に改善できる。
まとめ
| 項目 | 実装 |
|---|---|
| 分離方式 | Shared Schema + RLS |
| コンテキスト設定 | SET LOCAL app.* via pgx |
| ポリシー設計 | 階層ごとに個別ポリシー |
| バイパス | app.bypass_rls = 'true'(System Adminのみ) |
| テスト | 統合テストで「見える/見えない」を検証 |
RLSは「最終防衛ライン」として非常に心強い。アプリケーションにバグがあっても、DBレベルでデータ漏洩を防げる。
ソロ開発で複雑なマルチテナントを扱うなら、RLSは強い選択肢だと思う。
シリーズ記事
- 第1回: 1人では保守できない複雑さに自動化で挑む
- 第2回: WebAuthn認証をCIで自動テスト
- 第3回: Next.js × Go モノレポ構成
- 第4回: PostgreSQL RLSでマルチテナント分離(本記事)