この記事で得られること

  • マルチテナント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がデータ漏洩を防ぐ。

1
2
3
4
5
-- アプリケーションが誤って全件取得を実行しても
SELECT * FROM accounts;

-- RLSにより、現在のテナントのデータしか返らない
-- (他テナントのデータは見えない)

前提条件:

  • セッション変数(例: SET app.account_id = '...')でテナントコンテキストが設定されていること
  • 対象テーブルにRLSポリシーが定義されていること

例外:

  • スーパーユーザーや BYPASSRLS 権限を持つロールはRLSを迂回できる
  • DB管理用ロールは別途保護が必要

それでも、「アプリのバグで漏れない」 設計は、ソロ開発で特に心強い。

3. 4階層のRLSポリシー設計

3.1 RLSコンテキスト変数

PostgreSQLの SET LOCAL を使って、リクエストごとにコンテキストを設定する。

変数名説明
app.account_id現在のアカウントID550e8400-e29b-...
app.account_typeアカウント種別provider, consumer
app.bypass_rlsRLS迂回フラグ(Systemのみ)true, false

重要: SET LOCAL はトランザクション内でのみ有効。トランザクション終了時に自動でリセットされる。

3.2 accountsテーブルのポリシー

4階層それぞれに対応したポリシーを定義する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
-- RLSを有効化(FORCEで所有者も対象に)
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounts FORCE ROW LEVEL SECURITY;

-- 1. System Admin: 全てにアクセス可能
CREATE POLICY system_admin_all_accounts ON accounts
    FOR ALL
    USING (
        current_setting('app.bypass_rls', true) = 'true'
        OR current_setting('app.account_type', true) = 'system'
    );

-- 2. Provider: 自身 + 配下のReseller/Consumer
CREATE POLICY provider_accounts ON accounts
    FOR ALL
    USING (
        current_setting('app.account_type', true) = 'provider'
        AND (
            id = current_setting('app.account_id', true)::UUID
            OR provider_id = current_setting('app.account_id', true)::UUID
        )
    );

-- 3. Reseller: 自身 + 配下のConsumer
CREATE POLICY reseller_accounts ON accounts
    FOR ALL
    USING (
        current_setting('app.account_type', true) = 'reseller'
        AND (
            id = current_setting('app.account_id', true)::UUID
            OR reseller_id = current_setting('app.account_id', true)::UUID
        )
    );

-- 4. Consumer: 自身のみ
CREATE POLICY consumer_accounts ON accounts
    FOR ALL
    USING (
        current_setting('app.account_type', true) = 'consumer'
        AND id = current_setting('app.account_id', true)::UUID
    );

注意点:

  • 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 テーブルで使用している。

1
2
3
4
5
6
7
-- 自分のPATのみ作成可能
CREATE POLICY api_keys_user_insert ON api_keys
    FOR INSERT
    WITH CHECK (
        tenant_id = current_setting('app.account_id', true)::UUID
        AND user_id = current_setting('app.user_id', true)::UUID
    );

現状の課題: accounts テーブルには WITH CHECK を定義していない。 外部キー制約とアプリケーション層で制御しているが、今後追加を検討。

4. Go + pgx での実装

4.1 テナントコンテキスト構造体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// backend/internal/domain/tenant/context.go

type AccountType string

const (
    AccountTypeSystem   AccountType = "system"
    AccountTypeProvider AccountType = "provider"
    AccountTypeReseller AccountType = "reseller"
    AccountTypeConsumer AccountType = "consumer"
)

// Context holds tenant-specific information for the current request.
type Context struct {
    AccountID   uuid.UUID
    AccountType AccountType
    UserID      *uuid.UUID
    ProviderID  *uuid.UUID
    BypassRLS   bool
    Scopes      []string
}

4.2 RLSコンテキスト設定関数

トランザクション内で SET LOCAL を実行する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// backend/internal/infrastructure/postgres/tenant_context.go

// SetTenantContextTx sets the RLS context variables on a transaction.
func SetTenantContextTx(ctx context.Context, tx pgx.Tx, tc *tenant.Context) error {
    // Set account_id for RLS policies
    if _, err := tx.Exec(ctx,
        fmt.Sprintf("SET LOCAL app.account_id = '%s'", tc.AccountID.String()),
    ); err != nil {
        return fmt.Errorf("failed to set app.account_id: %w", err)
    }

    // Set account_type for RLS policies
    if _, err := tx.Exec(ctx,
        fmt.Sprintf("SET LOCAL app.account_type = '%s'", tc.AccountType),
    ); err != nil {
        return fmt.Errorf("failed to set app.account_type: %w", err)
    }

    // Set bypass_rls flag (only for system admin)
    bypassRLS := "false"
    if tc.BypassRLS {
        bypassRLS = "true"
    }
    if _, err := tx.Exec(ctx,
        fmt.Sprintf("SET LOCAL app.bypass_rls = '%s'", bypassRLS),
    ); err != nil {
        return fmt.Errorf("failed to set app.bypass_rls: %w", err)
    }

    return nil
}

今後の改善点: 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 コンテキストビルダー

アカウント種別ごとにコンテキストを生成する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// BuildSystemContext builds a tenant context for system admin operations.
func BuildSystemContext(userID *uuid.UUID) *tenant.Context {
    return &tenant.Context{
        AccountID:   uuid.Nil,
        AccountType: tenant.AccountTypeSystem,
        UserID:      userID,
        BypassRLS:   true,  // System Admin はRLSをバイパス
        Scopes:      []string{"*:*"},
    }
}

// BuildProviderContext builds a tenant context for provider operations.
func BuildProviderContext(accountID uuid.UUID, userID *uuid.UUID, scopes []string) *tenant.Context {
    return &tenant.Context{
        AccountID:   accountID,
        AccountType: tenant.AccountTypeProvider,
        UserID:      userID,
        ProviderID:  &accountID,
        BypassRLS:   false,
        Scopes:      scopes,
    }
}

5. テストでRLS漏れを検知する

5.1 統合テストの設計方針

RLSのテストは 「見えるべきものが見えて、見えないべきものが見えない」 を検証する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// backend/tests/integration/rls_isolation_test.go

// Provider A が Provider B のデータを見れないことを検証
func TestRLSIsolation_ProviderCannotAccessOtherProvider(t *testing.T) {
    pc := testutil.SetupPostgres(t)
    defer pc.TruncateTables(t)

    // Provider A を作成
    providerA := testutil.CreateProviderAccount(t, pc, "ProviderA")

    // Provider B を作成
    providerB := testutil.CreateProviderAccount(t, pc, "ProviderB")

    // Provider A として Provider B のアカウントを取得しようとする
    count := pc.CountWithTenant(t,
        providerA.Account.ID.String(), "provider",
        "SELECT COUNT(*) FROM accounts WHERE id = $1",
        providerB.Account.ID,
    )

    // RLS により見えない
    assert.Equal(t, 0, count,
        "Provider A は Provider B のアカウントを見ることができない")

    // 自身のアカウントは見える
    count = pc.CountWithTenant(t,
        providerA.Account.ID.String(), "provider",
        "SELECT COUNT(*) FROM accounts WHERE id = $1",
        providerA.Account.ID,
    )
    assert.Equal(t, 1, count,
        "Provider A は自身のアカウントを見ることができる")
}

5.2 テストヘルパー

CountWithTenant はRLSが有効なセッションでクエリを実行するヘルパー。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// CountWithTenant executes a count query with tenant context.
// RLS が正しく適用された状態でクエリを実行する。
func (pc *PostgresContainer) CountWithTenant(
    t *testing.T,
    accountID, accountType, query string,
    args ...interface{},
) int {
    ctx := context.Background()
    conn, err := pc.Pool.Acquire(ctx)
    require.NoError(t, err)
    defer conn.Release()

    // set_config でRLSコンテキストを設定
    _, err = conn.Exec(ctx, `
        SELECT set_config('app.account_id', $1, false),
               set_config('app.account_type', $2, false)
    `, accountID, accountType)
    require.NoError(t, err)

    var count int
    err = conn.QueryRow(ctx, query, args...).Scan(&count)
    require.NoError(t, err)

    return count
}

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が必要

1
2
3
4
5
-- これだけだとテーブル所有者はRLSをバイパスする
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;

-- 所有者もRLS対象にする
ALTER TABLE accounts FORCE ROW LEVEL SECURITY;

開発中に「あれ、全データ見えてしまう」と思ったら FORCE を確認。

例外: スーパーユーザーや BYPASSRLS 属性を持つロールは、FORCE でも RLS を迂回できる。

6.2 current_setting の第2引数

1
2
3
4
5
-- 第2引数 true: 変数が未設定でも NULL を返す(エラーにならない)
current_setting('app.account_id', true)

-- 第2引数なし: 変数が未設定だとエラー
current_setting('app.account_id')

RLSポリシーでは true を指定して、未設定時はフォールバック動作させる。 NULL との比較は NULL になり、RLSでは拒否扱いになる。

6.3 SET LOCAL はトランザクション内のみ

1
2
3
4
5
6
7
8
9
// ❌ トランザクション外では効かない
conn.Exec(ctx, "SET LOCAL app.account_id = '...'")
conn.Query(ctx, "SELECT * FROM accounts")

// ✅ トランザクション内で設定
tx, _ := conn.Begin(ctx)
tx.Exec(ctx, "SET LOCAL app.account_id = '...'")
tx.Query(ctx, "SELECT * FROM accounts")
tx.Commit(ctx)

SET LOCAL はトランザクション終了時に自動でリセットされる。

pgxの注意点: コネクションプールから借りた接続は BEGINCOMMIT で同一コネクション内に閉じる必要がある。

7. RLSのパフォーマンス

一般的な傾向

RLSのオーバーヘッドは、データ量・クエリ形態・ポリシーの複雑さによって大きく変わる。 外部の公開ベンチマークでは以下のような報告があるが、条件次第で結果は大きく異なる。

ソース条件報告値
Supabase単純なポリシー+インデックスあり数%〜15%程度
AntStackインデックスなし・大量データ10倍以上悪化

注意: 結合(JOIN)が多いクエリでは、単純なポリシーでもコストが増大することがある。

Saruでの対応

現時点ではデータ量が少なく、本格的なベンチマークは実施していない。 ただし、パフォーマンス劣化を防ぐため、設計段階で以下を意識した。

  • provider_id, owner_id 等のRLS対象カラムにインデックス
  • ポリシーは単純な等価比較のみ(サブクエリ・関数呼び出しなし)
  • 比較値を列の型(UUID)に合わせてキャスト(列側にキャストをかけない)

今後の計画

データ量の増加に伴い、以下を実施予定。

  1. EXPLAIN ANALYZE での定期確認

    • RLSポリシーがインデックスを使えているか
    • 結合クエリでプランが悪化していないか
  2. 代表的なクエリでの比較測定

    • 単純SELECT / JOIN多用 / 集計クエリ
    • テナントごとの行数偏りがある場合の影響
  3. 必要に応じた最適化

    • current_setting()(SELECT current_setting(...)) でラップしてinitPlan化
    • セキュリティバリアビューの検討

8. 今後の改善点

本記事で紹介した実装には、以下の改善余地がある。

項目現状改善案
accounts の WITH CHECK未定義INSERT/UPDATE 時の制約を追加
SET LOCAL の実装fmt.Sprintfset_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は強い選択肢だと思う。


シリーズ記事