この記事はもともとSubstackに公開した以下の記事を翻訳したものです。
Why RLS is NOT Optional for Every Supabase Project
よろしければSubstackの方にも登録してくださると嬉しいです!定期的に技術記事を公開しています。
Supabaseを使うときにRLSを有効化する必要がある?
はい、必要です。ほとんど全ての場合。そして単に有効化するだけでは不十分な場合がほとんどです。 もしあなたがつまらないと感じてすぐにこの記事を閉じてしまうとしても、これだけは伝えたいので一番最初に伝えさせていただきます。 なぜ、という部分については残りの部分を読んでいただければと思います。
Supabaseの台頭
最近とても多くの人がSupabaseを使っています。SupabaseはPostgreSQLをベースにしたサーバーレスのDBとして手軽に開発に組み込むことができ、さらにAuthやStorageなど便利な機能もあるため、個人開発やスタートスモールをする際にはかなり効果的な選択肢になってきます。 しかしその便利さと裏腹に、少し設定が間違えていたり不足していると、重大なインシデントにつながる可能性もあります。
たとえば有名なAIのLow-codeプラットフォームであるLovableでRLSの設定が不適切だったため重大なデータ漏洩が発生しました。
Supabaseを安全に使っていくために、RLSの仕組みと設定方法をについて学んでいきましょう。
RLSとは?
多くの方はご存知だと思いますが、RLSはSupabaseの機能ではなく、PostgreSQL 9.5で追加されたPostgreSQLの機能です。
RLSはRow Level Securityの頭文字を取ったものです。
Row Level とはどういう意味でしょうか?それはつまりデータベースの行単位でアクセス制御をすることが可能ということです。
あなたは以下のようなクエリを一度は書いたことがあるでしょう
SELECT * FROM users WHERE user_id = 100;
多くのWebアプリケーションでは複数のユーザーのデータは同じデータベースの同じテーブルに格納されるため、クエリ時にIDを指定することで特定のデータは特定のユーザーにしか見えないようにするのが普通です。
次の例をみてみましょう。
あるSaaSアプリケーションでテナントという概念があり、その中にユーザーがいます。 あるテナントに属する特定のユーザーの詳細情報を取得する関数を書きました。
func getUserData(db *sql.DB, tenantID, userID int) ([]UserData, error) {
query := `
SELECT id, tenant_id, user_id, data
FROM user_data
WHERE tenant_id = $1 AND user_id = $2
`
// Oops! IDs are inverted!
rows, err := db.Query(query, userID, tenantID)
if err != nil {
return nil, err
}
//...
}
もしIDが単なるAuto Incrementのintだとすると、このバグは簡単に別のテナントの別のユーザーのデータを取得できてしまいます。
これはシンプルな例ですが、実際の現場ではより複雑なクエリと戦う必要があり、こういったバグを完全に回避するのは難しいです。
こういった、どのユーザーに対してどのデータを見せて良いか、という制御をデータベースのレイヤーで実現できるようにしたのが、RLSです。
RLSはどうやってつかう?
今回はRLSの実際の使用方法として2つ紹介します。
runtime configuration parameter を使用する方法と、roleを使用する方法です
Runtime Configuration Parameter
まずは以下のようにテーブル単位で設定をします。
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
これでusersテーブルではRLSが有効化されました。 ただこれだけではまだ、テーブルには何も変わりはありません。
以下のようにポリシーを設定します。
CREATE POLICY users_select_policy
ON users
FOR SELECT
USING (user_id = current_setting('app.current_user_id'));
このポリシーは、users テーブルに対して、 current_user_id が一致するデータのみ SELECT 可能にする、という意味です。 これによって、特定のユーザーは自分が持つデータにしかアクセスできなくなります。
見慣れない current_setting('app.current_user_id')
という部分はなんでしょうか?
この current_setting というのはPostgreSQLのビルトイン関数です。引数に与えられた設定のキーを元にバリューを取得できます。
ではこの app.current_user_id
はどうやってセットするのでしょうか? これはトランザクションの中でセット可能です。
たとえば次のような感じ
BEGIN;
SET LOCAL app.hello = 'world';
SELECT current_setting('app.hello');
-- | current_setting |
-- | world |
COMMIT;
この app.hello
というパラメータはトランザクション内のみで有効になり、この値が先ほどのPolicyの current_setting('app.current_user_id')
という関数からの返り値になります。
このような仕組みで、トランザクションの最初にuser_idを設定することで、その中で実行されるクエリではすべて、RLSのPolicyを満たす行だけが取得できます。
BEGIN;
SET LOCAL app.current_user_id = 1;
SELECT * FROM users;
-- Only returns rows with user_id = 1
COMMIT;
この方法の欠点の一つは、全てのクエリでトランザクションを貼る必要があるということです。
技術的にはトランザクション内でパラメータを設定する必要はありませんが、そうしない場合、コネクションプールを使用していると、あるリクエストが古いリクエストによって設定されたパラメータ値を見てしまう可能性があります。
もちろん毎回コネクションを貼るという方法もありますが、オーバーヘッドが大きく、最大接続数の問題もあります。毎回のクエリの前にSET
を差し込むヘルパー関数を作る、なども実装のアイデアとしては考えられるかもしれませんが、それぞれのアプリにおける最適解は異なるでしょう。
Role-based RLS Policy
こちらは上記のruntime configuration parameterを使う場合よりも少しやることが増えます。
テーブルでRLSを有効化するステップは先ほどと同じです。
追加でRoleを作成します。これは実行時ではなく、マイグレーション時などに行います。
CREATE ROLE tenant_a;
そしてPolicyを追加します。今回の例では、特定のユーザーは同じテナント内のユーザーであればSELECT可能にします。
CREATE POLICY tenants_select_policy
ON users
FOR SELECT
USING (tenant_id = current_user);
current_user とはPostgreSQLにおける識別子の一つで現在のRole名を返します。ここでいうuserとはアプリのuserではなく、DBにおけるuserのことですね。これはSET ROLEというSQLで変更できます。
SELECT current_user;
-- postgress
SET ROLE tenant_a;
SELECT current_user;
-- tenant_a
トランザクション内ではSET LOCALでトランザクション内のみでオーバーライドすることが出来ます。
SELECT current_user;
-- postgress
BEGIN;
SET LOCAL ROLE tenant_a;
SELECT current_user;
-- tenant_a
COMMIT;
SELECT current_user;
-- postgress
こちらをアプリケーション側でセットアップする際は、たとえば以下のような方法が考えられます。
- コネクション開始時にSET ROLEをし、テナントごとにコネクションをプーリングする。アプリケーション側では、テナントIDを渡すとコネクションの実体を取得できるようなヘルパーを作成する。
- 上記のruntime configuration parameterを使う時と同じく、トランザクション内でSET LOCALでテナントを指定する。
RoleでのRLSポリシーでは、Roleを事前に作成しておく必要があります。そのため、ユーザー単位で細かく制御する用途には向かないでしょう。ユーザー登録のたびにDBにRoleを作成するのは現実的ではありません。しかし、テナントというもう少し大きい単位で、テナントを登録するためにリードタイムが発生しても問題ない、BtoB SaaS等であれば使用できるかと思います。
組み合わせる
上記2つの方法は組み合わせることが出来ます。
たとえば、adminは全てのデータにアクセスできるけれど、user はそのユーザーが持つレコードのみを参照・操作可能、などの設定は以下のように出来ます。 adminとuserというRoleがすでにあるとします。
-- Policy for "admin" role
CREATE POLICY admin_users_policy
ON users
TO admin
USING (true);
-- Policy for "user" role
CREATE POLICY user_users_policy
ON users
TO user
USING (user_id = current_setting('app.current_user_id'));
TO role
という書き方でそのRLSポリシーを適用するRoleを指定することで、Roleによって特定のPolicyを適用させる、ということが可能です。
アプリケーション側ではadmin用のコネクションプールとuser用のコネクションプールを分け、コネクション確立時にセットするRoleを分ける、などの実装になるでしょう。
RLS in Supabase
今まではPostgreSQLでのRLSの実装について、解説してきました。
この章ではSupabaseでのRLSの使用方法を追加で解説していきます。
SupabaseはPostgreSQLベースのデータベースですが、いくつか便利なRoleや関数をSupabaseが定義しており、もう少し便利にRLSを使用することが出来ます。
一つの大きな違いとして、標準のPostgreSQLではFORCEオプションを付けないでRLSを有効化した場合、ポリシーがないと全ての行が誰でもアクセス可能です。しかしSupabaseでは、RLSを有効化するとポリシーを明示的に作成するまでテーブルへの全てのアクセスがブロックされます。これはより安全なデフォルト動作です。
以下は実際にSupabaseのコンソールで作成したRLSポリシーです。
ALTER POLICY "Enable users to view their own data only"
ON "public"."users"
TO authenticated
USING (
((SELECT auth.uid() AS uid) = id)
);
ここで注目したいのは authenticated
と auth.uid()
です。
authenticated
というのはRole名で、認証済みユーザーのことです。その他に使えるRoleについては、公式ドキュメントを参照してください。
auth.uid()
というのはSupabaseが用意している関数で、ログイン中のユーザーのIDを返します。これによって、先ほどSET LOCAL
でやったような設定を入れずにユーザー単位でのアクセス制御が出来ます。
Do I need to enable RLS in Supabase? YES!!!
Supabaseの公式でも以下のように記載があります。
RLS must always be enabled on any tables stored in an exposed schema.
ではRLSを設定しない場合、どのようなシナリオになるのでしょうか?
If RLS is not enabled
多くのSupabaseアプリケーションでは supabase-js
を使って認証を行います。
たとえばこのような感です。
const supabase = createClient(supabaseUrl, supabaseAnonKey)
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'password',
})
ログインが成功すると Supabase は JWT を発行し、ブラウザに返します。 この JWT を使うと、クライアントから直接データベースにクエリを発行することができます。
ここで重要なのは、この JWT がデフォルトで authenticated
ロールとして動作するという点です。
もしテーブルに RLS が有効化されていなければどうなるでしょうか?
その場合、authenticated
ロールを持つユーザーはポリシーによる制限を受けずにテーブルへアクセスできてしまいます。
つまり「自分のデータだけを見るべきユーザー」が、同じテーブルに格納された他人のデータも取得できてしまうのです。
例えば、悪意あるアクターがアカウントを作成して有効なJWTを取得したとします。RLSが無効なテーブルがあれば、そのトークンを使って自由にクエリを発行でき、他のユーザーの情報を盗み出すことが可能になります。これは単なる理論上のリスクではなく、実際にセキュリティインシデントを引き起こしています。
さらに、Supabase には anon
キー(公開クライアント用キー)も存在します。
これを使って作成したクライアントは anon
ロールとして扱われ、やはり RLS が無効なテーブルに対してはアクセスできてしまいます。
でもPostgreSQLを使う場合って別にRLSは必須じゃないよね?
はい。この世にある全てのPostgreSQLを使ったプロジェクトがRLSを使っているわけではありません。実際、MySQLのようなデータベースにはこの機能すらありません。
しかし、SupabaseはPostgreSQLをベースにしていますが、単なる「PostgreSQLそのもの」ではありません。開発者がバックエンドレイヤーを介さずに直接利用できるようにPostgreSQLを公開するプラットフォームです。
その結果、Supabaseはデフォルトでパブリックフェイシングです。これにより、RLSのような機能はオプションではなく、パブリックAPI環境でデータを安全に保つために不可欠になります。
なぜかを理解するために、典型的なAWSセットアップを考えてみましょう。
CognitoとRDSを使う場合、Cognitoはマネージドサービスなのでパブリックですが、RDSインスタンスはほとんど常にプライベートサブネットに置き、内部サーバーからのみアクセス可能にします。
外部アクセスが必要なら、SSHトンネルなどを使ってデータベース自体をプライベートに保ちます。
Supabaseは異なります。認証とデータベースアクセスを同じパブリックエンドポイントの背後に組み合わせています。設計上、データベースをプライベートネットワークに「隠す」ことはできません。
それがSupabaseがRLSに依存する理由です。それなしでは深刻なセキュリティ問題が発生する可能性があります。
ソフトウェアの設計は常にトレードオフです。
Supabaseは迅速な開発とシンプルさを可能にしますが、本質的にパブリックフェイシングであるというコストがかかります。
このトレードオフを認識することが重要です:セキュリティファーストのマインドセットで設計しなければなりません。 したがって、アーキテクチャを計画する際、「CognitoをSupabase Authに、RDSをSupabase DBに置き換えるだけ」と考えるのは間違いです。代わりに、「Supabaseを使っている」と考え、そのユニークでパブリックフェイシングな性質に応じてシステムを設計する必要があります。
Conclusion
この記事では、RLSの仕組みと、Supabaseのパブリックフェイシングな設計によりなぜそれが必要かを見てきました。Supabaseは強力で便利ですが、デフォルトで公開されているため、RLSはオプションではなく必須です。常にRLSを有効化し、ポリシーを慎重に設定してデータを安全に保ってください。