9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MySQL で Row Level Security

Posted at

前置き

こんにちは、日本で一番 SQL が書ける俳優 @yebihara です。
ええ、俳優です。

最近、マルチテナント SaaS におけるセキュアなデータ管理というコンテキストで Row Level Security (RLS) が注目されてきているような印象を受けています。
自分はただの売れない俳優なので勘違いかもしれませんが。

直近で見かけたのはたしかこれ。

RLS が何なのかはこの記事では説明しないので、上記スライドなどを参照してください。

さて、RLS が利用できる RDBMS は限られています。
自分が知る限り、最初に RLS を実装したメジャーな RDBMS は Oracle で、2000年頃に Virtual Private Database (VPD) という機能名でリリースされました。もしかしたらそれ以前からあった Oracle Label Security というやつも RLS っぽいところがあったのかもしれないけど。

その後、いつかは知りませんが PostgreSQL も RLS を実装し、現在公開されている RLS の事例はほとんどが PostgreSQL のもののようです。

Web サービスでよく使われるもうひとつの RDBMS と言えば MySQL ですが、残念ながら RLS は備えていません。

が、いくつかの機能をうまく組み合わせると MySQL でも RLS 相当のことができます。
このアイディアは7、8年前に思いついて、当時勤めていた会社内では共有していましたが、今さらながら紹介します。今まで公開していなかったのは単に忙しかったからです。

本当は先月、さっきのスライドを見てすぐにこの記事を書こうかとも思いましたが、そうしなかったのは舞台の稽古で忙しかったからです。3日前にようやく千秋楽を迎えて時間ができました。

なお着想当時は MySQL 5.7 とかを使っていたと思いますが、今回 MySQL 8.3 で改めて検証しても特に違いはありませんでした。

テストスキーマ & テストデータ

人事データを管理するマルチテナント SaaS でも作っていると仮定して、こんな感じの従業員テーブルを作ってみましょう。

CREATE TABLE employees (
  id INT PRIMARY KEY,              -- ID (自動生成の連番)
  tenant_id INT NOT NULL,          -- テナントID
  employee_no VARCHAR(10),         -- 社員番号
  name VARCHAR(100) NOT NULL,      -- 氏名
  name_kana VARCHAR(100) NOT NULL, -- 氏名(カナ)
  date_of_birth DATE               -- 生年月日
);

そしてテストデータを用意する。

id tenant_id employee_no name name_kana date_of_birth
1 100 A001 弱竹 かぐや ナヨタケ カグヤ 1996-07-09
2 100 A002 石塚 陽 イシヅカ アキラ 1995-09-26
3 100 A003 倉持 康太 クラモチ コウタ 1997-03-07
4 100 A004 井上 智基 イノウエ トモキ 1998-05-13
5 100 A005 安部 修 アベ オサム 1997-01-06
6 100 A006 大野 一郎 オオノ イチロウ 1998-09-15
7 200 雨谷 美沙子 アメヤ ミサコ 2000-11-18
8 200 倉持 このみ クラモチ コノミ 2003-10-22
9 200 忠野 勇次 タダノ ユウジ 1973-11-18
10 200 門田 直樹 カドタ ナオキ 2006-10-12
11 200 天井 かぐすけ テンジョウ カグスケ 2000-10-03
12 200 宮迫 詩乃 ミヤサコ シノ 1990-09-09

このテーブルには2つのテナント企業(tenant_id が 100 と 200) についてそれぞれ6名ずつ、計12名の従業員情報が保存されています。

なお人名は僕が先月出演した舞台「バンブー・サマー2024」の登場人物から拝借しました。

試したい人のために INSERT 文も載せておきます。

INSERT INTO employees VALUES
    (1, 100, 'A001', '弱竹 かぐや', 'ナヨタケ カグヤ', '1996-07-09'),
    (2, 100, 'A002', '石塚 陽', 'イシヅカ アキラ', '1995-09-26'),
    (3, 100, 'A003', '倉持 康太', 'クラモチ コウタ', '1997-03-07'),
    (4, 100, 'A004', '井上 智基', 'イノウエ トモキ', '1998-05-13'),
    (5, 100, 'A005', '安部 修', 'アベ オサム', '1997-01-06'),
    (6, 100, 'A006', '大野 一郎', 'オオノ イチロウ', '1998-09-15'),
    (7, 200, NULL, '雨谷 美沙子', 'アメヤ ミサコ', '2000-11-18'),
    (8, 200, NULL, '倉持 このみ', 'クラモチ コノミ', '2003-10-22'),
    (9, 200, NULL, '忠野 勇次', 'タダノ ユウジ', '1973-11-18'),
    (10, 200, NULL, '門田 直樹', 'カドタ ナオキ', '2006-10-12'),
    (11, 200, NULL, '天井 かぐすけ', 'テンジョウ カグスケ', '2000-10-03'),
    (12, 200, NULL, '宮迫 詩乃', 'ミヤサコ シノ', '1990-09-09')
    ;

実現したいこと

さて、ここで Web アプリケーションの挙動を考えると、ログイン中のユーザーの所属テナントID が 100 なら最初の6名のみ、テナントIDが 200 なら次の6名のみしか検索できないようにしたいわけですね。

RLS がない場合、これはアプリケーションコードから実行する SELECT 文に WHERE tenant_id = 100 のような条件を付加するのが普通です。でもこれがすべてのクエリーで漏れなく行われることを保証するのが大変だし、万が一どこかで漏れると他社のデータが見えてしまう大事故につながるのが怖い、という話ですね。

ビューとユーザー定義変数の利用

Oracle の VPD の場合は、事前に RLS ポリシーを定義しておくと、SELECT 文の実行時にコンテキストに応じた適切な WHERE 条件を自動的に付加してくれるという機能でした(少なくとも昔は)。

似たようなことを MySQL でも実現するためにビューを利用してみることにしましょう。
次のようなビューを作成することを考えてみます。

CREATE OR REPLACE VIEW employees_rls AS
  SELECT * FROM employees WHERE tenant_id = @current_tenant_id;

ここで @current_tenant_id というのは MySQL のユーザー定義変数というやつです。
事前に SET @current_tenant_id = 100 のような SQL 文を実行しておくと、@current_tenant_id でその値を参照できるというものです。
なお変数名には任意のものを指定できます。

そしてこのビューに対してアプリケーションからこんな感じの SQL を実行することを想像してみてください。

SET @current_tenant_id = 100;
SELECT * FROM employees_rls;

tenant_id が 100 の従業員レコードのみが返ってきそうですね!

・・・が、残念ながらそううまくはいきません。
先ほどのビューを作成しようとした時点で、

View's SELECT contains a variable or parameter

というエラーが発生してしまいます。
どうやらビュー定義の SELECT 文からはユーザー定義変数を参照できないようです。

しかし諦めるのはまだ早い。

まずはユーザー定義変数の値を返すだけのファンクションを作成し、

CREATE FUNCTION current_tenant_id() RETURNS INT NO SQL RETURN @current_tenant_id;

ビュー定義ではユーザー定義変数の代わりにそのファンクションを参照する。

CREATE OR REPLACE VIEW employees_rls AS
  SELECT * FROM employees WHERE tenant_id = current_tenant_id();

なんとエラーが発生しない!

動作確認

では動作を確認してみましょう

まずはテナントID を 100 にセット。

mysql> SET @current_tenant_id = 100;
Query OK, 0 rows affected (0.00 sec)

そして SELECT 文を WHERE 句なしで実行。

mysql> select * from employees_rls;
+----+-----------+-------------+-----------+-------------+---------------+
| id | tenant_id | employee_no | name      | name_kana   | date_of_birth |
+----+-----------+-------------+-----------+-------------+---------------+
|  1 |       100 | A001        | 弱竹 かぐや | ナヨタケ カグヤ | 1996-07-09    |
|  2 |       100 | A002        | 石塚 陽    | イシヅカ アキラ | 1995-09-26    |
|  3 |       100 | A003        | 倉持 康太  | クラモチ コウタ  | 1997-03-07    |
|  4 |       100 | A004        | 井上 智基  | イノウエ トモキ  | 1998-05-13    |
|  5 |       100 | A005        | 安部 修    | アベ オサム    | 1997-01-06    |
|  6 |       100 | A006        | 大野 一郎  | オオノ イチロウ  | 1998-09-15    |
+----+-----------+-------------+------------------+------------------------+---------------+
6 rows in set (0.00 sec)

期待通り tenant_id = 100 の従業員レコードのみが検索されました。

次にテナントID を 200 にセット。

mysql> SET @current_tenant_id = 200;
Query OK, 0 rows affected (0.00 sec)

そして先ほどと全く同じ SELECT 文を実行。

mysql> select * from employees_rls;
+----+-----------+-------------+------------+----------------+---------------+
| id | tenant_id | employee_no | name       | name_kana      | date_of_birth |
+----+-----------+-------------+------------+----------------+---------------+
|  7 |       200 | NULL        | 雨谷 美沙子  | アメヤ ミサコ      | 2000-11-18    |
|  8 |       200 | NULL        | 倉持 このみ   | クラモチ コノミ    | 2003-10-22    |
|  9 |       200 | NULL        | 忠野 勇次    | タダノ ユウジ     | 1973-11-18    |
| 10 |       200 | NULL        | 門田 直樹    | カドタ ナオキ     | 2006-10-12    |
| 11 |       200 | NULL        | 天井 かぐすけ | テンジョウ カグスケ | 2000-10-03    |
| 12 |       200 | NULL        | 宮迫 詩乃    | ミヤサコ シノ     | 1990-09-09    |
+----+-----------+-------------+---------------------+------------------------------+---------------+
6 rows in set (0.01 sec)

今度は tenant_id = 200 の従業員レコードのみが検索された!

ちなみに事前に @current_tenant_id へのテナントIDの設定を忘れるとどうなるか?

mysql> select * from employees_rls;
Empty set (0.00 sec)

1件も返されません。安全でいいですね!

これって実用的なの?

ただの俳優がそんなことを知っているはずがありません。

ビュー定義で直接的にユーザー定義変数を参照できないのには何か理由があるのかもしれないし、ストアドファンクションを利用して実質的に同じことをすることで、予期せぬ不都合が発生する可能性がないとは言えないでしょう。

SET コマンドを実行する方法は利用するWebアプリケーションフレームワーク・DBアクセスライブラリーによって異なるだろうし、ストアドファンクションの利用がクエリーパフォーマンスにどの程度の影響を与えるのかも調べていません。

そういうのはエンジニアの皆さんのほうでどうにかしてくださいませ。

おまけ

舞台「バンブー・サマー2024」が、4月14日(日)までアーカイブ配信されるらしいです。

6月くらいには DVD も発売されるらしいです。

自分は「竹チーム」のほうにいます。どれが @yebihara かは想像にお任せします。
ちなみにこれが売れても自分には何も還元されないので、収益目的の宣伝ではありません。

9
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?