マルチテナントアプリ向けPostgreSQL行レベルセキュリティのパターン
PostgreSQLの行レベルセキュリティ(RLS)について、テナント分離やロールルールの実践的パターンを解説します。アクセス制御をアプリだけでなくデータベース側で強制する方法を学べます。

なぜデータベース側でのアクセス強制が重要なのか
業務アプリでは「ユーザーは自社のデータしか見られない」「マネージャーだけが返金を承認できる」などのルールがよくあります。多くのチームはこれらをUIやAPI側で実装してそれで十分だと考えますが、問題はデータベースに到達する経路がひとつ増えるごとに情報漏えいのチャンスが増えることです。内部の管理ツール、バックグラウンドジョブ、分析クエリ、忘れられたエンドポイント、あるいはチェックをスキップしてしまうバグなどが該当します。
テナント分離とは、ある顧客(テナント)が別の顧客のデータを決して読み取ったり変更したりできないようにすることです。ロールベースのアクセスは、同じテナント内でもエージェント、マネージャー、経理などで権限が異なることを指します。これらのルールは説明は簡単ですが、アプリの複数箇所に散らばると一貫性を保つのは難しくなります。
PostgreSQLの行レベルセキュリティ(RLS)は、どの行が参照・変更可能かをデータベース自身が決定する機能です。アプリ側の各クエリが正しいWHERE句を覚えていることに頼る代わりに、データベースが自動的にポリシーを適用します。
RLSが全ての問題を解決するわけではありません。スキーマ設計を代わりに行ったり、認証を置き換えたり、すでに強力なデータベース権限を持つユーザー(スーパーユーザーなど)からの保護にはならない点に注意してください。また「ある行は更新できるが選択できない」といった論理的ミスを防ぐには、読み取りと書き込み両方のポリシーを書く必要があります。
ただし得られる利点は大きいです。
- データベースに到達するすべての経路に対して一元のルールを適用できる
- 新機能が追加されたときの「やっちゃった」リスクが減る
- SQL上でアクセスルールが見えるので監査が明確になる
- APIのバグがすり抜けた場合の防御力が向上する
初期設定には少し手間がかかります。誰がリクエストしているか、どのテナントかを一貫してデータベースに渡す仕組みが必要で、アプリが成長するにつれてポリシーのメンテナンスも必要です。特にSaaSや機密データを扱う内部ツールでは、その投資に見合うだけのリターンがあります。
ジャーゴン無しで分かる行レベルセキュリティの基本
行レベルセキュリティ(RLS)は、クエリがどの行を見たり変更したりできるかを自動的にフィルタリングします。各画面やAPIエンドポイント、レポートがルールを覚えていることに頼る代わりに、データベースがそれを適用します。
PostgreSQLのRLSでは、SELECT、INSERT、UPDATE、DELETEごとにチェックされるポリシーを書きます。例えば「このユーザーはテナントAの行しか見られない」とポリシーで定義しておけば、うっかりした管理ページや新しいクエリ、急ぎのホットフィックスでも同じガードレールが働きます。
RLSはGRANT/REVOKEとは別物です。GRANTはテーブル自体にアクセスできるか(あるいは列単位)を決めますが、RLSはそのテーブル内のどの行にアクセスできるかを決めます。現実的には両方を組み合わせます:GRANTでテーブルアクセスを制限し、RLSでアクセスできる行を制限する、という形です。
また実運用でも有効です。ビューは多くの場合RLSに従いますし、結合やサブクエリでもフィルタリングは効きます。どのクライアントから実行されたクエリでも(アプリコード、SQLコンソール、バッチ、レポートツールなど)ポリシーが適用されます。
RLSは複数のクエリ手段や多くのロールが同じテーブルを共有するケース(SaaSや内部ツールでよくある)に向いています。単一の信頼できるバックエンドしかない小さなアプリや、機密性が低く一つのサービスからしかアクセスされないデータには過剰な場合もあります。管理ツール、エクスポート、BI、スクリプトなど複数の入り口がある瞬間に、RLSの導入は価値を生みます。
まずはテナント、ロール、データ所有を整理する
ポリシーを書く前に、まず誰が何を所有しているかを明確にしてください。PostgreSQLのRLSは、データモデルが既にテナントやロール、所有を反映していると最も効果的に機能します。
まずテナントです。多くのSaaSアプリでは、顧客データを含む共用テーブルにはtenant_idカラムを設けるのが最も簡単なルールです。請求書のような明らかなテーブルだけでなく、添付ファイルやコメント、監査ログ、バックグラウンドジョブのように忘れがちなテーブルにも必要です。
次に実際に使うロール名を決めます。少数でわかりやすいものにします:owner、manager、agent、read-onlyなど。これらは後でポリシーのチェックに対応させる業務上のロールです(データベースロールとは別物です)。
その後、レコードの所有形態を決めます。あるテーブルは単一ユーザーが所有する(例えばプライベートなメモ)、別のテーブルはチーム所有(共有インボックス)という具合です。混在させるとポリシーが読みづらくなり、迂回されやすくなります。
ルールをドキュメント化する簡単な方法は、各テーブルについて次の質問に答えることです:
- テナント境界はどのカラムで強制するか(どのカラムが境界か)?
- 誰が読むことができるか(ロールと所有の観点で)?
- 誰が作成・更新できるか(どんな条件で)?
- 誰が削除できるか(通常最も厳しいルール)?
- どんな例外を許すか(サポートスタッフ、自動化、エクスポートなど)?
例:Invoicesはマネージャーはそのテナントのすべての請求書を閲覧でき、エージェントは担当顧客の請求書だけ、読み取り専用ユーザーは閲覧のみで編集不可、というように決めます。どのルールを厳密に守るべきか(テナント分離、削除)とどれを柔軟にするか(マネージャーの追加可視性)を先に決めておくと良いです。AppMasterのようなノーコードツールで構築する場合でも、このマッピングはUIの期待値とデータベースルールを一致させるのに役立ちます。
マルチテナントテーブルの設計パターン
マルチテナントRLSは、テーブルの形が予測可能であるほど管理しやすくなります。テーブルごとにテナントの保持方法がバラバラだと、ポリシーが難解になります。一貫した設計はRLSポリシーを読みやすく、テストしやすく、正しく保つのに役立ちます。
まずは一つのテナント識別子を選び、どこでも同じように使いましょう。UUIDは推測されにくく多くのシステムで生成しやすいため一般的です。内部向けなら整数でも構いません。スラッグ("acme"のような)は人間にとって分かりやすいですが変わることがあるため表示用にし、コアキーにはしない方が無難です。
テナントスコープのデータには、該当するすべてのテーブルにtenant_idカラムを追加し、可能ならNOT NULLにします。テナント無しで行が存在し得る状態は設計のにおいで、グローバルデータとテナントデータが混在している可能性が高く、RLSポリシーを複雑にします。
インデックスはシンプルですが重要です。SaaSアプリの多くのクエリはまずテナントでフィルタし、その後ステータスや日付などで絞ります。デフォルトではtenant_idにインデックスを貼り、高トラフィックなテーブルは(tenant_id, created_at)や(tenant_id, status)のような複合インデックスを検討してください。
どのテーブルがグローバルでどれがテナントスコープなのかを早めに決めてください。一般的なグローバルテーブルには国コード、通貨コード、プラン定義などがあります。テナントスコープのテーブルには顧客、請求書、チケットなど、テナントが所有するものが含まれます。
保守しやすいルールを作るには、縛りを狭く保つのがコツです:
- テナントスコープのテーブル:
tenant_id NOT NULL、RLS有効、ポリシーは常にtenant_idをチェックする。 - グローバル参照テーブル:
tenant_idなし、テナントポリシー無し、ほとんどのロールは読み取りのみ。 - 共有だが制御が必要なテーブル:概念ごとにテーブルを分ける(グローバルとテナント行を混ぜない)。
AppMasterのようなツールで作る場合、この一貫性はデータモデル側でも有利に働きます。tenant_idが標準フィールドになれば、同じパターンをモジュール間で繰り返し使えます。
ステップバイステップ:最初のテナントポリシーを作る
PostgreSQLのRLSでの最初の勝利は、あるテーブルを現在のテナント内でのみ読み取り可能にすることです。目的は単純です:APIでWHERE句を忘れても、データベースが他テナントの行を返さないようにすること。
まずtenant_idカラムがあるテーブルから始めます。
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
RLSを有効にすると、よく驚かれるのはデフォルトの振る舞いです:あるロールがRLSの対象で、かつマッチするポリシーがない場合、SELECTはゼロ行を返し(書き込みは失敗します)。これが最初の段階では望ましい挙動です。
次に最小限の読み取りポリシーを追加します。この例ではアプリがログイン後にapp.tenant_idのようなセッション変数を設定する想定です:
CREATE POLICY invoices_tenant_read
ON invoices
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::uuid);
次に書き込みルールを追加します。RLSではUSINGが既存行にアクセスできるかを、WITH CHECKが挿入・更新時に許可される新しい値かを制御します。
CREATE POLICY invoices_tenant_insert
ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_update
ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_delete
ON invoices
FOR DELETE
USING (tenant_id = current_setting('app.tenant_id')::uuid);
ポリシーはデフォルトでPERMISSIVE(いずれかのポリシーで許可されればよい)です。複数の条件すべてを満たす必要がある場合はRESTRICTIVEを選びます(例えば「アカウントが有効であること」などの二重のガードを入れる時に有用です)。
ポリシーは小さく、ロールごとに分けておくと読みやすくなります。ORを多用した巨大なルールより、invoices_tenant_read_app_userやinvoices_tenant_read_support_agentのように対象ごとに分けた方が、テストやレビュー、将来的な変更が楽で安全です。
テナントとユーザーのコンテキストを安全に渡す方法
RLSを機能させるには、データベースが「誰が呼んでいるか」と「どのテナントか」を知る必要があります。RLSポリシーはクエリ時にデータベースが読める値としか比較できないため、そのコンテキストをセッションに渡す仕組みが必要です。
一般的なパターンは認証後にセッション変数を設定し、ポリシー側でcurrent_setting()で読む方法です。アプリはトークンを検証して(例えばJWTの署名と有効期限を確認)、必要なフィールド(tenant_id、user_id、role)だけをデータベース接続に書き込みます。
-- Run once per request (or per transaction)
SELECT set_config('app.tenant_id', '3f2a0c3e-9c7b-4d3f-9c5c-3c5e9c5d1a11', true);
SELECT set_config('app.user_id', '8d9c6b1a-6b6d-4e32-9c0d-2bfe6f6c1111', true);
SELECT set_config('app.role', 'support_agent', true);
-- In a policy
-- tenant_id column is a UUID
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
第三引数にtrueを渡すとその設定が現在のトランザクションローカルになります。接続プーリングを使う場合、プールされた接続が別のリクエストに再利用されるため、前のリクエストのコンテキストが残らないようにするために重要です。
JWTクレームからコンテキストを設定する場合
APIがJWTを使っている場合、クレームはそのまま信頼せず入力として扱ってください。まずトークンの署名と有効期限を検証し、その後必要なフィールドだけ(tenant_id、user_id、role)をセッション設定にコピーします。クライアントがこれらの値を直接ヘッダやクエリパラメータで送れるようにするのは避けてください。
コンテキストがない・無効な場合はデフォルト拒否
設定が欠けているときは行を返さないようにポリシーを設計してください。
current_setting('app.tenant_id', true)を使えば、設定がないとNULLが返ります。適切な型にキャスト(例えば::uuid)して無効なフォーマットは早めに失敗させ、テナント/ユーザーコンテキストが設定できない場合は推測するのではなくリクエストを失敗させる方が安全です。
これにより、UIをバイパスしたクエリや新しいエンドポイントが追加されたときでもアクセス制御の一貫性が保たれます。
保守しやすいロールパターン
RLSポリシーを読みやすく保つ最も簡単な方法は、アイデンティティと権限を分離することです。一般的な基盤はusersテーブルと、ユーザーをテナントとロール(複数も可)に紐づけるmembershipsテーブルです。こうするとポリシーは「現在のユーザーがこの行に対して適切なメンバーシップを持っているか?」という問いに集中できます。
ロール名は職種よりも実際の行動に結びつけておくと長持ちします。invoice_viewerやinvoice_approverのように、ポリシーが明確な動作で書ける名前が好ましいです。
保守しやすいロールパターンの例:
- Owner-only: 行に
created_by_user_id(またはowner_user_id)があり、その一致をチェックする。 - Team-only: 行に
team_idがあり、ポリシーで同じテナント内でそのチームのメンバーであることを確認する。 - Approved-only:
status = 'approved'のときだけ読み取りを許可し、書き込みは承認者に限定する。 - Mixed rules: 最初は厳格にして、後から小さな例外(例: "サポートは閲覧可能")を追加する。
クロステナントの管理者(cross-tenant admins)は多くのチームがつまずくポイントです。これを暗黙の“スーパーユーザー”で扱うのではなく明示的に扱ってください。platform_adminのような別概念を作り、ポリシーで慎重にチェックする。可能ならクロステナントのアクセスはデフォルトで読み取りのみとし、書き込みにはさらに高いハードルを設けるのが良いです。
ドキュメントも重要です。各ポリシーの上に短いコメントで「意図」を書いてください。SQLの説明ではなく「承認者はステータスを変更できる。閲覧者は承認済みの請求書のみ見ることができる。」といった一文があるだけで、数ヶ月後の編集が安全になります。
AppMasterのようなノーコードツールでもこれらのパターンは有効です。UIとAPIは早く変わっても、メンバーシップと明確なロール定義に基づくデータベースルールは安定します。
例:請求書とサポートを持つシンプルなSaaS
小さなSaaSを想像してください。複数の会社(テナント)にサービスを提供し、請求書とサポートチケットを扱います。ユーザーはエージェント、マネージャー、サポートなどのロールです。
データモデル(簡略化):請求書とチケットの各行にtenant_idがあり、チケットにはassignee_user_idもあります。アプリはログイン直後に現在のテナントとユーザーをデータベースセッションにセットします。
RLSを導入すると日常的なリスクはこう変わります。
テナントAのユーザーがテナントBの請求書IDを推測してアクセスしようとしても(あるいはUIが誤って送信しても)、クエリは実行されますがポリシーによりゼロ行が返ります。ポリシーはinvoice.tenant_id = current_tenant_idを要求するため、アクセス拒否の漏れは発生しません。
テナント内ではロールがさらにアクセスを絞ります。マネージャーはテナント内の全請求書とチケットを見られます。エージェントは自分に割り当てられたチケットや自分の下書きのみ見られる、といった具合です。フィルタが任意のAPIでは特にここでミスが出やすいです。
サポートは特殊ケースです。顧客対応のために請求書を閲覧する必要はありますが、amountやbank_account、tax_idのような機密フィールドは変更できてはなりません。実用的なパターンとしては:
- サポートロールには請求書の
SELECTを許可する(ただしテナントスコープ内)。 UPDATEは「安全な」経路だけで許可する(編集可能な列だけを露出するビューを使うか、保護されたフィールドへの変更を拒否する厳格な更新ポリシーを作る)。
リファクタリングでテナントフィルタを適用し忘れるとどうなるか。RLSがないとクロステナントの請求書が漏れてしまう恐れがありますが、RLSがあればデータベースが他テナントの行を返さないため、バグは画面が壊れる程度で済み、データ漏えいにはなりません。
AppMasterでこうしたSaaSを構築する場合でも、データベース側のルールは必要です。UIのチェックは有益ですが、何かが抜けたときに効くのはデータベースのルールです。
よくあるミスと回避方法
RLSは強力ですが、小さなミスで「安全」に見えて実は「驚き」を招くことがあります。問題は新しいテーブルの追加、ロール変更、あるいは誤ったDBユーザーでのテストなどで表れます。
よくある失敗は新しいテーブルでRLSを有効にし忘れることです。コアテーブルのポリシーは丁寧に作っていても、後から追加したnotesやattachmentsを全開放で出荷してしまうことがあります。習慣として「新しいテーブル=RLS有効+最低1つのポリシー」を徹底してください。
別の罠はアクションごとにポリシーが揃っていないことです。INSERTを許可しているのにSELECTをブロックしていると、作成直後にデータが「消えた」ように見えます。逆に読めるが作れない、という不整合も問題です。「作る→見る」「更新→再オープン」「削除→一覧」などのフローで考えてポリシーを作りましょう。
SECURITY DEFINER関数には注意が必要です。関数は所有者の権限で実行されるため、適切に扱わないとRLSを回避してしまうことがあります。使う場合は小さく保ち、入力を検証し、動的SQLは本当に必要な場合だけにしてください。
またアプリ側のフィルタリングに頼り、データベースのアクセスを開けたままにするのは避けてください。APIは成長して新しいエンドポイントやジョブが増えます。データベースロールが全て読める状態だと、いずれ何かが漏れます。
問題を早期に発見するための実務的なチェックリスト:
- 本番アプリが使うのと同じDBロールでテストを行う(個人の管理者ユーザーではない)。
- 各テーブルに対して否定テストを1つ入れる:別テナントのユーザーはゼロ行しか見えないことを確認する。
- 期待するアクション(
SELECT、INSERT、UPDATE、DELETE)が各テーブルでサポートされているか確認する。 SECURITY DEFINERの使用を見直し、なぜ必要かをドキュメント化する。- マイグレーションとコードレビューのチェックリストに「RLS有効?」を含める。
例:サポートエージェントがインボイスのメモを作成したが読み返せない場合、多くはINSERTポリシーはあるが対応するSELECTポリシーがない、あるいはそのセッションでテナントコンテキストが設定されていないことが原因です。
RLS設定を検証するための簡単チェックリスト
RLSはレビュー上は正しく見えても、実際の利用で失敗することがあります。検証はポリシーを読むことよりも、現実的なアカウントとクエリで壊せるか試すことが重要です。アプリが使う方法そのままでテストしてください。
まず少数のテストIDを用意します。少なくとも2つのテナント(Tenant A, Tenant B)を用意し、それぞれに通常ユーザーと管理者/マネージャーロールを作ります。サポートや読み取り専用ロールがあるならそれも追加します。
その後、次のようなチェックを繰り返し行います:
- 各ロールで基本操作を実行する:一覧取得、単一行取得(id指定)、挿入、更新、削除。各操作で許可されるケースと拒否されるケースの両方を試す。
- テナント境界を証明する:Tenant AからTenant Bのデータを読み書きしようとしてゼロ行が返るか、あるいは権限エラーになるか確認する。
- 結合による漏洩をテストする:保護されたテーブルを他のテーブル(参照テーブルを含む)と結合して、別テナントの関連行が引き込まれないか確認する。
- コンテキストがない・間違っている場合は拒否されるか:リクエストごとに設定するコンテキストをクリアして試す。"コンテキスト無し"はクローズドに失敗すべきです。無効なテナントIDも試す。
- 基本的なパフォーマンスを確認する:クエリプランを見て、インデックスがテナントフィルタパターン(通常は
tenant_id+検索・ソート項目)をサポートしているかを確認する。
テストで驚きがあればポリシーまたはコンテキスト設定を直してください。UIやAPIでパッチを当ててデータベースルールを"なんとか維持"しようとするのは避けてください。
次のステップ:安全に導入して一貫性を保つ
PostgreSQLのRLSは安全システムのように扱ってください:慎重に導入し、頻繁に検証し、チームが従えるほどシンプルなルールに保つこと。
小さく始めましょう。漏れが致命的なテーブル(決済、請求書、個人情報、顧客メッセージなど)からRLSを有効にし、そこで勝利体験を積む方が、全面展開して誰も理解していない状態より良いです。
実用的な導入順の例:
- コアな“所有される”テーブル(行が明確にテナントに属する)
- 個人情報を含むテーブル(PII)
- テナントでフィルタされる共有テーブル(レポート、分析)
- 結合テーブルやエッジケース(多対多関係)
- 基本が安定したらその他すべて
テストは必須にしてください。自動テストは異なるテナントやロールで同じクエリを実行して結果を確認するべきです。"許可される"と"拒否される"の両方のチェックを含めてください。最も高コストなのは静かに過剰許可され続けるバグです。
リクエストフロー内でセッションコンテキストを設定する一箇所を明確にしてください。tenant id、user id、roleは一度、早い段階で設定し、後で推測しないこと。トランザクションの途中でコンテキストを設定すると、古い値や欠けた値でクエリが走ることになります。
AppMasterで構築する場合は、生成されるバックエンドAPIとPostgreSQLポリシーの間の一貫性を計画してください。すべてのエンドポイントで同じセッション変数を使うなど、コンテキストの渡し方を標準化するとポリシーがどこでも同じように振る舞います。もしAppMasterを使っていてappmaster.ioという表記が出る場合でも、RLSはテナント分離の最終的な根拠として扱ってください。
最後に、失敗を監視してください。認可の失敗は有用なシグナルです。導入直後は拒否ログを追跡し、それが本当の攻撃なのかクライアント側の不具合なのか、あるいはポリシーが厳しすぎるのかを調査しましょう。
RLSを健康に保つための短い習慣リスト:
- デフォルト拒否の考え方を持ち、例外は意図的に追加する
- 明確なポリシー名(テーブル + アクション + 対象)
- ポリシー変更はコード変更と同様にレビューする
- 導入初期は拒否ログを記録・確認する
- RLSを有効にした新しいテーブルごとに小さなテストセットを追加する


