メインコンテンツへ

マルチテナンシーと RLS

Astar のマルチテナント分離は PostgreSQL の Row Level Security (RLS) で実現しています。アプリケーションコードの WHERE tenant_id = ... の書き忘れに依存せず、DB 側で強制するのが設計の核です。

2層セキュリティモデル

すべてのテーブルに RLS がかかっているわけではありません。ビジネスデータ認証メタデータで保護方式を分けています。

分類対象テーブル(例)保護方式
ビジネスデータworkspaces / tables / records / document_nodes / tags / templates などFORCE ROW LEVEL SECURITY(DB層で強制)
認証メタデータusers / tenants / tenant_users / roles / user_roles / role_permissionsRLS 無効。アプリ層(Repository)でフィルタリング

認証メタデータに RLS をかけない理由は循環依存です。JWT が届いた時点ではまだテナントコンテキストが確立していないのに、ユーザー認証にはまさにそのテナント情報(tenant_users 等)を読む必要があります。RLS は app.current_tenant_id の設定を前提とするため、「認証してからでないと RLS が効かないテーブルを、RLS が効く前提で読む」という矛盾が生まれます。これは業界標準の対処で、認証に必要な最小限のテーブルだけ RLS の外に置き、代わりにリポジトリ層でテナント/ユーザーフィルタを書きます。

除外テーブル一覧のSSoTはコード側 architecture/RlsCoverageExemptions.ktAUTH_METADATA / INFRASTRUCTURE / GLOBAL_NO_TENANT / TENANT_SERVICE_LAYER_ENFORCED の4分類)です。新しいテーブルを RLS 対象外にできるかどうかは、必ずこのコードを確認してください——ドキュメント側の一覧は変わり得るスナップショットに過ぎません。

docs/infrastructure/multitenancy.md は現行方式と矛盾する古い3層プラン案です。参照しないでください。RLS が現行唯一の方式です。

GUC チェーン

RLS ポリシーは PostgreSQL のセッション変数(GUC)に依存します。このチェーンを誰が・いつ設定するかが RLS の心臓部です。

TenantContextFilter(リクエスト受信時)
    ↓
TenantContextService(ThreadLocal に保持)
    ↓
RLSInterceptor(@Transactional への AOP アドバイス。トランザクションごとに1回だけ dedup)
    ↓
set_config(PostgreSQL セッションへ実際に反映)

ポリシー本体は必ず public.current_tenant_id() ヘルパー関数経由で書きます。

-- OK
CREATE POLICY tenant_isolation ON public.foo
    USING (tenant_id = public.current_tenant_id());

-- NG: current_setting() のインライン展開は禁止
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)

current_setting() を直接インラインで書いたポリシーは、過去に何度も修正対象になっています。必ず public.current_tenant_id() を使ってください。

新しいテーブルを作るときの必須パターン

実際のマイグレーション(V406__create_local_calendar_events.sql)から、必須要素を抜粋します。

CREATE TABLE public.local_calendar_events (
    id                       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id                UUID NOT NULL DEFAULT gen_random_uuid(),
    tenant_id                BIGINT NOT NULL,
    -- ドメインカラム ...
    CONSTRAINT local_calendar_events_public_id_key UNIQUE (public_id),
    CONSTRAINT local_calendar_events_tenant_id_id_unique UNIQUE (tenant_id, id),
    CONSTRAINT local_calendar_events_tenant_id_fkey FOREIGN KEY (tenant_id)
        REFERENCES public.tenants(id) ON DELETE CASCADE
);

ALTER TABLE public.local_calendar_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.local_calendar_events FORCE ROW LEVEL SECURITY;

CREATE POLICY local_calendar_events_select ON public.local_calendar_events
    FOR SELECT USING (tenant_id = public.current_tenant_id() AND user_id = public.current_user_id());

CREATE POLICY local_calendar_events_insert ON public.local_calendar_events
    FOR INSERT WITH CHECK (tenant_id = public.current_tenant_id() AND user_id = public.current_user_id());

CREATE POLICY local_calendar_events_update ON public.local_calendar_events
    FOR UPDATE
    USING     (tenant_id = public.current_tenant_id() AND user_id = public.current_user_id())
    WITH CHECK (tenant_id = public.current_tenant_id() AND user_id = public.current_user_id());

CREATE POLICY local_calendar_events_delete ON public.local_calendar_events
    FOR DELETE USING (tenant_id = public.current_tenant_id() AND user_id = public.current_user_id());

このテーブルは所有者スコープ(tenant_id に加えて user_id も絞る)の例です。テナント全体で共有するテーブルなら user_id 条件は不要ですが、4ポリシー全部(SELECT/INSERT/UPDATE/DELETE)と、UPDATE の WITH CHECK(付け忘れると別テナントへの書き換えが通ってしまう)は必須です。マイグレーションの詳しい規約は バックエンドガイド/マイグレーション を参照してください。

テナント指定の3層規約

エンドポイントがどこからテナントIDを取るかは、3パターンしかありません(4つ目を発明することは禁止されています)。

種別指定方法理由
テナント内エンドポイントX-Tenant-Id ヘッダーTenantContextFilter が RLS コンテキストを設定
プラットフォーム管理者のクロステナント操作/tenants/{tenantId}/ パス変数運用者は顧客テナントに所属しないためヘッダーを張れない
(禁止)クエリパラメータでのテナント指定どちらの規約でもない逸脱

例外は SSE の EventSource(ヘッダーを送れないブラウザ API の制約)で、ticket + tenantId をクエリパラメータで渡しますが、コントローラは解決済みの principal からテナントを導出し、生のクエリパラメータをそのまま信用することはありません。

権限チェック時になぜ 403 と 404 を区別しないのか

RLS の下では「他テナントのリソース」と「存在しないリソース」は構造的に見分けがつきません(クエリした瞬間に RLS が弾くため)。以前はこの2つを区別するために RLS をバイパスする SECURITY DEFINER 関数(get_resource_tenant_id)がありましたが、この関数は削除済みです。

現行設計はこの問題自体を解こうとしません。意図的に区別しないのが仕様です。

  • GET(読み取り)で権限が無い / リソースが存在しない → どちらも 404
  • 非GET(書き込み)で権限が無い → 403@PreAuthorize 自体の判定失敗のときのみ)
  • リソースの存在確認をする層(ResourceTenantResolver)は通常の RLS-aware クエリしか使わない — クロステナントリソースは常に「存在しない」扱い

これは情報漏洩の防止(「そのリソースは存在するが権限が無い」と教えてしまうと、リソースIDの存在自体が漏れる)を兼ねた設計判断です。詳しい実装(getResourceTenantIdresolveResourceTenantForAuth の使い分けなど)は 認可モデル を参照してください。

コルーチン境界をまたぐ RLS 伝播

Kotlin コルーチンは中断点の後で別スレッドに resume することがあり、ThreadLocal ベースの RLS コンテキストは素朴には生き延びません。この構造的ギャップは shared/infrastructure/security/rls/ の3ファイル(RlsDispatchers.kt / RlsContextElement.kt / RlsTransactions.kt)で塞いでいます。読み取り専用のバッチ処理を並列化するときは coroutineScope { items.map { asyncIO { ... } }.awaitAll() } の形を使い、withContext(Dispatchers.IO) を直接呼ばないでください(withIO が唯一許可された呼び出し口で、それ以外はアーキテクチャテストで CI がブロックします)。詳細は backend/CLAUDE.md の RLS 節を参照してください。

本ページの設計思想の元ネタは docs/architecture/rls-security-architecture.md ですが、個別の migration 番号やクラス名は squash 前の記述が混在しており stale です。実装の現行 SSoT は常に backend/CLAUDE.mdRlsCoverageExemptions.kt です。