🔐

Supabase RLS の実践パターン集 — 日記アプリで使った7つのポリシー設計

に公開

はじめに

Supabase の Row Level Security(RLS)は強力ですが、実際のアプリケーションでどう設計するかは意外と情報が少ないです。

個人開発の日記アプリ Storyie では、27 テーブルすべてに RLS ポリシーを設定しています。本記事では、実際に運用しているポリシーを 7 つのパターン に分類し、それぞれの設計意図とハマりどころを共有します。

パターン一覧

# パターン ユースケース
1 自分の行だけ CRUD ユーザー設定、デバイストークン
2 公開 or 自分の行を SELECT 投稿、メモ(visibility カラム)
3 全員 SELECT + 自分だけ INSERT/DELETE いいね、コメント
4 テナントメンバーシップで制御 マルチテナント系テーブル
5 ロールベースの権限分離 メンバー管理(owner/admin のみ変更可)
6 service_role 専用 閲覧ログ、集計用テーブル
7 INSERT のみ許可(Write-only) アクティビティログ、分析イベント

パターン 1: 自分の行だけ CRUD

最もシンプルで最も使用頻度が高いパターンです。

-- SELECT
USING (user_id = (SELECT auth.uid()))
-- INSERT
WITH CHECK (user_id = (SELECT auth.uid()))
-- UPDATE
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()))
-- DELETE
USING (user_id = (SELECT auth.uid()))

ユーザー設定やプッシュ通知トークンなど、完全にユーザー個人に紐づくデータに使います。

ポイント: auth.uid() ではなく (SELECT auth.uid())

Supabase の公式ドキュメントでも推奨されていますが、auth.uid()サブクエリでラップ すると、PostgreSQL のプランナが関数の再評価を避けられるためパフォーマンスが向上します。

-- ❌ 行ごとに auth.uid() が再評価される可能性がある
USING (user_id = auth.uid())

-- ✅ 一度だけ評価される
USING (user_id = (SELECT auth.uid()))

数十行程度のテーブルでは差が出ませんが、数万行以上のテーブルでは目に見える差になります。Storyie では全ポリシーでこの書き方に統一しています。

パターン 2: 公開 or 自分の行を SELECT

投稿やメモのように 公開/非公開の切り替え があるテーブルで使います。

-- SELECT: 公開記事は誰でも読める、非公開は本人だけ
USING (
  visibility = 'public'
  OR user_id = (SELECT auth.uid())
)

-- INSERT / UPDATE / DELETE: 本人のみ
WITH CHECK (user_id = (SELECT auth.uid()))

visibility カラムを pgEnum で定義し、private / public の 2 値にしています。

なぜ friendsfollowers_only を作らなかったのか

最初は段階的な公開範囲も検討しましたが、以下の理由でシンプルな 2 値にしました。

  1. RLS ポリシーの複雑化 — フォロワー判定のサブクエリが入るとパフォーマンスに影響する
  2. MVP として十分 — 個人の日記アプリでは「公開か非公開か」で事足りる
  3. 後から追加できるpgEnum に値を追加してポリシーを拡張するだけ

RLS 設計では「今必要なものだけ実装する」ことが重要です。過剰な設計はポリシーの見通しを悪くします。

パターン 3: 全員 SELECT + 自分だけ INSERT/DELETE

いいねやコメントなど、誰でも読めるが、書き込みは認証ユーザーに限定 するパターンです。

-- SELECT: 認証済みなら全件閲覧可能
FOR SELECT TO authenticated USING (true)

-- INSERT: 自分のレコードだけ作成可能
FOR INSERT TO authenticated WITH CHECK (user_id = (SELECT auth.uid()))

-- DELETE: 自分のレコードだけ削除可能
FOR DELETE TO authenticated USING (user_id = (SELECT auth.uid()))

TO 句でロールを明示する

TO authenticated を付けることで、匿名ユーザー(anon ロール)からのアクセスを弾けます。Supabase ではデフォルトで anonauthenticated の 2 つのロールが存在するので、これを活用しない手はありません。

コメント系のテーブルでは SELECTTO public(= 全ロール)にする場合もあります。公開投稿のコメントは未ログインユーザーにも表示したいケースがあるからです。

-- コメント: 未認証ユーザーでも閲覧可能
FOR SELECT TO public USING (true)

-- コメント: 認証済みユーザーのみ投稿可能
FOR INSERT TO authenticated WITH CHECK (author_id = (SELECT auth.uid()))

パターン 4: テナントメンバーシップで制御

マルチテナント構成では、user_id ではなく テナントへの所属 でアクセスを制御します。

-- テナント情報: 自分が所属するテナントのみ閲覧可能
FOR SELECT USING (
  EXISTS (
    SELECT 1 FROM members
    WHERE members.tenant_id = tenants.id
    AND members.user_id = (SELECT auth.uid())
  )
)

INSERT / UPDATE / DELETE は RLS ではなく、サービスレイヤー(service_role)で制御しています。テナント作成はサインアップフローの一部として実行されるため、クライアントから直接操作する必要がないからです。

IN vs EXISTS の使い分け

テナントメンバーの判定には 2 つの書き方があります。

-- IN: シンプルだが、サブクエリの結果が大きいと遅い
tenant_id IN (
  SELECT tenant_id FROM members
  WHERE user_id = (SELECT auth.uid())
)

-- EXISTS: 最初の一致で止まるため、テーブルが大きい場合に有利
EXISTS (
  SELECT 1 FROM members
  WHERE members.tenant_id = tenants.id
  AND members.user_id = (SELECT auth.uid())
)

ユーザーあたりのテナント数が少ない(通常 1 つ)場合は IN でも EXISTS でも差はありません。しかし、将来のスケールを考えると EXISTS のほうが安全です。

パターン 5: ロールベースの権限分離

メンバー管理(追加・編集・削除)は、owner または admin ロール を持つメンバーだけに許可します。

-- INSERT: owner/admin のみ新メンバーを追加可能
FOR INSERT WITH CHECK (
  tenant_id IN (
    SELECT tenant_id FROM members
    WHERE user_id = (SELECT auth.uid())
    AND role IN ('owner', 'admin')
  )
)

再帰参照に注意

メンバーテーブルのポリシーが 自分自身のテーブルを参照 しています。これは PostgreSQL では問題なく動作しますが、テスト時に混乱しやすいポイントです。

「自分のメンバーシップレコードを使って、同じテーブルの別レコードへの権限を判定する」という構造は、理解すると自然ですが、初見では分かりにくいです。コメントでしっかり意図を書いておくことをおすすめします。

パターン 6: service_role 専用

クライアントからは書き込ませたくないが、サーバーサイドの処理(cron ジョブ等)では書き込みが必要なテーブルです。

-- service_role のみ INSERT 可能
FOR INSERT TO service_role WITH CHECK (true)

閲覧ログのようなテーブルがこのパターンです。Edge Function 経由で service_role キーを使って書き込み、定期的な cron ジョブで集計テーブルに反映しています。

なぜクライアントから直接書かないのか

  1. 不正なデータの水増しを防ぐ — クライアントから INSERT できると、API を直接叩いてデータを操作できてしまう
  2. バリデーションをサーバーで担保 — 同一ユーザーの短時間内の重複レコード除外など、ビジネスロジックをサーバー側で管理
  3. RLS ポリシーがシンプルになる — クライアント向けの複雑な条件が不要

パターン 7: INSERT のみ許可(Write-only)

アクティビティログなど、書き込みのみで読み取り不要 なテーブルです。

-- INSERT のみ: 自分のログを書き込める
FOR INSERT WITH CHECK (user_id = (SELECT auth.uid()))
-- SELECT / UPDATE / DELETE のポリシーなし = アクセス不可

RLS が有効なテーブルでは、ポリシーが存在しない操作は 暗黙的に拒否 されます。つまり、INSERT ポリシーだけ定義すれば SELECT / UPDATE / DELETE は自動的にブロックされます。

これは Supabase の「デフォルト拒否」の原則を活かした設計です。ポリシーを追加するほどアクセスが広がるのであって、制限されるのではないという点を理解しておくことが重要です。

設計で意識していること

1. ポリシーはスキーマと一緒に管理する

Drizzle の pgPolicy を使い、テーブル定義とポリシーを同じファイルに書いています。ポリシーが別の場所に散らばると「このテーブルにはどんなアクセス制御があるのか」が分からなくなります。

2. 操作ごとにポリシーを分ける

// ✅ 操作ごとに分離
selectPolicy: pgPolicy("posts_select", { for: "select", ... }),
insertPolicy: pgPolicy("posts_insert", { for: "insert", ... }),
updatePolicy: pgPolicy("posts_update", { for: "update", ... }),
deletePolicy: pgPolicy("posts_delete", { for: "delete", ... }),

1 つのポリシーで全操作をカバーすることもできますが、操作ごとに分けるほうが以下の点で優れています。

  • デバッグしやすい — どの操作が拒否されたか特定しやすい
  • 変更の影響範囲が明確 — SELECT のルールを変えても INSERT には影響しない
  • コードレビューしやすい — 各操作の意図がポリシー名から読み取れる

3. UPDATE には USINGWITH CHECK の両方を書く

-- USING: どの行を更新対象にできるか(WHERE句に近い)
-- WITH CHECK: 更新後の値が条件を満たすか
FOR UPDATE
  USING (user_id = (SELECT auth.uid()))
  WITH CHECK (user_id = (SELECT auth.uid()))

UPDATE の WITH CHECK を省略すると、USING の条件がそのまま適用されます。しかし、明示的に書くことで「更新後も user_id が変わらないことを保証する」という意図が伝わります。

4. テストは anon / authenticated / service_role の 3 ロールで

ポリシーを追加・変更したら、最低限 3 つのロールでテストします。

  • anon — 未認証ユーザーとしてアクセスした場合
  • authenticated — ログインユーザーとしてアクセスした場合(自分のデータ vs 他人のデータ)
  • service_role — サーバーサイド処理としてアクセスした場合(RLS をバイパス)

Supabase Dashboard の SQL エディタで SET ROLE を使えば手軽にテストできます。

まとめ

RLS の設計は「デフォルト拒否」の原則を軸に、ユースケースに合わせてパターンを使い分けることが大切です。

Storyie で 27 テーブル分のポリシーを設計・運用した結果、7 つのパターンに集約できました。最初から完璧な設計は不要で、シンプルなパターンから始めて必要に応じて拡張していくのが実践的です。

重要なのは、ポリシーをコードとして管理し、意図を明文化すること。Drizzle の pgPolicy と組み合わせることで、スキーマ変更とポリシー変更を同じ PR でレビューできるようになり、セキュリティの見通しが格段に良くなります。


Storyie — 日々の出来事を記録・共有できる日記アプリ

Discussion