VitestをつかってSupabaseのRow Level Security(RLS)のPolicyをテストする
こんにちは、株式会社Berryの浅沼です。
先日公開したブログ、『Supabaseでアプリをリリースする前に確認すること』の中で、RLSの有効化について触れさせていただきました。Berryでは、RLSに設定したPolicyを検証するテストをVitestでコード化することも進めています。今回は、VitestでRLSをテストするために行っている自分たちの方法を紹介したいと思います。
背景とモチベーション
昨年、すでに動いているサービスのRLSを見直し、全面的にPolicyを変更したことがあります。そのときは、手作業で設定したPolicyの挙動を確認していて・・・すべてを網羅できているか?、と、なかなか検証を確実にするのに四苦八苦した覚えがあります。そのとき、RLSのテストをコード化して、繰り返し実施、及び、自動化しようというモチベーションを得ました。
セキュアなデータベース運用の要になる部分なので、開発や設定変更時に実施できるテストコードが用意できていると、開発・変更時の運用スピードが格段に異なります。
Vitest + Supabase CLI を活用する
Berryでは、Vue + TypeScript + Supabaseを活用しています。Vueで作られたアプリケーションのユニットテストはVitestを利用して実装しています。その中で、Supabaseのデータベースを利用するFunctionについては、Vitest + Supabase CLIを利用してテストを自動実行しています。SupabaseのAuthを通してテストする実装方法を得ていたので、RLSのテストについても使い慣れた環境の中で実装することにしました。
RLSをテストするアプローチ
例として、ログインユーザーごとのRoleを想定してみます。各Roleには、以下のようなPolicyが設定されているとします。
Editor: 所属する組織と一致したレコードへのSELECT/INSERT/UPDATEの権限がある。DELETE権限はなし。
Viewer: 所属する組織と一致したレコードへのSELECT権限のみがある。他の権限はなし。
所属する組織と一致したレコード の部分は、マルチテナントなアプリケーションの場合には、よくある設定ではないでしょうか。
テストでは、
- 所属組織の一致 or 不一致の場合
- SELECT, INSERT, UPDATE, DELETE の各操作のケース
を検証できるようにテストコードを実装します。
Migrationとseed.sqlにテストデータを用意
Supabase CLI用にRLSを設定するMigrationとテストデータをseed.sqlに用意しておきます。
Supabase CLIの活用には、MigrationによるDB構成のコード化とseed.sqlを利用した初期データやテスト用データが欠かせません。
Supabase CLIをつかってローカル環境とリモート環境をオペレーションする方法は、公式のドキュメントを参照するのが一番良いと思います。
テストコードのディレクトリ構成
RLSのspecは、Roleごとにディレクトリを分けて、その下に各テーブルごとのspecを配置しています。これは、Github Actionsをつかった自動テストのときに、Role単位で並列実行するために分けています。
テーブルの数が多くなってくると、直列での実行は時間が長くなっていくため、並列で実行し易い単位でグループ化しておくのが便利です。
- src
- spec
- RLS
- Editor
- Examples.spec.ts
- Viewer
- Examples.spec.ts
Editor Roleのテストコードを書いてみる
RLSのテストをするときには、Vitestのコードの中で、テスト対象のRoleを持つユーザーでログインした状態をつくるところから始めます。ログイン情報はgitignoreしている.env.localや環境変数に記載します。
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { supabase } from "../../../supabase";
const testUserEmail = import.meta.env.VITE_TEST_EDITOR_EMAIL;
const testPassword = import.meta.env.VITE_TEST_EDITOR_PASSWORD;
describe("ExamplesRLS", () => {
beforeAll(async () => {
// テスト実行前にログインしておく
await supabase.auth.signInWithPassword({
email: testUserEmail,
password: testPassword
});
});
afterAll(async () => {
// テスト実行後にログアウト
await supabase.auth.signOut();
});
describe("Allow Editor Select Access Own Organization", () => {
it("所属OrgazationのレコードをSELECTできること", async () => {
const organizationId = "my_organization";
const result = await supabase.from("examples")
.select("*")
.eq("organization_id", organizationId);
expect(result.error).toBeNull();
expect(result.data?.length).toBeGreaterThan(0);
});
});
describe("Disallow Editor Select Access Other Organization", () => {
it("他OrgazationのレコードをSELECTできないこと", async () => {
const organizationId = "other_organization";
const result = await supabase.from("examples")
.select("*")
.eq("organization_id", organizationId);
expect(result.error).toBeNull();
expect(result.data).toEqual([]);
});
});
まずは、所属する組織と一致したレコードへのSELECTができることを検証するテストコードを実装してみました。成功すれば、1件以上のレコードが取得できることを想定してアサーションを記述しています。
その次は、他OrgazationのレコードをSELECTできないことを検証しています。この場合は、取得したレコードが1件もないことがアサーションの内容になっています。
この要領で、INSERT/UPDATE/DELETEを加えて行きます。
describe("Allow Editor Insert Access Own Organization", () => {
it("所属OrgazationレコードをINSERTできること", async () => {
const organizationId = "my_organization";
const title = "insert-test-rls-my-organization";
const result = await supabase
.from("examples")
.insert({
organization_id: organizationId,
title: title,
})
.select();
expect(result.error).toBeNull();
expect(result.data?.length).toBeGreaterThan(0);
});
});
describe("Disallow Editor Insert Access Other Organization", () => {
it("他OrgazationのレコードをINSERTできないこと", async () => {
const organizationId = "other_organization";
const title = "insert-test-rls-other-organization";
const result = await supabase
.from("examples")
.insert({
organization_id: organizationId,
title: title,
})
.select();
expect(result.error?.message).toBe(`new row violates row-level security policy for table "examples"`);
expect(result.data).toBeNull();
});
});
describe("Allow Editor Update Access Own Organization", () => {
it("所属OrgazationのレコードをUPDATEできること", async () => {
const id = "id-my-organization-record";
const result = await supabase
.from("examples")
.update({
title: "update-test-rls-my-organization"
})
.eq("id", id)
.select();
expect(result.error).toBeNull();
expect(result.data?.length).toBeGreaterThan(0);
});
});
describe("Disallow Editor Update Access Other Organization", () => {
it("他OrgazationのレコードをUPDATEできないこと", async () => {
const id = "id-other-organization-record";
const result = await supabase
.from("examples")
.update({
title: "update-test-rls-other-organization"
})
.eq("id", id)
.select();
expect(result.error).toBeNull();
expect(result.data).toEqual([]);
});
});
describe("Disallow Editor Delete Access", () => {
it("レコードをDELETEできないこと", async () => {
const id = "id-my-organization-record";
const result = await supabase
.from("examples")
.delete()
.eq("id", id)
.select();
expect(result.error).toBeNull();
expect(result.data).toEqual([]);
});
});
INSERTの場合は、RLSのPolicyに関するエラーメッセージが返ってきます。しかし、他はRLS Policyに関するエラーメッセージは返ってこないので、レコードの取得結果から判断することになります。そのため、seed.sqlには、Policyがチェックできるようにテストデータを準備しておくことが重要です。
Viewer Roleの場合も同じアプローチでテストを実装していきます。異なるのは、INSERT/UPDATEができないことを検証するようにするところだけです。
Policyを変更するときは、レコードの抽出条件や各操作の成功・失敗を定義し直して、テストコードを動かしながら変更を検証していきます。
Github Actionsで自動実行
VitestでRLSのテストを実装・実行できるようになったら、もちろん、Github Actionsでもプルリクエストごとに実行するなど、自動実行を開発プロセスに組み込めます。
name: Run RLS Vitest
on:
pull_request:
branches:
- main
- release/*
- development
defaults:
run:
shell: bash
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
VITE_SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
VITE_TEST_VIEWER_EMAIL: ${{ secrets.TEST_VIEWER_EMAIL }}
VITE_TEST_VIEWER_PASSWORD: ${{ secrets.TEST_VIEWER_PASSWORD }}
VITE_TEST_EDITOR_EMAIL: ${{ secrets.TEST_EDITOR_EMAIL }}
VITE_TEST_EDITOR_PASSWORD: ${{ secrets.TEST_EDITOR_PASSWORD }}
jobs:
test-rls:
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
matrix:
role: [Editor, Viewer]
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
- name: Start Supabase
run: supabase start
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "yarn"
- name: Install dependencies
run: yarn install
- name: Run Vitest RLS for ${{ matrix.role }}
run: yarn run vitest src/spec/RLS/${{ matrix.role }}
他のRoleもテストに加えるときは、[Editor, Viewer]を増やしていけば、設定したRole分のテストを並行で実行します。
現在の手元の開発では、約30テーブルのテストを実行しており、一つのRoleあたり2分程度の実行時間になっています。
まとめ
RLSのテストについて、Supabase CLIを利用し、Authを通したインテグレーションテストの方法について紹介させていただきました。
RLSの検証は、後回しにするほど面倒になる気がします。テーブルを追加、RLSを設定した時点でPolicyの挙動確認をコード化できていれば、後々に大きな手間をかけずに検証することができます。また、RLS設定・運用を持続可能にするためにもテストのコード化は重要だと考えています。
Supabase活用のなかでもセキュリティの要となるRLS、その理解とテストが深まれば、さらにSupabaseを使うことが楽しくなると思います。
今後の展望
テストコードを書いていくと、他のRoleやテーブルでも同様の記述が多くなることに気がつくと思います。正直、SELECT/INSERT/UPDATE/DELETEの似たような検証を量産するのは、ちょっとダルくなります。
そこで、なにかしらのコマンド等でRLSのテストコードを生成したり、基本的なことだけでもScaffoldすることを検討しています。AI等を使うことでも、かなりの手間削減が期待できると思います。
そして、StorageにもRLSがあるので、その部分にもテストを届かせたい気持ちです。
応募待っています
幹部候補エンジニア募集中です!一緒に、プロダクトもコードもテストも発展させたい方、大歓迎です。
医療業界での経験や3Dの知見は問いません。Berryの考え方や製品に少しでも興味が持てた方はお気軽に応募下さい。
Discussion