Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

RLS PERMISSIVEはOR結合、system_adminの全校policyが常に発火しテナント越境した

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PostgreSQLのRLSでPERMISSIVEポリシーはOR結合されるため、特権ロール用の全校アクセスpolicyが1つでも存在すると、別のpolicyが自校だけに絞っていても常に全校が可視になる。

結論

PostgreSQLのRLSでPERMISSIVEポリシーはOR結合される。特権ロール用の「全校アクセス可」ポリシーが1つでも存在すれば、他のポリシーが特定テナントだけに絞っていても、その絞り込みは発火条件としては意味を持たない。 特定校だけを対象にするはずの操作で、自校チェックが他校行をすり抜けた。

症状

このシステムには、学校単位のテナントを横断して全校を見る運営側ロール(system_admin)と、1校に閉じたテナント側ロール(school_admin)がいます。学校管理者ハブ(学科・学年・クラスのCRUD)は特定の学校だけを対象にする操作で、親参照(学年→学科など)を書き込む前に「その参照先が自校に実在するか」をRLS越しのSELECTで確認してから結線する、という防御が実装されていました。

この自校チェック自体はコードとして正しく書かれていました。ところが、actorがsystem_adminで実行されたとき、チェックが他校の行まで「自校に存在する」と誤判定し、他テナントの学科・学年に子をぶら下げたり、付け替えたりする操作が通ってしまう状態でした。school_adminで実行する分には問題が起きず、system_adminで実行したときだけ境界が消える、という形で現れていました。

原因

RLSのポリシーにはPERMISSIVEとRESTRICTIVEの2種類があり、デフォルトはPERMISSIVEです。同じテーブル・同じコマンドに対してPERMISSIVEポリシーが複数存在する場合、PostgreSQLはそれらをORで結合します。つまり、どれか1つのUSING句がtrueと評価されれば、その行は可視・操作可能になります。

このDBには2つのRLSポリシーが同じテーブルに乗っていました。

  • tenant_isolation: school_id = NULLIF(current_setting('app.current_school_id', true), '')::uuid。自校の行だけを通す、テナント境界そのものの定義です。
  • system_admin_full_access: current_setting('app.current_user_role', true) = 'system_admin'。運営側ロールが全校を見られるようにするための、学校一覧などの画面向けのポリシーです。

両方ともPERMISSIVEなのでOR結合されます。actorがsystem_adminとしてトランザクションを開くと、app.current_user_role = 'system_admin'が常にtrueになるためsystem_admin_full_accessが発火し、app.current_school_idの値に関係なく全校の行が可視になります。tenant_isolation側がどれだけ正しく自校に絞っていても、OR結合の相手が「無条件で通す」ポリシーである以上、絞り込みは成立しません。

学校管理者ハブの自校チェック(existsInSchool、実装はapps/web/lib/school-admin/hub-actions.ts)は、このRLS越しのSELECT結果を見て「他校か自校か」を判定していました。system_adminで実行されたトランザクションの中では、SELECTそのものが他校行を返してしまうため、チェックの実装は正しいまま、判定結果だけが間違ったものになっていました。

テストで実際に対比すると、差はこうなります(packages/db/__tests__/rls/tenant-scoped-context.test.ts、実PostgreSQL接続)。

// 降格なし (raw system_admin) は他校 department が可視 = 旧来の越権経路
const rows = await withTenantContext(
  db,
  { userId: fx.sysAdmin, role: "system_admin", schoolId: fx.schoolA },
  (tx) => tx.select().from(departments).where(eq(departments.id, deptB)),
  APP,
);
expect(rows).toHaveLength(1); // system_admin_full_access が全校発火

schoolAのsystem_adminとして実行したtxから、schoolBの学科(deptB)をidで直接SELECTすると、1件返ってきます。自校はschoolAのつもりでも、system_admin_full_accessが無条件で発火するため他校行が見えてしまう、という状態がそのまま再現されています。

直す

ポリシー側でsystem_admin用の条件にschool_idを足す案は採りませんでした。system_adminには全校を横断して見る必要がある経路(学校一覧画面など)が別に存在し、ポリシー側で一律に絞るとその経路が壊れるためです。必要なのは「操作の種類によって、同じsystem_adminでも見える範囲を変える」ことでした。

そこで、特定の学校を対象にするテナントスコープ操作のときだけ、トランザクション内でロールを一時的にschool_adminへ降格する純粋関数を追加しました。

export function tenantScopedContext(ctx: TenantContext): TenantContext {
  if (ctx.role === "system_admin" && ctx.schoolId != null && ctx.schoolId !== "") {
    return { ...ctx, role: "school_admin" };
  }
  return ctx;
}

schoolIdが無いsystem_adminは降格しません。降格するとtenant_isolationの述語(school_id = app.current_school_id)が成立する対象校が無くなり、全件不可視になってしまうためです。全校横断が必要な経路はこの変換を素通りさせることで、従来どおりの動作を保っています。

呼び出し側のwithSessionにtenantScopedオプションを追加し、学校管理者ハブの共通後処理finishからはtenantScoped: trueで呼ぶようにしました。

const data = await withSession(build, { tenantScoped: true });

降格を適用したあとの対比テストでは、同じSELECTが0件になります。

const rows = await withTenantContext(
  db,
  tenantScopedContext({ userId: fx.sysAdmin, role: "system_admin", schoolId: fx.schoolA }),
  (tx) => tx.select().from(departments).where(eq(departments.id, deptB)),
  APP,
);
expect(rows).toHaveLength(0); // tenant_isolation のみ残り他校は不可視

降格によってsystem_admin_full_accessの述語がfalseになり、tenant_isolationだけが残るため、他校行は不可視になります。UPDATEでも同様に、他校の学科名を書き換えようとする操作は0行に対して実行され、対象行は変更されないことを確認しています(テスト中では実際に「乗っ取り」という文字列を書き込もうとして、それが反映されないことを検証しています)。

監査ログへの影響はありません。降格で変えているのはapp.current_user_roleだけで、app.current_user_idはactor本人のuidのまま変わらないため、「誰が操作したか」を記録する側のポリシーはそのまま成立します。

再発しない形にする

この事故が教えているのは、「自校チェックをアプリ側で実装したから安全」ではなく、そのチェックが読んでいるSELECT自体がRLSという別の層で境界を持っている、ということです。アプリのロジックが正しくても、ロジックの入力になるデータの境界が崩れていれば、結果は間違います。

再発防止としてやったことは、ポリシーを個別に強くするのではなく、「このtxは誰の視点で実行されているか」を呼び出し側で明示する方式に変えたことです。tenantScopedはopt-inなので、既存の全校横断経路には影響しません。新しく特定校を対象にする操作を追加するときは、withSessionにtenantScoped: trueを渡すかどうかを都度判断する必要があり、ここは設計として呼び出し側の意識に依存したままです。

同種のリスクは、特権ロール用のPERMISSIVEポリシーを持つ他のテーブルにも共通してあり得ます。見るべきは個々のポリシーの正しさではなく、同じテーブル・同じコマンドに乗っているPERMISSIVEポリシーの組み合わせがOR結合された結果どうなるか、です。

よくある質問

Q1RLSのPERMISSIVEポリシーが複数あると、なぜ常に緩い方が勝つのですか?

PostgreSQLのRLSはデフォルトでPERMISSIVEで、同じテーブル・同じコマンドに対する複数のPERMISSIVEポリシーはOR結合されます。どれか1つでもUSING句がtrueになれば行は可視・操作可能になるため、全校を許す特権ロール用のポリシーが1つあるだけで、他のポリシーがどれだけ絞り込んでいても意味を持たなくなります。

Q2アプリ側で自校チェックをしているのに、なぜRLSの問題だと分かったのですか?

自校チェック(existsInSchool)自体はSELECTの結果を見て判定しています。そのSELECTがRLS越しに実行されるため、RLSが他校行を可視にしてしまえば、チェックの実装が正しくても判定結果そのものが間違ったものになります。アプリのロジックではなく、ロジックが読んでいるデータの境界が壊れていました。

Q3ロールを降格する以外に、ポリシー側だけで直す方法はなかったのですか?

system_admin用のポリシーにschool_idの条件を足す案もありましたが、system_adminには学校一覧など全校を横断して見る必要がある画面が別に存在します。ポリシー側で一律に絞ると、その経路まで壊れます。操作の種類(全校横断か、特定校を対象にするテナントスコープ操作か)によって必要な権限が変わるため、呼び出し側でロールを切り替える方式にしました。

Q4ロールを降格すると、監査ログの「誰が操作したか」が分からなくなりませんか?

分からなくなりません。降格で変えるのはapp.current_user_roleだけで、app.current_user_idはactor本人のuidのままです。監査ログのポリシーはactor_user_idがapp.current_user_idと一致することを見ているため、system_adminであることを記録したいわけではなく誰が操作したかを記録したいという目的には影響しません。

確認した環境

  • Next.js ^16.3.8 / Drizzle ORM ^0.45.2 / postgres ^3.4.5 / Cloud SQL for PostgreSQL 16
  • 2026-05-31 修正(ADR-019 §#95 / Issue #197 / PR #196・#220)

この記事の根拠

  • TypeScriptファイル 115〜149行目コミット f62c133
  • TypeScriptファイル 82〜114行目コミット f62c133
  • TypeScriptファイル 27〜48行目コミット f62c133
  • TypeScriptファイル 69〜95行目コミット f62c133
  • TypeScriptファイル 80〜119行目コミット f62c133

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。