Chuyển đến nội dung chính
友田 陽大
Databases & 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の安全な使い分けまで、公式準拠の実コードで解説します。

Published
Reading time
10 min read
Author
友田 陽大
Chia sẻ

「テナント分離は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つ足したら別の役割の境界が崩れた」が起きやすい領域——テストだけが安全な変更を保証します。

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

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc