Industrial-grade RBAC helper for Supabase multi-app projects (JWT role_ids + optional permission RPC + React hooks + pg manager + CLI).
npm install @sunboyoo/supabase-rbac> 工业级、多 App、多模块 RBAC(Supabase + PostgreSQL + TypeScript/React)
> 数据库 RLS 才是最终权威;前端仅用于 UI 显隐(可选),避免“前端鉴权等于安全”的误区。
---
* 功能概览
* 核心设计原则
* 安装与初始化
* Supabase Dashboard 必做配置
* 数据库部署与验证
* 前端集成(React)
* 服务端/管理端集成(Node)
* 可选增强:permission-name 语义化判定
* 最佳实践:权限命名与角色管理
* 常见问题(FAQ)
* 安全边界与威胁模型
* 版本与兼容性
---
* ✅ 多应用支持(app_id):同一 Supabase 项目支持多个业务 App/模块。
* ✅ 自动合并 global 角色:app1 + global 角色集合合并用于判定。
* ✅ 数据库强制鉴权(RLS):策略调用 rbac.has_perm(app_id, perm_name)。
* ✅ JWT 注入 role_ids(UUID):通过 Supabase Auth Custom Access Token Hook 注入。
* ✅ React 集成:RBACProvider + useRBAC()。
* ✅ Server 管理端:RBACManager(pg 直连,不依赖 PostgREST 暴露 rbac schema)。
* ✅ CLI 初始化:rbac-init 将 migrations 复制到项目 supabase/migrations。
* 🧩(可选)permission-name 前端语义化判定:通过受控 RPC 拉取“我的权限名列表”。
---
* 前端的 hasRole() / hasPermission() 只能用于 UI 显隐或交互优化。
* 真正的安全来自业务表的 RLS:没有权限时数据库返回 403/0 rows。
* 强烈建议:Supabase Dashboard → API → Exposed Schemas 中移除 rbac / hooks。
* 这样可最大化降低:
* 权限字典枚举
* 意外暴露 RBAC 表
* 运维期间策略漂移导致的风险
* JWT 中存的是 role UUID,而不是 role name:
* 角色重命名不会影响鉴权
* 不依赖 role name join
* token 体积小
---
``bash`
pnpm add @sunboyoo/supabase-rbac
在你的项目根目录执行:
`bash`
npx rbac-init或
pnpm exec rbac-init
常用参数:
`bash`
npx rbac-init --dest ./supabase/migrations
npx rbac-init --dry-run
npx rbac-init --force
npx rbac-init --verbose
> ✅ 推荐:默认不覆盖已有文件(避免误伤本地改动)。
> 如需覆盖,请显式 --force。
---
路径:Supabase Dashboard → API → Exposed Schemas
* ✅ 保留:public(以及你业务需要的 schema)rbac
* ❌ 移除:、hooks
> 这是安全关键点:避免 PostgREST 暴露 RBAC 表/视图。
路径:Dashboard → Auth → Hooks → Custom access token hook
选择:
* Function:hooks.custom_access_token_hook
此 Hook 会在登录/refresh token 时执行,把每个 app 的 role_ids 注入到 JWT claims。
---
`bash`
supabase db push
用户登录后,在前端拿到 session 的 access_token 并 decode(仅用于验证):
你应能看到类似结构(示意):
`json`
{
"app_metadata": {
"role_ids": {
"app1": ["uuid-..."],
"global": ["uuid-..."]
}
}
}
> 若看不到 role_ids:通常是 Hook 没绑定 或用户还未刷新 session。
当你在管理端更新用户角色后:
`ts`
await supabase.auth.refreshSession();
否则用户 token 仍是旧 claims。
在业务表上配置 RLS 策略调用 rbac.has_perm():
`sql
-- Example business table (app1-private) and RLS policies.
-- In real projects, you usually create one migration per module/table group.
create table if not exists public.orders (
id uuid primary key default gen_random_uuid(),
created_at timestamptz not null default now(),
customer text,
amount_cents int not null default 0
);
alter table public.orders enable row level security;
drop policy if exists app1_order_select on public.orders;
create policy app1_order_select
on public.orders
for select
using (
(select rbac.has_perm('app1', 'order.read'))
);
drop policy if exists app1_order_insert on public.orders;
create policy app1_order_insert
on public.orders
for insert
with check (
(select rbac.has_perm('app1', 'order.create'))
);
drop policy if exists app1_order_update on public.orders;
create policy app1_order_update
on public.orders
for update
using (
(select rbac.has_perm('app1', 'order.update'))
);
drop policy if exists app1_order_delete on public.orders;
create policy app1_order_delete
on public.orders
for delete
using (
(select rbac.has_perm('app1', 'order.delete'))
);
`
要点:
* 用 (select rbac.has_perm(...)) 包裹:诱导 PostgreSQL 优化器做 InitPlan(一次计算)has_perm
* 即使未完全 InitPlan, 内部也有事务级缓存,避免 N+1 查询
* 客户端正常 CRUD;无权限时:SELECT 返回 0 行,INSERT/UPDATE/DELETE 返回 403
---
`tsx
import { RBACProvider } from "@sunboyoo/supabase-rbac/client";
import { supabase } from "./supabaseClient";
export function Root() {
return (
);
}
`
`tsx
import { useRBAC } from "@sunboyoo/supabase-rbac/client";
export function Toolbar() {
const {
isAuthenticated,
isRBACReady,
roleIds,
hasRole,
refresh
} = useRBAC();
if (!isRBACReady) return null;
const ADMIN_ROLE_ID = "00000000-0000-0000-0000-000000000001"; // 示例:建议来自你的常量映射
return (
{hasRole([ADMIN_ROLE_ID]) && (
)}
$3
你有 3 种推荐方式:
1. Seed 输出常量文件(推荐)
2. 管理端查询后写入配置/环境变量
3. 业务端后端 API 下发(适用于动态系统)
---
服务端/管理端集成(Node)
> RBACManager 采用 pg 直连数据库,不依赖 PostgREST 暴露
rbac schema,符合安全最佳实践。$3
`ts
import { RBACManager } from "@sunboyoo/supabase-rbac/server";const rbac = new RBACManager({
connectionString: process.env.SUPABASE_DB_URL!, // 推荐使用 Supabase 提供的数据库连接串
rbacSchema: "rbac" // 可省略
});
`$3
`ts
const appId = "app1";// 0) 确保 app 已存在(外键约束)
await rbac.createApp({ id: appId, name: "App 1" });
// 1) 创建权限字典
const permId = await rbac.createPermission({
appId,
name: "order.delete",
description: "Delete orders"
});
// 2) 创建角色
const roleId = await rbac.createRole({
appId,
name: "Admin"
});
// 3) 绑定角色-权限
await rbac.grantPermissionToRole({ appId, roleId, permissionId: permId });
// 4) 给用户分配角色
await rbac.assignRole({ userId: targetUserId, roleId });
`$3
`ts
await rbac.close();
`---
可选增强:permission-name 语义化判定
$3
* 优点:业务代码更可维护:
hasPermission('order.delete')
* 代价:增加一个 RPC 查询(你需要接受额外 DB 负载 + 一定暴露面)$3
1. 确保数据库已部署 permission-name RPC(例如
get_my_permissions)
2. Provider 开启 permissions 选项:`tsx
client={supabase}
appId="app1"
permissions={{
enabled: true,
rpcName: "get_my_permissions",
ttlMs: 60000,
nonBlocking: true
}}
>
`$3
将 RPC 放在你 已暴露的 schema(例如
public),RBAC 表仍保持隐藏:`sql
create or replace function public.get_my_permissions(target_app_id text)
returns table(permission_name text)
language sql
security definer
set search_path = ''
as $$
select distinct p.name as permission_name
from rbac.user_roles ur
join rbac.roles r on r.id = ur.role_id
join rbac.role_permissions rp on rp.role_id = r.id and rp.app_id = r.app_id
join rbac.permissions p on p.id = rp.permission_id and p.app_id = r.app_id
where ur.user_id = auth.uid()
and r.app_id in (target_app_id, 'global');
$$;grant execute on function public.get_my_permissions(text) to authenticated;
`> 注意:如需自定义
global key,请同步调整 RPC 与前端 globalAppId。使用:
`tsx
const { hasPermission, isPermissionReady } = useRBAC();
if (!isPermissionReady) return null;return hasPermission("order.delete") ? : null;
`$3
即使前端显示了按钮,最终能否 DELETE 仍由 RLS 决定。
---
最佳实践:权限命名与角色管理
$3
* 全小写
* 点号分层
* 稳定、不频繁改动
示例:
*
order.read
* order.create
* order.update
* order.delete
* payroll.export$3
*
global 是真实 app_id(不是 NULL)
* 如果 global 角色要跨 App 赋权: * 必须在
rbac.permissions(app_id='global') 中创建“同名镜像权限”---
常见问题(FAQ)
$3
* Hook 未绑定或用户未 refresh session。
* 解决:
1. Dashboard 绑定 hook
2. 用户端执行
supabase.auth.refreshSession()$3
因为你最佳实践要求 rbac schema 不暴露,而 supabase-js(PostgREST)访问不了隐藏 schema。pg 直连是最可靠路径。
$3
* 库会优雅降级:permissionNames 置空,仍可用 hasRole。
* 你应检查:
* permission-name RPC 是否已部署
* rpcName 是否匹配
* GRANT EXECUTE 是否给 authenticated
---
安全边界与威胁模型
* ✅ DB/RLS:安全权威
* ⚠️ Client gating:只做体验
* ✅ RBAC schema 不暴露:降低枚举风险
* ⚠️ 可选 RPC:增加暴露面,但控制在“仅返回自己权限”范围内
* ⚠️ 不要给不受信任用户直连 SQL 或“SQL 执行器”类 RPC;
has_perm 使用事务级缓存(set_config/current_setting`),需在受控 SQL 环境使用---
* Node:建议 18+
* React:>=16.8
* supabase-js:^2.x
* PostgreSQL:Supabase 托管 PG(带 pgcrypto)
---