はじめに:よくある詰まりポイント
Supabaseで複数ユーザーが関わるアプリを作っていると、こんな壁にぶつかることがある。
「RLSを設定して、自分のグループのデータは守れた。でも連携相手のグループのデータを読みたい場合、どうすればいい?」
チームコラボレーションツール、家族共有アプリ、パートナー企業とのデータ共有——ユースケースは様々だが、RLSで守りつつ、特定の相手にだけ見せるという要件は非常によく出てくる。
筆者は育休中に夫婦でデータを共有する資産管理アプリをSupabaseで開発する中でこの問題に何度もぶつかった。試行錯誤の末にたどり着いた設計パターンを、汎用化して紹介する。
前提:グループベースの基本構造
この記事で想定するテーブル構造を示す。
-- グループ(チーム・世帯・テナントなど)
groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)
-- グループメンバー
group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID REFERENCES groups(id),
user_id UUID REFERENCES auth.users(id),
role TEXT DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT now()
)
-- データテーブル(items, documents, records など)
items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID REFERENCES groups(id),
name TEXT NOT NULL,
...
)
group_idが全テーブルの共通キー。これを軸にRLSを設計する。
設計判断①:グループは「分けて参照」する
やりがちな失敗:2ユーザーを同じグループに入れる
「AさんとBさんがデータを共有したい」という要件を聞いて、最初に思いつくのが「2人を同じグループに入れる」設計だ。しかしこれは後で痛い目を見る。
問題1:権限の粒度が荒くなる
同一グループのメンバーは互いのデータをすべて見られる。「Aのこのデータは共有したいが、あのデータは見せたくない」という要件が後から来ると、RLSが複雑になる。
問題2:グループ分離(連携解除)のコストが高い
チームを解散する、連携を解除する——グループを分けていないと、ユーザーをどの新しいグループに移すか、既存データをどう扱うかが複雑になる。
問題3:招待フローが設計に絡み合う
「先に一方がグループを作り、後から相手を招待する」フローが複雑になる。グループ作成と招待受諾が非同期になると、状態管理が難しくなる。
正解:別グループを作り、互いのIDを参照する
-- 連携状態を管理するテーブル
group_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID REFERENCES groups(id),
linked_group_id UUID REFERENCES groups(id),
linked_at TIMESTAMPTZ DEFAULT now(),
-- 招待コード(一時的)
invite_code TEXT,
invite_code_expires_at TIMESTAMPTZ,
UNIQUE(group_id)
)
招待コードはgroup_linksテーブルに一時的に保存し、有効期限を設ける。相手が受け入れたら両方のレコードに互いのlinked_group_idを書き込んで連携完了。連携解除はlinked_group_idをNULLにするだけだ。
| 同一グループ統合 | 別グループ参照 | |
|---|---|---|
| 権限の粒度 | 粗い(全共有) | 細かい(選択的共有) |
| 連携解除 | 複雑 |
linked_group_idをnullに |
| 招待フロー | 設計に依存 | 独立して設計できる |
| 将来の拡張 | 難しい | 参照先を変えるだけ |
設計判断②:RLSヘルパー関数で「ポリシーを薄く」する
Before:各ポリシーにサブクエリを直書きする
CREATE POLICY "read own items"
ON items FOR SELECT
USING (
EXISTS (
SELECT 1 FROM group_members
WHERE group_members.group_id = items.group_id
AND group_members.user_id = auth.uid()
)
);
CREATE POLICY "read own documents"
ON documents FOR SELECT
USING (
EXISTS (
SELECT 1 FROM group_members
WHERE group_members.group_id = documents.group_id
AND group_members.user_id = auth.uid()
)
);
After:ヘルパー関数に集約する
CREATE OR REPLACE FUNCTION is_group_member(p_group_id UUID)
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM group_members
WHERE group_id = p_group_id
AND user_id = auth.uid()
);
$$ LANGUAGE sql SECURITY INVOKER STABLE;
CREATE POLICY "read own items"
ON items FOR SELECT
USING (is_group_member(group_id));
CREATE POLICY "read own documents"
ON documents FOR SELECT
USING (is_group_member(group_id));
is_group_member()だけを修正すれば全テーブルの挙動が変わる。SECURITY INVOKERを指定しているのがポイントで、関数を呼び出したユーザーの権限で実行されるためauth.uid()が正しく解決される。
設計判断③:Security Definer RPCで「制御された窓口」を作る
なぜRLSだけでは「他グループのデータが読めない」のか
RLSは「自分がそのグループのメンバーかどうか」を判定する。自分が属していないグループのデータは原則読めない。
-- これは機能しない(RLSが弾く)
SELECT * FROM items WHERE group_id = '連携相手のgroup_id';
アンチパターン:RLSポリシーをORで拡張する
-- 悪い例:ポリシーが肥大化する
CREATE POLICY "read own or linked items"
ON items FOR SELECT
USING (
is_group_member(group_id)
OR EXISTS (
SELECT 1 FROM group_links gl
WHERE gl.group_id IN (
SELECT gm.group_id FROM group_members gm WHERE gm.user_id = auth.uid()
)
AND gl.linked_group_id = items.group_id
)
);
正解:Security Definer RPCを「制御された窓口」として使う
CREATE OR REPLACE FUNCTION get_linked_group_items()
RETURNS TABLE (
id UUID,
name TEXT,
created_at TIMESTAMPTZ
) AS $$
DECLARE
v_linked_group_id UUID;
BEGIN
SELECT gl.linked_group_id INTO v_linked_group_id
FROM group_links gl
INNER JOIN group_members gm ON gm.group_id = gl.group_id
WHERE gm.user_id = auth.uid()
LIMIT 1;
IF v_linked_group_id IS NULL THEN
RETURN;
END IF;
RETURN QUERY
SELECT i.id, i.name, i.created_at
FROM items i
WHERE i.group_id = v_linked_group_id
AND i.is_visible_to_linked_groups = true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
| RLSにOR追加 | Security Definer RPC | |
|---|---|---|
| ポリシーの複雑さ | 高(肥大化) | 低(関数内に封じ込め) |
| 返すカラムの制御 | 難しい | 自由に指定可 |
| 将来の変更 | 全ポリシーを確認 | 関数だけ修正 |
| セキュリティ | リスクあり | 高(明示的制御) |
Security Definer RPCは「RLSの穴」ではなく「制御された窓口」
SECURITY DEFINERはRLSをバイパスするが、関数内のauth.uid()で呼び出し元を必ず検証する。「誰が何を取れるか」を明示的に定義できる分、RLSをORで複雑化させるよりも安全と言える。
設計判断④:部分公開フラグは初期設計に組み込む
ALTER TABLE items
ADD COLUMN is_visible_to_linked_groups BOOLEAN NOT NULL DEFAULT true;
「全部見せていい」ならデフォルトtrueのまま使えばいい。「見せたくないものだけ個別にfalseにする」運用が自然にできるようになる。
おまけ:Supabase固有の落とし穴2つ
Edge Functionsは変更のたびに再デプロイが必要
supabase functions deploy <function-name>を忘れずに。開発が進んだらCI/CDに組み込むのが現実解。
JWTリフレッシュを意識すると401エラーが激減する
クライアントSDKはautoRefreshTokenがデフォルトtrueだが、フォアグラウンド復帰時に明示的にセッション確認を挟むと安定する。
まとめ:設計チェックリスト
- ☐ グループはマージしない: 別グループを作り
linked_group_idで参照し合う - ☐ RLSヘルパー関数を作る:
is_group_member()に判定ロジックを集約する - ☐ Security Definer RPCを「窓口」にする: RLSポリシーをORで肥大化させない
- ☐ 部分公開フラグを最初から追加する:
is_visible_to_linked_groupsは後からALTERするより初期設計に入れる - ☐ RPCは返すカラムを絞る: 連携相手に見せていいデータだけをSELECTする
「RLSで詰まった」「別グループのデータが取れない」と感じたら、Security Definer RPCを試してみてほしい。
この記事で紹介した設計を実際に使っているアプリ
この記事で紹介した設計パターンは、育休中に開発した夫婦向け資産管理アプリ cotty Asset で実際に動いている実装をベースにしている。
もし興味があればぜひ使ってみてほしい。
cotty Asset - 夫婦の資産管理アプリ(iOS)
https://apps.apple.com/jp/app/cotty-asset/id6758410886