Supabase の RLS テストを自動化して、セキュリティ事故を防ぐ方法

はじめに
Supabase は、Firebase 代替として人気を集めるオープンソースのバックエンドプラットフォームです。PostgreSQL をベースにしており、認証、ストレージ、リアルタイムサブスクリプションなど、多くの機能を提供しています。その中でも特に重要な機能の一つが、Row Level Security(RLS)です。
RLS は、データベースレベルでアクセス制御を行う機能で、どのユーザーがどのデータにアクセスできるかを細かく制御することができます。しかし、この強力な機能も、設定ミスがあれば意味がありません。むしろ、誤った安心感を与えてしまう危険性すらあります。
本記事では、プロジェクトでの経験を基に、Supabase におけるセキュリティ、特に RLS の自動テスト戦略について解説します。手動テストの限界と自動テストの必要性、そして具体的な実装方法について詳しく見ていきましょう。
Supabase におけるセキュリティの課題
RLS とは何か、なぜ重要なのか
Row Level Security(RLS)は、PostgreSQL の機能で、テーブルの各行に対するアクセス制御を行うことができます。例えば、「ユーザーは自分のデータだけを見ることができる」「管理者はすべてのデータを見ることができる」といったポリシーを設定できます。
Supabase では、この RLS を簡単に設定できる UI を提供しており、SQL の知識がなくても基本的なセキュリティポリシーを実装できます。しかし、この便利さゆえに、設定の重要性や複雑さを見落としがちです。
RLS 設定ミスによるセキュリティリスク
RLS の設定ミスは、深刻なセキュリティリスクを引き起こす可能性があります。以下に、想定される具体的な例を挙げます:
- データ漏洩のリスク: RLS が正しく設定されていないと、意図せずユーザー A がユーザー B の情報を見ることができてしまいます。
-- 正しいRLS設定の例 CREATE POLICY "ユーザーは自分のプロフィールのみ参照可能" ON profiles FOR SELECT USING (auth.uid() = user_id);
- データ改ざんのリスク: RLS 設定ミスにより、悪意のあるユーザーがサービスの重要情報を改ざんできてしまう可能性があります。
-- 正しいRLS設定の例(管理者のみアクセス可能) CREATE POLICY "管理者のみアクセス可能" ON master_data USING (false); -- 通常のユーザーはアクセス不可
手動テストの限界
RLS の設定が正しいかどうかを手動でテストすることには、以下のような限界があります:
- 時間と労力の問題: RLS の CRUD 操作(作成・読取・更新・削除)すべてを手動でテストするのは、非常に時間がかかります。特に、複数のテーブルや複雑なポリシーがある場合、すべてのケースを網羅するのは現実的ではありません。
- 継続的な検証の難しさ: マイグレーションで RLS 設定を変更した際に、既存の設定が壊れていないか確認するのは大変です。手動テストでは、変更の度に全てのポリシーを再確認する必要があり、これは非効率的です。
- テスト環境の準備の手間: 手動テストでは、異なるユーザー権限でのテストを行うために、複数のアカウントでログインし直したり、テストデータを準備したりする手間がかかります。
これらの問題から、RLS のテストは自動化することが強く推奨されます。次のセクションでは、テスト自動化の利点と具体的な実装方法について説明します。
テスト自動化の利点
RLS テストを自動化することで、以下のような利点があります:
- 開発速度の向上: PR ごとに RLS テストが自動実行されるため、セキュリティリグレッションを早期に発見できます。これにより、問題の修正コストを大幅に削減できます。
- セキュリティの信頼性向上: 自動テストにより、人的ミスによるセキュリティホールの可能性を減らすことができます。また、テストカバレッジを可視化することで、セキュリティの網羅性を確認できます。
- 開発者の意識向上: 新機能追加時にもセキュリティを考慮した開発が促進されます。テストが失敗すると即座にフィードバックが得られるため、開発者はセキュリティを意識せざるを得なくなります。
- ドキュメントとしての価値: テストコードは、意図したセキュリティポリシーのドキュメントとしても機能します。新しいチームメンバーが参加した際に、テストコードを見ることでセキュリティモデルを理解しやすくなります。
- 継続的な検証の実現: CI/CD パイプラインに組み込むことで、すべての変更に対してセキュリティテストを実行できます。これにより、「セキュリティは後回し」という状況を防ぎます。
実際に、プロジェクトでは、RLS テストの自動化により、以下のような変化がありました:
- セキュリティ関連のバグが早期に発見されるようになった
- チーム全体のセキュリティ意識が向上した
- コードレビューでセキュリティの観点からのフィードバックが増えた
- 新機能のリリース時の安心感が増した
テスト環境の構築
Supabase のテストを効率的に行うためには、開発環境とは分離されたテスト環境が必要です。ここでは、プロジェクトで採用したテスト環境の構築方法について説明します。
環境分離の重要性
開発環境とテスト環境を分離することで、以下のメリットがあります:
- テスト実行時に開発データが破壊されるリスクを排除できる
- テスト用のデータを自由に操作できる
- 開発とテストを並行して行える
- CI/CD パイプラインでの自動テストが容易になる
テスト用 Supabase インスタンスの設定
Supabase のローカル開発環境では、supabase start
コマンドでインスタンスを起動します。テスト用には、別のポート番号を使用した設定ファイルを用意します。
# test/supabase/config.toml
project_id = "test"
[api]
enabled = true
# 開発環境と競合しないようにポート番号を変更
port = 54421
[db]
# 開発環境と競合しないようにポート番号を変更
port = 54422
shadow_port = 54420
major_version = 15
[studio]
enabled = true
# 開発環境と競合しないようにポート番号を変更
port = 54423
api_url = "<http://127.0.0.1>"
...
マイグレーションの共有
開発環境とテスト環境で同じマイグレーションファイルを使用するために、シンボリックリンクを設定します。これにより、マイグレーションの変更が両環境に自動的に反映されます。
# Linuxまたは macOS
ln -s ./supabase/migrations test/supabase/migrations
テスト用クライアント設定
テスト用の Supabase クライアントを設定します。通常のクライアントと管理者権限を持つクライアントの両方を用意します。
// test/helpers/supabase-client.ts
import { createClient } from "@supabase/supabase-js";
// テスト用のSupabaseクライアント
export const supabaseTest = createClient(
process.env.SUPABASE_TEST_URL || "<http://localhost:54421>",
process.env.SUPABASE_TEST_ANON_KEY
);
// 管理者用のSupabaseクライアント
export const supabaseTestAdmin = createClient(
process.env.SUPABASE_TEST_URL || "<http://localhost:54421>",
process.env.SUPABASE_TEST_SERVICE_ROLE_KEY
);
自動テストの実装
ここでは、RLS テストの実装例を紹介します。
RLS テストの実装
RLS テストでは、通常のユーザー権限でデータにアクセスした場合に、適切なアクセス制御が行われるかをテストします。 以下は、GitHub連携アプリのGithub App インストール情報の RLS テストの例です。 RLS では認証ユーザーからのアクセスをすべて禁止しており、API からのみアクセスできる想定をしています。
// test/specs/github_app_installations/rls.test.ts
import { supabaseTest, supabaseTestAdmin } from "../../helpers/supabase-client";
import {
createTestUser,
deleteTestUser,
generateTestId,
setupTestEnvironment,
} from "../../helpers/test-utils";
describe("GitHub App Installations RLS Tests", () => {
let userId: string;
const testId = generateTestId();
const testInstallation = {
organization_name: `test-org-${testId}`,
organization_id: 12345,
installation_id: 67890,
};
beforeAll(async () => {
await setupTestEnvironment();
// テスト用ユーザーを作成
const { data: userData } = await createTestUser(
`user_${testId}@example.com`,
"password123",
{ user_name: `user_${testId}` }
);
if (!userData?.user) {
throw new Error("テストユーザーの作成に失敗しました");
}
userId = userData.user.id;
// テスト用のGitHub Appインストール情報を管理者権限で作成
await supabaseTestAdmin
.from("github_app_installations")
.insert(testInstallation);
});
// 各テスト前に通常ユーザーとしてログイン
beforeEach(async () => {
await supabaseTest.auth.signInWithPassword({
email: `user_${testId}@example.com`,
password: "password123",
});
});
afterAll(async () => {
cleanUp();
});
test("通常ユーザーはGitHub Appインストール情報を参照できない", async () => {
// GitHub Appインストール情報を参照しようとする
const { data, error } = await supabaseTest
.from("github_app_installations")
.select("*");
// RLSによりデータがフィルタリングされるはず
expect(error).toBeNull();
expect(data).toEqual([]);
});
test("通常ユーザーはGitHub Appインストール情報を追加できない", async () => {
// GitHub Appインストール情報を追加しようとする
const { data, error } = await supabaseTest
.from("github_app_installations")
.insert({
organization_name: `test-org-insert-${testId}`,
organization_id: 54321,
installation_id: 98765,
});
// RLSによりアクセスが拒否されるはず
expect(error).not.toBeNull();
expect(data).toBeNull();
});
test("通常ユーザーはGitHub Appインストール情報を更新できない", async () => {
// 更新前のデータを取得(管理者権限で)
const { data: beforeData } = await supabaseTestAdmin
.from("github_app_installations")
.select("*")
.eq("organization_id", testInstallation.organization_id)
.single();
expect(beforeData).not.toBeNull();
// GitHub Appインストール情報を更新しようとする
const { error } = await supabaseTest
.from("github_app_installations")
.update({ installation_id: 99999 })
.eq("organization_id", testInstallation.organization_id);
// エラーは発生しないはず
expect(error).toBeNull();
// 更新後のデータを取得(管理者権限で)
const { data: afterData } = await supabaseTestAdmin
.from("github_app_installations")
.select("*")
.eq("organization_id", testInstallation.organization_id)
.single();
// データが更新されていないことを確認
expect(afterData).not.toBeNull();
expect(afterData?.installation_id).toBe(testInstallation.installation_id);
});
test("通常ユーザーはGitHub Appインストール情報を削除できない", async () => {
// 削除前にデータが存在することを確認(管理者権限で)
const { data: beforeData } = await supabaseTestAdmin
.from("github_app_installations")
.select("*")
.eq("organization_id", testInstallation.organization_id);
expect(beforeData).not.toBeNull();
expect(beforeData?.length).toBeGreaterThan(0);
// GitHub Appインストール情報を削除しようとする
const { error } = await supabaseTest
.from("github_app_installations")
.delete()
.eq("organization_id", testInstallation.organization_id);
// エラーは発生しないはず
expect(error).toBeNull();
// 削除後もデータが存在することを確認(管理者権限で)
const { data: afterData } = await supabaseTestAdmin
.from("github_app_installations")
.select("*")
.eq("organization_id", testInstallation.organization_id);
// データが削除されていないことを確認
expect(afterData).not.toBeNull();
expect(afterData?.length).toBeGreaterThan(0);
expect(afterData?.[0].organization_id).toBe(
testInstallation.organization_id
);
});
});
このテストでは、以下のポイントに注目してください:
- テスト前の準備:
- テスト用ユーザーの作成
- テスト用データの挿入(管理者権限で)
- 各テスト前にユーザーとしてログイン
- RLS の検証方法:
- 参照テスト:データが空配列として返されることを確認
- 追加テスト:エラーが発生することを確認
- 更新テスト:操作前後でデータが変わっていないことを確認
- 削除テスト:操作前後でデータが残っていることを確認
特に、更新と削除のテストでは、エラーが発生しないにもかかわらず、実際には操作が行われていないことを確認する必要があります。これは、RLS の特性上、操作が拒否されてもエラーは返されないためです。
まとめ:RLS の安全性は「テスト戦略」で決まる
Supabase の RLS は強力なセキュリティ機能ですが、その効果は正しい設定と継続的なテストによって初めて保証されます。
本記事では以下のポイントを紹介しました:
- RLS の設定ミスは深刻なリスクになりうる(データ漏洩、改ざん)
- 手動テストでは限界があるため、自動化が不可欠
RLS のセキュリティは「設定しただけ」では守れません。
「設定した上で、それが正しく機能しているかを継続的に確認する」ことこそが、本当のセキュリティです。