メインコンテンツへスキップ
友田 陽大
データベース・RLS
Supabase
RLS
PostgreSQL
セキュリティ
アーキテクチャ設計

Supabase RLSでRBAC(ロールベースアクセス制御):custom claims・authorize()関数・app_metadataで役割と権限を設計する

Supabase(PostgreSQL)でRBACをRLSに統合する公式パターンを実装ガイド化。app_role/app_permission列挙とuser_roles/role_permissionsテーブル、custom_access_token_hookでJWTにrole claimを載せる、security definerなauthorize()関数で権限を判定、RLSポリシーからauthorize()を呼ぶ、ロール変更後のトークン更新とapp_metadata vs user_metadataの安全な使い分けまで、公式準拠の実コードで解説します。

公開日
読了時間
10分
著者
友田 陽大
シェア

「テナント分離はRLSでできた。でも同じテナントの中で『管理者だけが削除できる』『閲覧者は読むだけ』をどう表現するか?」——マルチテナントSaaSを作り込むと、必ずこの壁に当たります。これは**RBAC(Role-Based Access Control=ロールベースアクセス制御)**の領域で、「どの行か(所有・テナント)」とは直交する「何をしてよいか(役割・権限)」の話です。

重要なのは、この2つを混ぜずに重ねること。テナント分離は tenant_id で、RBACは「役割→権限」で。両者を同じポリシーに雑に詰め込むと、読めない・直せない・漏れるポリシーになります。

この記事は、SupabaseでRBACをRLSに統合する公式パターン——custom_access_token_hook でJWTに役割を載せ、authorize() 関数で権限を判定し、RLSポリシーから呼ぶ——を、つまずきやすい「ロール変更後のトークン更新」「app_metadatauser_metadata の使い分け」まで含めて実装ガイド化します。題材はリアルタイム試合記録アプリ(選手・チーム管理者・スコアラー・スカウト・運営が同じデータを別の見え方で触る、69テーブル全RLS・280ポリシー)。内容はSupabase公式: Custom Claims & RBACRLS公式(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 > Hookscustom_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_hookuser_roleフック(Authサーバー)が付与✅ 使ってよい

user_metadatasupabase.auth.updateUser() でユーザー自身が変更できます。ここに role: 'admin' を置いて認可すれば、ユーザーが自分を管理者に昇格できる——典型的な特権昇格の穴です。認可には必ず改ざん不可のクレーム(本記事のhook由来 user_roleapp_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つ足したら別の役割の境界が崩れた」が起きやすい領域——テストだけが安全な変更を保証します。

一次情報(必ず最新を確認してください)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

お困りごとはありませんか?

設計から実装・運用まで、一人 × 生成AI で伴走します

この記事のような実装を、要件定義から本番運用まで一気通貫で。まずは30分の無料技術相談から、状況をお聞かせください。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい