【技術解説】Supabase RLSポリシー設計パターン集 - セキュアなマルチテナント実装

はじめに
こんにちは、QUEST代表のてんちょーです。
Supabaseを使ったアプリケーション開発では、Row Level Security(RLS)の設計が最も重要なセキュリティ対策の一つです。しかし、RLSの実装パターンや設計思想について日本語で体系的に解説されている資料は少ないのが現状です。
私たちは複数のプロダクト(CRM、記事生成SaaS、コーポレートサイト)でSupabaseを活用しており、その中で蓄積したRLS設計のベストプラクティスを共有します。
この記事で学べること
- RLS(Row Level Security)の基本概念と必要性
- 認証・認可パターンの設計手法(自分のデータのみアクセス、公開/非公開制御)
- マルチテナント設計(組織単位のアクセス制御、ロールベースアクセス)
- 実際のプロダクトで使用しているRLSポリシーの実装例
- よくあるミスとその対策(ポリシー忘れ、パフォーマンス問題、service_roleの誤用)
- RLSポリシーのテスト手法
対象読者
- Supabaseを使ったアプリケーション開発をしているエンジニア
- マルチテナントSaaSを構築したいと考えている方
- データベースのセキュリティ設計に興味がある方
- PostgreSQLのRow Level Securityについて学びたい方
RLS(Row Level Security)とは
概要
Row Level Security(RLS)は、PostgreSQLが提供する行レベルのアクセス制御機能です。通常のSQL権限(GRANT/REVOKE)がテーブル全体へのアクセスを制御するのに対し、RLSは行(レコード)単位でアクセスを制限できます。
従来のSQL権限:
テーブル全体へのSELECT/INSERT/UPDATE/DELETE権限を制御
RLS:
各レコードごとに「誰がアクセスできるか」を制御
なぜRLSが重要か
SupabaseではクライアントサイドJavaScriptから直接データベースにアクセスできます。これは開発効率を大幅に向上させる一方で、適切なセキュリティ設計がなければ全データが筒抜けになるリスクを抱えています。
RLSなしの危険性
// ❌ RLSが設定されていない場合、全ユーザーのデータが取得できてしまう
const { data } = await supabase
.from('customers')
.select('*')
// → 本来見えるべきでない他ユーザーのデータまで取得できる
RLSありの安全性
// ✅ RLSが有効な場合、自分のデータのみ取得される
const { data } = await supabase
.from('customers')
.select('*')
// → 自動的に「auth.uid() = user_id」の条件が適用される
RLSの仕組み
RLSは以下の要素で構成されます。
- RLSの有効化:
ALTER TABLE テーブル名 ENABLE ROW LEVEL SECURITY; - ポリシーの作成: 誰が、どの操作(SELECT/INSERT/UPDATE/DELETE)を、どの条件で許可するかを定義
- 自動適用: クエリ実行時に自動的にポリシーが適用される
-- 1. RLS有効化
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
-- 2. ポリシー作成(ユーザーは自分の記事のみ閲覧可能)
CREATE POLICY "users_read_own_articles"
ON articles FOR SELECT
USING (auth.uid() = user_id);
-- 3. 自動適用(以下のクエリで自動的にuser_id条件が追加される)
-- SELECT
* FROM articles WHERE auth.uid() = user_id

基本パターン
実際のプロダクト開発でよく使う基本的なRLSパターンを紹介します。
パターン1: 認証ユーザーのみアクセス許可
最もシンプルなパターンです。ログインしているユーザーなら誰でもアクセス可能な場合に使用します。
-- RLS有効化
ALTER TABLE organization_settings ENABLE ROW LEVEL SECURITY;
-- 認証ユーザーは閲覧可能
CREATE POLICY "organization_settings_select"
ON organization_settings
FOR SELECT TO authenticated
USING (true);
-- 認証ユーザーは更新可能
CREATE POLICY "organization_settings_update"
ON organization_settings
FOR UPDATE TO authenticated
USING (true);
-- 認証ユーザーは挿入可能
CREATE POLICY "organization_settings_insert"
ON organization_settings
FOR INSERT TO authenticated
WITH CHECK (true);
使用例:
- 組織設定(会社名、ロゴ、住所など)
- システム設定
- マスターデータ
TypeScript実装例:
// lib/supabase/organization.ts
export async function getOrganizationSettings() {
const { data, error } = await supabase
.from('organization_settings')
.select('*')
.single()
if (error) throw error
return data
}
export async function updateOrganizationSettings(
settings: Partial<OrganizationSettings>
) {
const { data, error } = await supabase
.from('organization_settings')
.update(settings)
.eq('id', settings.id)
if (error) throw error
return data
}
パターン2: 自分のデータのみアクセス可能
最も一般的なパターンです。ユーザーは自分が作成したデータのみ閲覧・編集できます。
-- 通知テーブル
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
notification_type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- RLS有効化
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
-- ユーザーは自分の通知のみアクセス可能
CREATE POLICY "users_crud_own_notifications"
ON notifications
FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
ポイント:
USING: SELECT/UPDATE/DELETE時の条件WITH CHECK: INSERT/UPDATE時のバリデーションFOR ALL: すべての操作(SELECT/INSERT/UPDATE/DELETE)に適用
TypeScript実装例:
// lib/supabase/notifications.ts
export async function getMyNotifications() {
// auth.uid()が自動的に適用されるため、
// 自分の通知のみ取得される
const { data, error } = await supabase
.from('notifications')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
}
export async function markAsRead(notificationId: string) {
// 自分の通知のみ更新可能(他人の通知は更新できない)
const { error } = await supabase
.from('notifications')
.update({ is_read: true })
.eq('id', notificationId)
if (error) throw error
}
パターン3: 公開/非公開フラグ制御
公開データは全員が閲覧可能、非公開データは作成者のみアクセス可能にするパターンです。
-- 記事テーブル
CREATE TABLE IF NOT EXISTS articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
title TEXT NOT NULL,
content TEXT,
status TEXT NOT NULL DEFAULT 'draft', -- draft, published
published_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- RLS有効化
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
-- 公開記事は誰でも閲覧可能
CREATE POLICY "public_read_published_articles"
ON articles
FOR SELECT
TO public
USING (status = 'published');
-- 認証ユーザーも公開記事を閲覧可能
CREATE POLICY "authenticated_read_published_articles"
ON articles
FOR SELECT
TO authenticated
USING (status = 'published');
-- ユーザーは自分の記事(下書き含む)を閲覧可能
CREATE POLICY "users_read_own_articles"
ON articles
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- ユーザーは自分の記事のみ作成・更新・削除可能
CREATE POLICY "users_crud_own_articles"
ON articles
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "users_update_own_articles"
ON articles
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "users_delete_own_articles"
ON articles
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
TypeScript実装例:
// lib/supabase/articles.ts
// 公開記事一覧(未ログインユーザーでも取得可能)
export async function getPublishedArticles() {
const { data, error } = await supabase
.from('articles')
.select('*')
.eq('status', 'published')
.order('published_at', { ascending: false })
if (error) throw error
return data
}
// 自分の記事一覧(下書き含む)
export async function getMyArticles() {
const { data, error } = await supabase
.from('articles')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
}
// 記事を公開
export async function publishArticle(articleId: string) {
const { error } = await supabase
.from('articles')
.update({
status: 'published',
published_at: new Date().toISOString(),
})
.eq('id', articleId)
if (error) throw error
}
マルチテナントパターン
複数の組織(テナント)がデータを共有するSaaSアプリケーションで使うパターンです。
パターン4: 組織単位のアクセス制御
ユーザーは自分が所属する組織のデータのみアクセス可能にします。
-- 顧客テーブル(組織ごとに分離)
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL, -- テナント識別子
company_name TEXT NOT NULL,
contact_name TEXT NOT NULL,
email TEXT,
phone TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ユーザー・組織の紐付けテーブル
CREATE TABLE IF NOT EXISTS organization_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
user_id UUID NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- admin, member, viewer
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(organization_id, user_id)
);
-- RLS有効化
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
-- ユーザーは自分が所属する組織の顧客のみ閲覧可能
CREATE POLICY "members_read_org_customers"
ON customers
FOR SELECT
TO authenticated
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
)
);
-- ユーザーは自分が所属する組織に顧客を追加可能
CREATE POLICY "members_insert_org_customers"
ON customers
FOR INSERT
TO authenticated
WITH CHECK (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
)
);
TypeScript実装例:
// lib/supabase/customers.ts
// 自分の組織の顧客一覧を取得
export async function getOrganizationCustomers() {
// RLSが自動的に organization_id でフィルタする
const { data, error } = await supabase
.from('customers')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
}
// 顧客を追加
export async function createCustomer(
organizationId: string,
customer: Partial<Customer>
) {
const { data, error } = await supabase
.from('customers')
.insert({
organization_id: organizationId,
...customer,
})
if (error) throw error
return data
}
パターン5: ロールベースアクセス(RBAC)
組織内でも役割(admin/member/viewer)によってアクセス権限を変えるパターンです。
-- 管理者(admin)のみがユーザー管理可能
CREATE POLICY "admins_manage_org_members"
ON organization_members
FOR ALL
TO authenticated
USING (
EXISTS (
SELECT 1
FROM organization_members
WHERE user_id = auth.uid()
AND organization_id = organization_members.organization_id
AND role = 'admin'
)
);
-- メンバー以上は組織メンバーを閲覧可能
CREATE POLICY "members_view_org_members"
ON organization_members
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1
FROM organization_members
WHERE user_id = auth.uid()
AND organization_id = organization_members.organization_id
AND role IN ('admin', 'member', 'viewer')
)
);
より複雑な権限制御の例:
-- 顧客の削除は管理者のみ
CREATE POLICY "admins_delete_customers"
ON customers
FOR DELETE
TO authenticated
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
AND role = 'admin'
)
);
-- 閲覧者(viewer)は読み取り専用
CREATE POLICY "viewers_read_only_customers"
ON customers
FOR SELECT
TO authenticated
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
AND role IN ('admin', 'member', 'viewer')
)
);
-- メンバー以上は更新可能
CREATE POLICY "members_update_customers"
ON customers
FOR UPDATE
TO authenticated
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
AND role IN ('admin', 'member')
)
)
WITH CHECK (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
AND role IN ('admin', 'member')
)
);
TypeScript実装例(権限チェック):
// lib/supabase/permissions.ts
export async function getUserRole(organizationId: string): Promise<string | null> {
const { data, error } = await supabase
.from('organization_members')
.select('role')
.eq('organization_id', organizationId)
.single()
if (error) return null
return data.role
}
export async function canDeleteCustomer(organizationId: string): Promise<boolean> {
const role = await getUserRole(organizationId)
return role === 'admin'
}
export async function canUpdateCustomer(organizationId: string): Promise<boolean> {
const role = await getUserRole(organizationId)
return role === 'admin' || role === 'member'
}
パターン6: 階層的権限(部署→チーム→個人)
大規模な組織では、部署やチーム単位でデータアクセスを制御する必要があります。
-- 部署テーブル
CREATE TABLE IF NOT EXISTS departments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
parent_department_id UUID REFERENCES departments(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ユーザー・部署の紐付け
CREATE TABLE IF NOT EXISTS department_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES departments(id),
user_id UUID NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(department_id, user_id)
);
-- プロジェクトテーブル(部署単位で管理)
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
department_id UUID REFERENCES departments(id),
name TEXT NOT NULL,
status TEXT DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- RLS有効化
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 自分が所属する部署のプロジェクトのみアクセス可能
CREATE POLICY "department_members_access_projects"
ON projects
FOR SELECT
TO authenticated
USING (
department_id IN (
SELECT department_id
FROM department_members
WHERE user_id = auth.uid()
)
);

実装例: QUESTBook CRMのRLSポリシー
実際に運用しているCRMシステムのRLS設計を紹介します。
システム概要
- 顧客管理(customers)
- 問い合わせ管理(inquiries)
- 商談管理(meetings)
- 見積もり管理(quotations)
- 契約管理(contracts)
- 案件管理(projects)
設計方針
- 認証ユーザーは全データにアクセス可能(小規模チーム向け)
- 通知のみユーザー個別管理
- 外部キーでデータ整合性を保証(CASCADE削除)
顧客テーブルのRLS
-- 顧客テーブル
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_name TEXT NOT NULL,
contact_name TEXT NOT NULL,
phone TEXT,
email TEXT,
address TEXT,
industry TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- RLS有効化
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
-- 認証済みユーザーは全顧客を閲覧・編集可能
CREATE POLICY "認証済みユーザーは全顧客を閲覧・編集可能"
ON customers
FOR ALL
USING (auth.role() = 'authenticated')
WITH CHECK (auth.role() = 'authenticated');
問い合わせテーブルのRLS
-- 問い合わせテーブル
CREATE TABLE IF NOT EXISTS inquiries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID REFERENCES customers(id) ON DELETE CASCADE,
service_type TEXT NOT NULL CHECK (service_type IN ('website', 'business-dev', 'career')),
inquiry_source TEXT NOT NULL CHECK (inquiry_source IN ('web', 'phone', 'email', 'referral')),
inquiry_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
inquiry_content TEXT,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'contacted', 'qualified', 'lost')),
assigned_to UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- RLS有効化
ALTER TABLE inquiries ENABLE ROW LEVEL SECURITY;
-- 認証済みユーザーは全問い合わせを閲覧・編集可能
CREATE POLICY "認証済みユーザーは全問い合わせを閲覧・編集可能"
ON inquiries
FOR ALL
USING (auth.role() = 'authenticated')
WITH CHECK (auth.role() = 'authenticated');
通知テーブルのRLS(個別管理)
-- 通知テーブル
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
notification_type TEXT NOT NULL CHECK (notification_type IN ('deadline', 'payment', 'task', 'inquiry')),
title TEXT NOT NULL,
message TEXT NOT NULL,
link_url TEXT,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- RLS有効化
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
-- ユーザーは自分の通知のみ閲覧・編集可能
CREATE POLICY "ユーザーは自分の通知のみ閲覧・編集可能"
ON notifications
FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
インデックス設計
RLSポリシーでよく使うカラムにはインデックスを作成し、パフォーマンスを向上させます。
-- 頻繁に検索するカラムにインデックス作成
CREATE INDEX IF NOT EXISTS idx_customers_company_name ON customers(company_name);
CREATE INDEX IF NOT EXISTS idx_inquiries_customer_id ON inquiries(customer_id);
CREATE INDEX IF NOT EXISTS idx_inquiries_status ON inquiries(status);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read);
よくあるミスと対策
実際の開発で遭遇したミスとその対策を紹介します。
ミス1: RLS有効化後にポリシーを設定しない
症状: RLSを有効にしたが、ポリシーを設定し忘れてすべてのアクセスが拒否される。
-- ❌ RLSを有効にしただけで終わり
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
-- → すべてのSELECT/INSERT/UPDATE/DELETEが拒否される
エラーメッセージ:
new row violates row-level security policy for table "articles"
対策: RLS有効化と同時に必ずポリシーを設定する。
-- ✅ RLS有効化 + ポリシー設定をセットで行う
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_crud_own_articles"
ON articles
FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
ミス2: service_roleの使いすぎ
症状: バックエンド処理でservice_role(RLSをバイパスする特権キー)を使いすぎて、セキュリティホールを作る。
// ❌ 本来anonキーで十分なのにservice_roleを使う
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // RLSバイパス
)
// → RLSが効かず、全データにアクセスできてしまう
対策: 基本的にanonキーを使い、service_roleは本当に必要な場合のみ使う。
service_roleが必要なケース:
- バックグラウンドジョブ(Cron)
- 管理者専用API
- 初期データ投入スクリプト
通常のAPI処理ではanonキーを使う:
// ✅ anonキーでRLSが効く
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // RLS適用
)
ミス3: USINGとWITH CHECKの混同
症状: USINGとWITH CHECKの違いを理解せず、意図しない挙動になる。
- USING: SELECT/UPDATE/DELETE時の条件(既存データへのアクセス制御)
- WITH CHECK: INSERT/UPDATE時のバリデーション(新規データの制約)
-- ❌ WITH CHECKを忘れると、他人のuser_idでINSERTできてしまう
CREATE POLICY "users_insert_articles"
ON articles
FOR INSERT
TO authenticated
USING (auth.uid() = user_id); -- USINGだけでは不十分
-- → INSERT時にuser_idを偽装できる
対策: INSERT/UPDATE時は必ずWITH CHECKを設定する。
-- ✅ WITH CHECKで挿入データを検証
CREATE POLICY "users_insert_articles"
ON articles
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
ミス4: N+1クエリ問題(パフォーマンス劣化)
症状: RLSポリシーで複雑なサブクエリを使い、パフォーマンスが劣化する。
-- ❌ 毎回サブクエリが実行される
CREATE POLICY "members_read_org_customers"
ON customers
FOR SELECT
TO authenticated
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid() -- 毎回実行
)
);
対策: インデックスを作成し、クエリを最適化する。
-- ✅ インデックス作成
CREATE INDEX IF NOT EXISTS idx_organization_members_user_id
ON organization_members(user_id);
CREATE INDEX IF NOT EXISTS idx_customers_organization_id
ON customers(organization_id);
さらなる最適化(マテリアライズドビュー):
-- ユーザーがアクセス可能な組織一覧をキャッシュ
CREATE MATERIALIZED VIEW user_accessible_organizations AS
SELECT DISTINCT user_id, organization_id
FROM organization_members;
-- インデックス作成
CREATE INDEX idx_user_accessible_orgs
ON user_accessible_organizations(user_id, organization_id);
-- 定期的に更新(Cron Job)
REFRESH MATERIALIZED VIEW user_accessible_organizations;
ミス5: ポリシー名の重複
症状: 同じポリシー名で複数のポリシーを作ろうとしてエラーになる。
-- ❌ 同じポリシー名
CREATE POLICY "read_policy" ON customers FOR SELECT ...;
CREATE POLICY "read_policy" ON inquiries FOR SELECT ...; -- エラー
対策: ポリシー名にテーブル名を含める命名規則を使う。
-- ✅ テーブル名を含める
CREATE POLICY "customers_read_policy" ON customers FOR SELECT ...;
CREATE POLICY "inquiries_read_policy" ON inquiries FOR SELECT ...;
テスト方法
RLSポリシーが正しく機能しているかテストする方法を紹介します。
方法1: 別ユーザーでログインしてテスト
最も確実な方法は、実際に別のユーザーでログインして確認することです。
// tests/rls.test.ts
import { createClient } from '@supabase/supabase-js'
describe('RLS Policy Tests', () => {
test('ユーザーAは自分の記事のみ取得できる', async () => {
// ユーザーAでログイン
const supabaseA = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
await supabaseA.auth.signInWithPassword({
email: 'userA@example.com',
password: 'password',
})
// 記事を取得
const { data: articlesA } = await supabaseA
.from('articles')
.select('*')
// ユーザーAの記事のみ取得されることを確認
expect(articlesA?.every(a => a.user_id === 'user-a-uuid')).toBe(true)
// ユーザーBでログイン
const supabaseB = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
await supabaseB.auth.signInWithPassword({
email: 'userB@example.com',
password: 'password',
})
// 記事を取得
const { data: articlesB } = await supabaseB
.from('articles')
.select('*')
// ユーザーBの記事のみ取得されることを確認
expect(articlesB?.every(a => a.user_id === 'user-b-uuid')).toBe(true)
// ユーザーAとユーザーBの記事が重複しないことを確認
const idsA = new Set(articlesA?.map(a => a.id))
const idsB = new Set(articlesB?.map(a => a.id))
const intersection = [...idsA].filter(id => idsB.has(id))
expect(intersection.length).toBe(0)
})
})
方法2: SQL Editorで直接確認
Supabaseダッシュボードの SQL Editor で、特定のユーザーとしてクエリを実行できます。
-- ユーザーAとしてクエリ実行
SET LOCAL role authenticated;
SET LOCAL request.jwt.claims.sub = 'user-a-uuid';
SELECT
* FROM articles;
-- → ユーザーAの記事のみ表示されるはず
方法3: RLSバイパスの確認(service_role)
service_roleキーではRLSがバイパスされることを確認します。
test('service_roleはRLSをバイパスする', async () => {
const supabaseService = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // service_role
)
const { data: allArticles } = await supabaseService
.from('articles')
.select('*')
// すべてのユーザーの記事が取得される
expect(allArticles!.length).toBeGreaterThan(0)
})
方法4: Playwright E2Eテスト
実際のブラウザでユーザー操作をシミュレートしてテストします。
// e2e/rls.spec.ts
import { test, expect } from '@playwright/test'
test('ユーザーAは他人の記事を編集できない', async ({ page }) => {
// ユーザーAでログイン
await page.goto('/login')
await page.fill('input[name="email"]', 'userA@example.com')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
// ユーザーBの記事URLに直接アクセス
await page.goto('/articles/user-b-article-id/edit')
// 403エラーまたはリダイレクトされることを確認
await expect(page.locator('text=アクセス権限がありません')).toBeVisible()
})
パフォーマンス最適化
RLSポリシーのパフォーマンスを向上させるテクニックを紹介します。
1. インデックスの作成
RLSポリシーで使用するカラムには必ずインデックスを作成します。
-- auth.uid()と比較するカラム
CREATE INDEX idx_articles_user_id ON articles(user_id);
-- organization_idでフィルタする場合
CREATE INDEX idx_customers_organization_id ON customers(organization_id);
-- 複合インデックス
CREATE INDEX idx_articles_user_status ON articles(user_id, status);
2. EXPLAINでクエリプランを確認
EXPLAIN ANALYZE
SELECT
* FROM articles
WHERE auth.uid() = user_id;
-- インデックスが使われているか確認
-- Seq Scan → インデックスなし(遅い)
-- Index Scan → インデックスあり(速い)
3. マテリアライズドビューの活用
複雑なJOINや集計が必要な場合は、マテリアライズドビューを使います。
-- ユーザーごとの記事統計
CREATE MATERIALIZED VIEW user_article_stats AS
SELECT
user_id,
COUNT(*) AS total_articles,
COUNT(CASE WHEN status = 'published' THEN 1 END) AS published_articles,
MAX(created_at) AS last_article_at
FROM articles
GROUP BY user_id;
-- インデックス作成
CREATE INDEX idx_user_article_stats_user_id ON user_article_stats(user_id);
-- 定期的に更新
REFRESH MATERIALIZED VIEW user_article_stats;
まとめ
Supabase RLSポリシーの設計パターンを体系的に解説しました。
重要ポイント
- RLSは必須: クライアントサイドからデータベースに直接アクセスする場合、RLSなしでは全データが漏洩する
- 基本パターンを理解する: 認証ユーザーのみ、自分のデータのみ、公開/非公開制御
- マルチテナント設計: 組織単位のアクセス制御、ロールベースアクセス、階層的権限
- よくあるミス: ポリシー忘れ、service_roleの誤用、USING/WITH CHECKの混同
- テストは必須: 別ユーザーでのテスト、SQL Editorでの確認、E2Eテスト
- パフォーマンス最適化: インデックス作成、EXPLAINでの確認、マテリアライズドビュー
次のステップ
- 自分のプロジェクトにRLSを導入してみる
- 既存のRLSポリシーをレビューして改善する
- E2Eテストでセキュリティを確認する
- パフォーマンスを計測して最適化する
参考リンク
- Supabase公式ドキュメント - Row Level Security
- PostgreSQL公式ドキュメント - Row Security Policies
- Supabaseのセキュリティベストプラクティス
この記事が、Supabaseを使ったセキュアなアプリケーション開発の参考になれば幸いです。
著者: てんちょー(合同会社QUEST 代表)
普段はSIerで経営企画部員として働きながら、週末起業で高校時代の友人と共同創業。Claude Codeを活用した業務効率化・システム開発を得意としています。
🌐 コーポレートサイト: https://llc-quest.com 🐦 Twitter (X): https://x.com/questceo_ai

