← 戻る 薄暗い木製机の上で光るヴィンテージターミナルモニター、散らばった書類と柔らかいくもり日の光

Supabase RLS: 私が毎回書くポリシー

2021年に遡ると、あるSaaSクライアントが立ち上げから6週間後に私の元へ来ました。彼らのプロダクトはプロジェクト管理ツールでした。きれいなUI、しっかりしたオンボーディング、まともなリテンション。その時、ベータユーザーの1人が気付きました。GETリクエストのproject_idを調整することで、他のユーザーのデータを読めるということを。認証バイパスではなく、SQLインジェクションでもなく、ただポリシーが足りていなかったのです。テーブルはRLSが有効でしたが、SELECTポリシーは完全に開かれていて、USING (true)でした。誰かがチュートリアルから無思慮にコピペして、そのままにしていたのです。

その事件は私の心に残りました。それ以来、私はRLSをアーキテクチャとして扱い、後から考えるものではないと考えています。Seahawkで12,000以上のサイトとアプリを構築した後、フロントエンドコードを1行書く前にほぼ反射的に書くポリシーが少数ありますす。

これがそのリストです。

RLSが手間に見合う理由

SupabaseはPostgreSQLで動作しているため、Row Level Securityはボルトオン機能ではなく、第一級のデータベース機能です。テーブルでRLSを有効にしてユーザーがSupabaseクライア経由でクエリを実行すると、すべての行が自動的にポリシーを通してフィルタリングされます。APIレイヤーが何をしようが、しまいが関係ありません。

これが最後の部分のポイントです。REST、GraphQL、エッジファンクション、バックグラウンドジョブなど、すべて同じデータベースにヒットするチームと仕事をしてきました。アプリケーションレイヤーでこれらすべてにアクセス制御を強制するのは調整の悪夢です。データベースレイヤーで強制するのは、ただそれだけです。

正直に言うと、ポリシーを書く手間は、ジュニア開発者がWHEREキーセットを追加し忘れた最初の時点で元が取れます。

1つのことを誤解している人がいますが、RLSを有効にしてポリシーがないというのは「開かれたアクセス」という意味ではありません。通常のロールにとって全くアクセスできないという意味です。すべての行はデフォルトで拒否されます。RLSを有効にしてアプリが即座に壊れたなら、そういうわけです。

最初にRLS保護する4つのテーブル

すべてのものが同じレベルの精査が必要なわけではありません。でもこれら4つのテーブルタイプは、他の何かに触れる前にロックダウンされます。

  • ユーザープロフィールテーブル(profiles、users、accounts): 明白なものです。ユーザーは自分の行だけを読めるべきです。管理者は全部読めるかもしれません。誰も他の誰かのプロフィールを書くべきではありません。
  • リソース/コンテンツテーブル(projects、documents、posts): アプリの中核的な「モノ」がここに行きます。所有権は通常、ここでは直感的です。
  • 請求と購読テーブル: Stripeとプランデータ、購読ステータス、または請求履歴をSupabaseに保存している場合は、これをしっかりロックします。試用期限を誤って他のユーザーに晒してしまうアプリを見たことがあります。
  • 監査ログ: これらはユーザーにとって読み取り専用です(あるいは全く読めないかもしれません)。サービスロールだけが書き込めるべきです。

私が実際に書くポリシー

1. オーナーのみポリシー(私が最も使うパターン)

これが私が何よりも書く1つです。シンプルな前提: ユーザーは自分が所有する行だけを見られます。

`` create policy "Users can view own rows" on profiles for select using (auth.uid() = user_id); ``

auth.uid()はSupabaseヘルパーで、現在認証されているユーザーのUUIDを返します。シンプルで、高速で、user_idがインデックスされていれば索引化されます。これをinsertポリシーと組み合わせて、user_idをデフォルトでauth.uid()に設定するので、ユーザーは他の誰かになりすまして行を挿入できません。

`` create policy "Users can insert own rows" on profiles for insert with check (auth.uid() = user_id); ``

withcheck句は書き込み操作用です。usingは読み取り用です。これらを混同する人が多く、正しく見えるけれど実際には不正な挿入を防げないポリシーになってしまいます。

2. Org/Teamポリシー(マルチテナントアプリ)

ここから興味深くなります。マルチテナントSaaSでは、ユーザーが自分だけでなく、自分の組織に属する行を見られる必要があります。

私が採用しているパターンは、ユーザーと組織をリンクするメンバーシップジョインテーブルです。

`` create policy "Org members can view org resources" on projects for select using ( exists ( select 1 from memberships where memberships.org_id = projects.org_id and memberships.user_id = auth.uid() ) ); ``

Seahawkは数十人のユーザーを持つ組織がある金融技術プロジェクトに取り組みました。読み取り専用ロールもあれば、書き込みアクセス権を持つユーザーもいます。このパターンをメンバーシップにrole列を追加して拡張し、ポリシーで直接使用しました。viewer ロールはデータベースレベルでUPDATEもDELETEも実行できません。APIで強制されるのではなく、データベースで強制されます。

3. 公開読み取り/所有者書き込みポリシー

公開表示だが所有者のみ編集可能なコンテンツ用です。ブログ投稿、公開プロフィール、商品リストなど。

``` create policy "Anyone can read published posts" on posts for select using (published = true);

create policy "Authors can update own posts" on posts for update using (auth.uid() = author_id) with check (auth.uid() = author_id); ```

2つの別々のポリシーです。これらを1つに統合しようとする人を見かけますが、ロジックが複雑になります。分けたままにしてください。同じ操作で必要な場合、Postgresが自動的にそれらをORで結合します。

4. サービスロールエスケープハッチ

正当にRLSをバイパスする必要がある操作があります。バックグラウンドジョブ、ウェブフック、管理スクリプトです。これらの場合、RLSを完全にバイパスするservice_roleキーを使用します。

ただし、フロントエンドコードではservice_roleキーを絶対に公開しません。サーバー側の環境変数にのみ存在します。Next.jsのpages/ディレクトリにハードコードされていたコードベースをレビューしたことがあります。それはデータベース全体が完全に開かれているのと同じです。

Supabaseエッジ関数を使用している場合、エッジ関数内でservice_roleクライアントを安全に使用できます。エッジ関数はサーバー側で実行されるためです。Supabaseの認証とサービスロールに関する公式ドキュメントは、まだ読んでいなければ最初から最後まで読む価値があります。

5. 管理者オーバーライドポリシー

管理パネルを持つアプリの場合、ユーザーのJWTメタデータまたは別のuser_rolesテーブルに保存されたロールを確認し、管理者にフルアクセス権を付与するポリシーを追加します。

`` create policy "Admins can do everything" on projects for all using ( exists ( select 1 from user_roles where user_roles.user_id = auth.uid() and user_roles.role = 'admin' ) ); ``

以前はロールをJWTカスタムクレームに保存していました。これは高速です(サブクエリなし)が、ロールが変更されるたびにJWTを再発行する必要があります。ほとんどのアプリでは、サブクエリで問題ありません。スケール時にパフォーマンスの問題が見られる場合、Supabase認証フックを介したJWTカスタムクレームが対策です。

私が犯した(そして見た)よくある間違い

実際に私または私のクライアントに影響を与えたことについて、直接述べます。

  1. UPDATEポリシーとDELETEポリシーを忘れること。SELECTポリシーを書いて完了した気になるのは簡単です。違います。4つの操作すべてをテストしてください。SELECT、INSERT、UPDATE、DELETE。今ではSupabaseダッシュボードの組み込みポリシーテスターを使用していますが、何年も前はpsqlで生のSQLを書いて手動でテストしていました。
  2. USINGとWITH CHECKの混同。USINGはクエリが見ることができる行をフィルタリングします。WITH CHECKは書き込み操作が許可されるかどうかを検証します。UPDATEの場合は両方が必要です。対象にできる行を制御するUSING、更新後の行がどのようなものであるかを制御するWITH CHECK。
  3. 再帰的ポリシーループ。テーブルAのポリシーがテーブルBをクエリし、テーブルBのポリシーがテーブルAをクエリする場合、無限再帰が発生します。teamsとteam_membersテーブルが相互参照していた場合に発生しました。修正方法は、セキュリティで保護された関数を使用してサイクルを壊すことです。
  4. 匿名ユーザーとしてテストしないこと。Supabaseではanon ロール(anon)を使用できます。常に認証されたユーザーとanonの両方としてポリシーをテストしてください。トークンなし、有効なユーザートークン、別のユーザーのトークンを切り替えるために、Postmanで異なる認証トークンを使用してシミュレートしています。
  5. ストレージバケットのRLSを無効のままにしておく。RLSはstorage.objectsテーブルにも適用されます。Supabase Storageバケットを作成してそのテーブルを保護しないままにすると、パスを推測できればだれでもあなたの「プライベート」ファイルを読むことができます。ユーザーアップロードドキュメントを保存していたクライアントプロジェクトで、私はこれを痛い目で学びました。

ポリシーをリリース前にテストする方法

これは理論的なチェックリストではなく、私の実際のプロセスです。

  1. Supabase SQLエディタでポリシーを記述します。
  2. 別のブラウザタブを開き、別のテストユーザーとしてサインインします。
  3. ブロックされるはずのデータにアクセスしてみます。ブロックされていることを確認します。
  4. 表示されるはずのデータにアクセスしてみます。機能することを確認します。
  5. 自分が所有していない行に対してUPDATEとDELETEを実行します。失敗するはずです。
  6. Supabaseログでrow-level security policy violatedエラーをチェックします。

特に複雑な場合、特にマルチテナント組織ポリシーについては、supabase-jsクライアントを使って2つの異なるユーザーセッションで小さなテストスクリプトを書き、期待される結果をアサートします。記述に20分かかりますが、本番環境でのデバッグに何時間も節約できます。

RLSを使わない方がいい場合

RLSが常に正しいツールとは限りません。

すべてのユーザーが信頼できる従業員の内部管理ツールを構築している場合、RLSは複雑さを加える一方でメリットがあまりありません。シンプルなサーバー側の認証チェックで十分です。データモデルが非常に複雑で、ポリシーが5段階のサブクエリを必要とする場合は、適切なサービス分解を備えたAPIレイヤーでアクセス制御を強制する方がよいでしょう。

また、Supabaseを単なるバックエンドとして使用しており、その前に独自のAPIがあって、Supabase URLやanonキーをクライアントに公開しない場合、RLSはオプションです。APIがセキュリティレイヤーになります。ただし、私はそのような場合でも防御の多層化が価値あるため基本的なポリシーを追加します。

つまり、RLSはツールです。宗教ではありません。システムをシンプルで安全にする場合に使用してください。チュートリアルが使えと言ったからカーゴカルトのようにそれを使わないでください。

FAQ

サーバー側APIでのみSupabaseを使用している場合、RLSは必要ですか?

厳密には不要です。フロントエンドがSupabaseに直接触れず、すべてが独自のサーバーを経由する場合、APIがセキュリティレイヤーです。ただし、少なくとも所有者ベースのポリシーを追加して、2番目の防御線として機能することをお勧めします。だれかがAPIのバグを見つけた場合、RLSは漏れたものをキャッチします。

RLSはパフォーマンスに影響しますか?

ポリシーが大きなテーブルに対する高コストなサブクエリを含む場合、影響する可能性があります。ほぼ常に、修正はインデックスです。ポリシー条件で使用される列(user_idやorg_idなど)にインデックスがあることを確認してください。昨年のプロジェクトでは、org_idにインデックスを追加することで、800k行のテーブルでポリシー評価時間が~40msから2ms未満に低下しました。

Supabase RealtimeでRLSを使用できますか?

はい。RealtimeサブスクリプションはRLSポリシーを遵守します。ユーザーがテーブルの変更をサブスクライブする場合、そのポリシーが表示を許可する行のイベントのみを受け取ります。これはSupabaseのアーキテクチャにおける本当に素晴らしい設計決定の1つです。

`for all`と個別ポリシーの記述の違いは何ですか?

for allはSELECT、INSERT、UPDATE、DELETEをカバーする単一ポリシーを作成します。管理者オーバーライドパターンに便利です。その他のすべての場合、操作ごとに個別ポリシーを記述します。条件は通常異なるためです。SELECTはパブリック読み取りを許可し、INSERTは所有権を要求する可能性があります。個別ポリシーは、午後11時に何かが間違うとき、理解しやすくなります。

ブロックすべきではないリクエストをブロックしているポリシーをデバッグするには?

まず、auth.uid()が実際に値を返しているか確認します。ユーザーが認証されていない場合、nullが返されてほとんどのポリシーが失敗します。次に、ポリシーを一時的にUSING (true)に設定して、クエリ自体が機能することを確認します。3番目に、チェックしている値をログに記録するテストポリシーを追加します(security definer関数を使用してnoticeを発行)。SupabaseダッシュボードログもまたRLS違反を表示し、これは以前より痛みが少なくなっています。

---

行レベルのセキュリティは地味な仕事です。起こらなかった侵害についてブログ記事を書く人はいません。しかし2021年のプロジェクトデータ漏洩事件は、「RLS有効」と「RLS正しく実装」の間のギャップが、ほとんどの人が考えるより広いことを教えてくれました。これらのポリシーはそのギャップを埋めます。少なくとも、私にとってはそうです。

← 戻る