「テナント分離はRLSでできた。でも同じテナントの中で『管理者だけが削除できる』『閲覧者は読むだけ』をどう表現するか?」——マルチテナントSaaSを作り込むと、必ずこの壁に当たります。これは**RBAC(Role-Based Access Control=ロールベースアクセス制御)**の領域で、「どの行か(所有・テナント)」とは直交する「何をしてよいか(役割・権限)」の話です。
重要なのは、この2つを混ぜずに重ねること。テナント分離は tenant_id で、RBACは「役割→権限」で。両者を同じポリシーに雑に詰め込むと、読めない・直せない・漏れるポリシーになります。
この記事は、SupabaseでRBACをRLSに統合する公式パターン——custom_access_token_hook でJWTに役割を載せ、authorize() 関数で権限を判定し、RLSポリシーから呼ぶ——を、つまずきやすい「ロール変更後のトークン更新」「app_metadata と user_metadata の使い分け」まで含めて実装ガイド化します。題材はリアルタイム試合記録アプリ(選手・チーム管理者・スコアラー・スカウト・運営が同じデータを別の見え方で触る、69テーブル全RLS・280ポリシー)。内容はSupabase公式: Custom Claims & RBAC・RLS公式(2026年6月時点)に忠実です。
前提:RLSの基礎はRLS入門、テナント分離(行の所有)の設計はマルチテナント本番設計で扱いました。本記事は「役割と権限」に集中します。
1. 設計の地図:役割→権限→ポリシーの3段
RBACをRLSに統合するとは、次の3段を設計することです。順番に意味があります。
ユーザー ──(user_roles)──▶ 役割(role)
│
▼ (role_permissions)
権限(permission) 例: 'channels.delete'
│
▼ (RLS policy が authorize('channels.delete') を呼ぶ)
その操作を許可 / 拒否
ポイントは**「役割」と「権限」を分離すること。ユーザーに直接「削除権限」を付けるのではなく、ユーザーに役割を、役割に権限**を割り当てる。こうすると「moderatorに新しい権限を1つ足す」が1行のINSERTで全moderatorに反映され、ポリシー側は一切触らずに済みます(ETC:変更が一箇所に閉じる)。
なぜテナント分離と分けるのか:
tenant_id = ...(どの行か)とauthorize('messages.delete')(何をしてよいか)は変わる理由が違う(SRP)。テナント境界は所有で決まり、権限は組織内の役職で決まる。ポリシーではusing (tenant_id = current_tenant() and (select authorize('messages.delete')))のようにANDで重ねる——混ぜるのではなく、層として積むのが正解です。
2. 役割と権限を2つのテーブルに正規化する
まず列挙型で役割と権限を定義し、2つのテーブルに正規化します(Supabase公式)。列挙型にするのは、タイプミスやゴミ値をDBが弾いてくれる(型安全)からです。
-- 役割と権限を列挙型で固定する(不正な値をDBが拒否する)
create type public.app_permission as enum ('channels.delete', 'messages.delete');
create type public.app_role as enum ('admin', 'moderator');
-- ユーザー → 役割(多対多を許す)
create table public.user_roles (
id bigint generated by default as identity primary key,
user_id uuid references auth.users on delete cascade not null,
role app_role not null,
unique (user_id, role)
);
-- 役割 → 権限
create table public.role_permissions (
id bigint generated by default as identity primary key,
role app_role not null,
permission app_permission not null,
unique (role, permission)
);
この2表が「誰がどの役割か」「役割が何をできるか」の**単一の真実源(SSoT)**になります。新しい権限を増やすときは app_permission に値を足し、role_permissions に1行入れるだけ。
3. 発行時にJWTへ役割を載せる:custom_access_token_hook
判定のたびに user_roles を引くのは遅い。そこでSupabaseのAuth Hookを使い、トークン発行のタイミングで役割をJWTのクレームに焼き込みます。一度載せれば、以降の全リクエストでDBを引かずに役割が分かります。
-- トークン発行直前に呼ばれ、claimsに user_role を追加する
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role public.app_role;
begin
select role into user_role
from public.user_roles
where user_id = (event->>'user_id')::uuid;
claims := event->'claims';
if user_role is not null then
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
else
claims := jsonb_set(claims, '{user_role}', 'null');
end if;
event := jsonb_set(event, '{claims}', claims);
return event; -- 変更後のeventを返すとそのclaimsでトークンが発行される
end;
$$;
このフックは Auth サーバー(supabase_auth_admin)だけが実行でき、一般ユーザーからは見えない・実行できないように権限を絞ります。ここを緩めると、ユーザーが自分の役割を細工できてしまいます。
grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
grant all on table public.user_roles to supabase_auth_admin;
revoke all on table public.user_roles from authenticated, anon, public;
最後にダッシュボードの Authentication > Hooks で custom_access_token_hook を選んで有効化します。これで発行されるJWTに user_role クレームが載ります。
4. 権限を判定する:authorize() 関数
役割がJWTに載ったので、**「この役割は、この権限を持つか」**を判定する関数を作ります。security definer(=RLSをバイパスして role_permissions を引ける)で、search_path='' を必ず付けます。
create or replace function public.authorize(
requested_permission app_permission
)
returns boolean
language plpgsql
stable
security definer
set search_path = '' -- search_path注入を塞ぐ(security definerの必須作法)
as $$
declare
bind_permissions int;
user_role public.app_role;
begin
-- JWTに焼き込まれた役割を読む(DBのuser_rolesは引かない=速い)
select (auth.jwt() ->> 'user_role')::public.app_role into user_role;
-- その役割がその権限を持つ行数を数える
select count(*) into bind_permissions
from public.role_permissions
where role_permissions.permission = requested_permission
and role_permissions.role = user_role;
return bind_permissions > 0;
end;
$$;
ポイントは、役割はJWT(auth.jwt())から、権限マッピングは role_permissions から引くハイブリッドであること。役割は変化が緩いのでJWTに載せて高速化し、「役割→権限」の対応は将来増えるのでテーブルに置いて柔軟に保つ——この分担が効きます。
5. RLSポリシーから authorize() を呼ぶ
あとはポリシーで authorize() を呼ぶだけです。(select ...) で包むのは、行ごとの再評価を防ぐパフォーマンス最適化です(理由)。
-- admin/moderator のうち 'channels.delete' 権限を持つ役割だけが削除できる
create policy "Allow authorized delete on channels"
on public.channels for delete
to authenticated
using ( (select authorize('channels.delete')) );
create policy "Allow authorized delete on messages"
on public.messages for delete
to authenticated
using ( (select authorize('messages.delete')) );
テナント分離と重ねる(実戦形)
実際のSaaSでは、RBACは単独では使いません。テナント境界(所有)とANDで重ねます。これが「混ぜずに積む」の具体形です。
-- 「同じテナントの行」かつ「messages.delete 権限を持つ役割」だけが削除できる
create policy "Tenant-scoped authorized delete"
on public.messages for delete
to authenticated
using (
tenant_id = (select public.current_tenant_id()) -- どの行か(所有/テナント)
and (select authorize('messages.delete')) -- 何をしてよいか(役割/権限)
);
2つの関心事が and で並ぶことで、ポリシーが読んで意図が分かる形になります。テナント境界を絶対に外したくないなら、テナント条件側を as restrictive の別ポリシーに切り出してANDで固定するのも有効です。
6. 落とし穴1:認可に使ってよいクレームを取り違えない
RBACで最も危険な事故が、ユーザーが書き換えられる場所を認可の根拠にすることです。Supabaseのユーザーメタデータには2種類あります(Supabase公式)。
| メタデータ | 誰が書き換えられるか | 認可に使う? |
|---|---|---|
raw_user_metadata(user_metadata) | ユーザー自身が更新可能 | ❌ 絶対に使わない |
raw_app_metadata(app_metadata) | サーバー/管理者のみ(ユーザー不可) | ✅ 使ってよい |
custom_access_token_hook の user_role | フック(Authサーバー)が付与 | ✅ 使ってよい |
user_metadata は supabase.auth.updateUser() でユーザー自身が変更できます。ここに role: 'admin' を置いて認可すれば、ユーザーが自分を管理者に昇格できる——典型的な特権昇格の穴です。認可には必ず改ざん不可のクレーム(本記事のhook由来 user_role や app_metadata)だけを使ってください。
役割が滅多に変わらないなら、フックを使わず app_metadata に直接持たせる軽量版も選べます。
-- app_metadata.role を使う軽量版(hookを立てない選択肢)
create policy "Admins can read all"
on public.audit_logs for select
to authenticated
using ( (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );
app_metadata の更新はサーバー側(service_role)から auth.admin.updateUserById() で行い、ユーザーに触らせないこと。
7. 落とし穴2:JWTはスナップショット——ロール変更は即時反映されない
最後に、運用で必ず効いてくる性質です。JWTは発行時点のスナップショット。フックは新しく発行されるトークンにしか効きません。だから——
- ユーザーを
moderatorからadminに昇格しても、手元の(まだ失効していない)トークンはmoderatorのまま。 - 逆に権限を剥奪しても、トークンが失効するまで(リフレッシュまで)古い権限が残る。
これは性能(DBを引かない)と引き換えのトレードオフです。対処は権限の性質で分けます。
| 権限の性質 | 方式 | 反映タイミング |
|---|---|---|
| 変化が緩い(職位・プラン) | JWTクレーム(hook/app_metadata) | 次のトークン更新時 |
| 即時剥奪が必須(BAN・退会・解雇) | RLSでDBの user_roles を直接引く | 即時 |
「昇格は次回ログインで反映でOK、剥奪は即時」という要件なら、昇格はJWT・剥奪はDBチェックを併用します。アプリ側で即時反映したいときは、ロール変更後に supabase.auth.refreshSession()(またはサインアウト→再認証)でトークンを更新させ、新しいクレームを取り直させます。
// ロール変更を即座にクライアントへ反映したいとき、トークンを更新する
await supabase.auth.refreshSession();
「ロールを変えたのに反映されない」というRBACの定番のハマりは、ほぼこのトークン鮮度の理解で解けます。
まとめ:役割と権限は「分けて、重ねる」
- RBACは役割→権限→ポリシーの3段。テナント分離(どの行か)とは直交する関心事で、混ぜずにANDで重ねる。
- 役割は
user_roles、権限はrole_permissionsに正規化。custom_access_token_hookでJWTに役割を焼き込み、判定のたびにDBを引かない。 - **
authorize()(security definer・search_path='')**で役割と権限を突き合わせ、ポリシーは(select authorize('...'))で呼ぶ。 - 認可に使ってよいのは改ざん不可のクレームだけ。
user_metadataを認可に使わない(特権昇格の穴)。 - JWTはスナップショット。即時剥奪が要る権限はDBチェック、変化の緩い権限はクレームと使い分け、必要なら
refreshSession()で更新する。
役割設計まで含めて固めたら、最後はpgTAPで各役割の許可/拒否をテストし、CIで退行を止めてください。RBACは「役割を1つ足したら別の役割の境界が崩れた」が起きやすい領域——テストだけが安全な変更を保証します。