🚀

集合論で考える RDB と RLS

に公開

あけましておめでとうございます。
年末年始はUnityの勉強をしていたK@zuki.です。
今回は、以前LTでやった内容を改めてブログ用に書き直した資料になります。

RDBを使っていると、私たちは息をするように「フィルタリング」を行います。
マルチテナントなら WHERE tenant_id = ?。論理削除なら WHERE deleted_at IS NULLですね。
これを毎回書くのは面倒ですし、書き忘れたら事故になります。
そこで、多くのフレームワークやORMには「自動的に条件を付与する仕組み(Global Scope)」が存在します。

しかし、この機能は、しばしば 「アンチパターン」 として嫌われます。

一方で、PostgreSQLなどが持つ Row-Level Security (RLS)「自動的に条件を付与する仕組み」として紹介されがち ですが、こちらは推奨される堅牢な設計とされます。

「アプリでやる自動フィルタ」と「DBでやるRLS」。
やっていることは似ているのに、なぜ評価がこれほど違うのでしょうか?

この記事では、RDBの基礎である集合論(Set Theory) の視点から、その決定的な違いと、RLSが守る 「SQLの意味論(Semantics)」 について考えてみましょう。

RDB は集合論である

そもそも、1970年にE.F.Coddが提唱したリレーショナルモデルにおいて、データベースは単なるデータの置き場所ではありません。
数学的な基盤の上に成り立つシステムです。

  • テーブル = 集合 (Set)
    テーブルとは「タプル(行)の集合」であり、順序を持ちません。
  • SQL = 関係代数 (Relational Algebra)
    私たちが書く SQL は、集合に対する演算を行うための宣言的な記述です。

SQLの各句は、数学的な演算子への糖衣構文(Syntactic Sugar) として捉えることもできます。

SQL 関係代数 記号 意味
WHERE Selection (選択) \sigma 条件に合う「行」を取り出す
SELECT Projection (射影) \pi 必要な「属性(列)」を取り出す
JOIN Join (結合) \Join 集合同士からペアを作る

例えば、「IDが1より大きいドキュメントのタイトルが欲しい」というクエリは、手続きではなく 「どの部分集合か」の定義として解釈することができます。

\pi_{title} ( \sigma_{id > 1} ( documents ) )

この「集合論の世界」を前提にすると、RLSの正体が見えてきます。

RLSは定義域の再定義

RLS (Row-Level Security) とは、ポリシー関数に基づいて、すべてのクエリに対し、透過的に選択演算(\sigma)を適用する仕組みです。

これを数式で表すと、以下のようになります。

T' = \sigma_{policy}(T)
  • T: DBが管理する物理的な全集合
  • T': ユーザーに提示される論理的な部分集合

つまり、RLSを有効にするということは、クエリを書き換えるというよりも、定義域(Domain)そのものを「私に見える部分集合 T'」に再定義することと同義です。

-- RLS有効化後の世界
SELECT * FROM documents;

このクエリを投げたとき、ユーザーは「フィルタされた結果」を見ているのではありません。「最初から T' しか存在しない世界」 を見ているのです。
対して、アプリケーション側での制御(手動・自動に限らず)は、 T 全体に対して WHERE でフィルタをかける形になります。

-- WHEREで絞り込む場合
SELECT * FROM documents WHERE tenant_id = X;

RLSと同じdocumentsを参照はしていますが、全体集合から絞り込むのか、最初から絞り込まれている部分集合に対してクエリをかけるのかが異なります。

なぜ「アプリ側のスコープ」は嫌われるのか

ここで冒頭の疑問に戻ります。
アプリ側で自動で追加するスコープの仕組みも、自動的に WHERE を足してくれる点では似ています。
なぜWHEREでの追加が嫌われるのでしょうか?

それは、数学的なアプローチ(意味論)が根本的に異なるからです。

論理積による合成 (Syntactic Filtering)

アプリ側での制御は、開発者が「本来やりたいクエリ条件(Q)」に、「権限フィルタ(F)」を論理積(AND) で合成するアプローチです。

Result = \sigma_{Q \land F}(T)

SQLで書くとこうなります。

-- Q (id > 5) AND F (tenant_id = 1)
SELECT * FROM documents WHERE id > 5 AND tenant_id = 1;

このアプローチは 「構文(Syntax)」レベルの介入に過ぎません。
もし開発者がうっかり F を忘れてしまった場合、クエリは単なる \sigma_{Q}(T) となり、他テナントのデータを含んだ結果が返ります(情報漏洩)。

安全性は 「常に F を合成し続ける」 という手続き上の努力に依存しており、unscoped などで簡単に破綻します。

定義域の縮小 (Semantic Domain Definition)

一方、RLSのアプローチは 「定義域(Domain)の縮小」 です。

まず、ポリシー関数によって論理集合 T' が定義されます。

T' = \sigma_{F}(T)

アプリケーションが発行するクエリ Q は、この T' に対して実行されます。

Result = \sigma_{Q}(T')
-- フィルタ F は書かなくていい
SELECT * FROM documents WHERE id > 5;

たとえ開発者が F を忘れても、アクセス対象がすでに T' に縮小されているため、他テナントのデータは物理的に存在しないかのように振る舞います。

端的にまとめると

  • アプリ制御 - 全集合 T から、毎回手作業で安全な部分を切り出している
  • RLS - 最初から安全な部分集合 T' しか渡されていない

といった違いがあると思ってもらえれば良いです。

自己防衛的な集合

この違いが決定的な差を生むのが、テーブル結合(JOIN) の場面です。

アプリ制御は事後的なフィルタ

アプリ側で制御する場合、論理的には「結合した結果」か「結合時」、「個別」に対してフィルタをかける形になりがちです。

Result = \sigma_{F}(A \Join B)

もしくは

Result = \sigma_{F}(A) \Join \sigma_{F}(B)

これでは、結合の過程で本来見えてはいけないデータが計算対象になったり、LEFT JOIN の条件次第ではフィルタがすり抜けたりするリスクが、RLSよりも高いです。

RLSは自己防衛的な集合 (Self-Defensive Sets)

RLS環境下では、結合演算(\Join)が行われるに、各テーブル(集合)に対して独立して \sigma が実行されます。

A' \Join B' \Join C'
Result = (\sigma_{F} A) \Join (\sigma_{F} B) \Join (\sigma_{F} C)

各テーブルが「自分が見せてよいデータ」だけに縮小してから結合に参加します。
これをこの資料では 「自己防衛的な集合」 と呼びます。

フィルタ済み部分集合同士の結合となるため、無駄な中間生成を防げるだけでなく、どのような結合クエリを書いても「見えてはいけないデータ」が混入する余地が数学的にありません

まとめ

RLSを導入する最大のメリットは、セキュリティの向上だけではありません。
「SQLを宣言的な記述に戻せること」 です。
さて冒頭のdocumentsのクエリを覚えていますか?

  • Before 「idが以上で、テナントIDがXで…」という ノイズ(フィルタ条件) を毎回記述
    \pi_{title}(\sigma_{id > 1 \land tenant\_id = 1}(documents))
  • After 「欲しいデータ」の条件だけを記述
    \pi_{title}(\sigma_{id > 1}(documents'))

クエリを書き換えるのではなく、「正しく定義された集合 documents'」に対してクエリを実行する
これにより、アプリケーションコードからノイズが消え、開発者は純粋なビジネスロジック(集合演算)に集中できるようになります。

「RDBもRLSも、同じ集合論の世界にいる」

だからこそ、両者は親和性が高く、堅牢なアーキテクチャを生み出せるのです。

Discussion