@sunboyoo/supabase-rbac 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/bin/rbac-init.js +122 -0
- package/dist/chunk-23MRNBGM.js +116 -0
- package/dist/chunk-ZFY3OHWO.js +54 -0
- package/dist/client.cjs +307 -0
- package/dist/client.d.cts +7 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +237 -0
- package/dist/index.cjs +209 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +18 -0
- package/dist/server.cjs +150 -0
- package/dist/server.d.cts +57 -0
- package/dist/server.d.ts +57 -0
- package/dist/server.js +6 -0
- package/dist/shared.cjs +82 -0
- package/dist/shared.d.cts +28 -0
- package/dist/shared.d.ts +28 -0
- package/dist/shared.js +14 -0
- package/dist/types-BayIl-Ha.d.cts +116 -0
- package/dist/types-BayIl-Ha.d.ts +116 -0
- package/migrations/20251229000000_create_rbac_schemas_and_extensions.sql +70 -0
- package/migrations/20251229000001_create_rbac_tables.sql +134 -0
- package/migrations/20251229000002_create_rbac_views.sql +70 -0
- package/migrations/20251229000003_create_rbac_functions_and_hook.sql +246 -0
- package/migrations/20251229000004_setup_rbac_grants_and_hardening.sql +97 -0
- package/migrations/20251229000005_setup_example_app1_orders_rls.sql +102 -0
- package/package.json +105 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/* =================================================================================================
|
|
2
|
+
ENGLISH COMMENT BLOCK (Architecture + Ops + Frontend Guidance)
|
|
3
|
+
====================================================================================================
|
|
4
|
+
|
|
5
|
+
FILE PURPOSE
|
|
6
|
+
- Creates two critical functions:
|
|
7
|
+
1) hooks.custom_access_token_hook(event jsonb)
|
|
8
|
+
- Runs during login / token refresh.
|
|
9
|
+
- Injects per-app role_ids (UUID arrays) into JWT claims at:
|
|
10
|
+
claims.app_metadata.role_ids
|
|
11
|
+
- This keeps JWT small and stable (UUID-based; role renames safe).
|
|
12
|
+
|
|
13
|
+
2) rbac.has_perm(target_app_id text, required_perm_name text)
|
|
14
|
+
- RLS-friendly authorization primitive.
|
|
15
|
+
- Implements Global-Mirror semantics:
|
|
16
|
+
Path A: target app roles ↔ target app permission_id
|
|
17
|
+
Path B: global roles ↔ global permission_id (same name mirror)
|
|
18
|
+
- Uses transaction-local GUC cache (set_config/current_setting) to mitigate N+1.
|
|
19
|
+
|
|
20
|
+
WHY THIS IS PRODUCTION-READY
|
|
21
|
+
- JWT stores role_ids UUID (not names): rename-safe and join-free checks.
|
|
22
|
+
- Deterministic and safe GUC keys via md5 (no illegal characters).
|
|
23
|
+
- Sentinel UUID caches "permission not found" to avoid repeated lookups.
|
|
24
|
+
- Defensive parsing: never 500 due to malformed claims; fail-closed to deny access.
|
|
25
|
+
|
|
26
|
+
FRONTEND / OPS GUIDANCE (Must-read)
|
|
27
|
+
- Role assignment changes require JWT refresh:
|
|
28
|
+
supabase.auth.refreshSession()
|
|
29
|
+
- Hook must be wired in Dashboard:
|
|
30
|
+
Auth → Hooks → Custom access token hook → hooks.custom_access_token_hook
|
|
31
|
+
- Do not call has_perm directly from clients. Use RLS on business tables and handle 401/403.
|
|
32
|
+
- Permission naming: use stable lowercase dotted strings (order.read, payroll.export).
|
|
33
|
+
|
|
34
|
+
==================================================================================================== */
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
/* =================================================================================================
|
|
38
|
+
中文注释区块(架构 + 运维 + 前端指引)
|
|
39
|
+
====================================================================================================
|
|
40
|
+
|
|
41
|
+
文件目的
|
|
42
|
+
- 创建两个关键函数:
|
|
43
|
+
1) hooks.custom_access_token_hook(event jsonb)
|
|
44
|
+
- 在登录/刷新 token 时执行
|
|
45
|
+
- 向 JWT 注入各 App 的 role_ids(UUID 数组),路径:
|
|
46
|
+
claims.app_metadata.role_ids
|
|
47
|
+
- JWT 只携带 role_ids,体积小、重命名不失效。
|
|
48
|
+
|
|
49
|
+
2) rbac.has_perm(target_app_id, required_perm_name)
|
|
50
|
+
- 给 RLS Policy 调用的核心鉴权原语
|
|
51
|
+
- 实现 Global-Mirror:
|
|
52
|
+
路径A:target_app 角色 ↔ target_app 权限
|
|
53
|
+
路径B:global 角色 ↔ global 同名镜像权限
|
|
54
|
+
- 事务级 GUC 缓存避免 RLS 扫描多行时的 N+1 查询。
|
|
55
|
+
|
|
56
|
+
为何可生产
|
|
57
|
+
- JWT 存 UUID(不存 name):重命名安全、判定无 join、性能极强
|
|
58
|
+
- md5 生成 GUC key:保证变量名合法,防特殊字符引发错误
|
|
59
|
+
- 哨兵 UUID 缓存“不存在权限”:避免重复查 permissions 表
|
|
60
|
+
- 防御性解析:token claims 异常也不会 500,默认拒绝(fail-closed)
|
|
61
|
+
|
|
62
|
+
前端/运维必读
|
|
63
|
+
- 用户角色变更后必须刷新 JWT:
|
|
64
|
+
supabase.auth.refreshSession()
|
|
65
|
+
- 必须在 Dashboard 绑定 Hook:
|
|
66
|
+
Auth → Hooks → Custom access token hook → hooks.custom_access_token_hook
|
|
67
|
+
- 不建议前端直接调用 has_perm;让 RLS 拦截并处理 401/403
|
|
68
|
+
- 权限名建议:全小写 + 点号分层(order.read, payroll.export)
|
|
69
|
+
|
|
70
|
+
==================================================================================================== */
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
-- Functions:
|
|
74
|
+
-- 1) hooks.custom_access_token_hook: inject role_ids into JWT claims (per app_id)
|
|
75
|
+
-- 2) rbac.has_perm: RLS-friendly permission check using:
|
|
76
|
+
-- - Role IDs from JWT (UUID)
|
|
77
|
+
-- - Permission ID pre-resolution (with sentinel caching)
|
|
78
|
+
-- - Transaction-local caching using set_config/current_setting
|
|
79
|
+
|
|
80
|
+
create or replace function hooks.custom_access_token_hook(event jsonb)
|
|
81
|
+
returns jsonb
|
|
82
|
+
language plpgsql
|
|
83
|
+
stable
|
|
84
|
+
set search_path = ''
|
|
85
|
+
as $$
|
|
86
|
+
declare
|
|
87
|
+
uid uuid;
|
|
88
|
+
role_id_map jsonb;
|
|
89
|
+
begin
|
|
90
|
+
-- Defensive: malformed user_id should not break auth flow
|
|
91
|
+
begin
|
|
92
|
+
uid := (event ->> 'user_id')::uuid;
|
|
93
|
+
exception when others then
|
|
94
|
+
return event;
|
|
95
|
+
end;
|
|
96
|
+
|
|
97
|
+
-- Inject per-app role UUID arrays:
|
|
98
|
+
-- { "app1": ["uuid..."], "global": ["uuid..."] }
|
|
99
|
+
select jsonb_object_agg(app_id, role_ids)
|
|
100
|
+
into role_id_map
|
|
101
|
+
from (
|
|
102
|
+
select
|
|
103
|
+
r.app_id,
|
|
104
|
+
jsonb_agg(distinct r.id order by r.id) as role_ids
|
|
105
|
+
from rbac.roles r
|
|
106
|
+
join rbac.user_roles ur on ur.role_id = r.id
|
|
107
|
+
where ur.user_id = uid
|
|
108
|
+
group by r.app_id
|
|
109
|
+
) t;
|
|
110
|
+
|
|
111
|
+
return jsonb_set(
|
|
112
|
+
event,
|
|
113
|
+
'{claims,app_metadata,role_ids}',
|
|
114
|
+
coalesce(role_id_map, '{}'::jsonb),
|
|
115
|
+
true
|
|
116
|
+
);
|
|
117
|
+
end;
|
|
118
|
+
$$;
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
create or replace function rbac.has_perm(target_app_id text, required_perm_name text)
|
|
122
|
+
returns boolean
|
|
123
|
+
language plpgsql
|
|
124
|
+
stable
|
|
125
|
+
security definer
|
|
126
|
+
set search_path = ''
|
|
127
|
+
as $$
|
|
128
|
+
declare
|
|
129
|
+
_res_key text := 'rbac.res.' || md5(coalesce(target_app_id,'') || '|' || coalesce(required_perm_name,''));
|
|
130
|
+
_t_roles_key text := 'rbac.rids.' || md5('t|' || coalesce(target_app_id,''));
|
|
131
|
+
_g_roles_key text := 'rbac.rids.' || md5('g|global');
|
|
132
|
+
_t_pid_key text := 'rbac.pid.' || md5('t|' || coalesce(target_app_id,'') || '|' || coalesce(required_perm_name,''));
|
|
133
|
+
_g_pid_key text := 'rbac.pid.' || md5('g|global|' || coalesce(required_perm_name,''));
|
|
134
|
+
|
|
135
|
+
_target_role_ids uuid[];
|
|
136
|
+
_global_role_ids uuid[];
|
|
137
|
+
|
|
138
|
+
_target_pid uuid;
|
|
139
|
+
_global_pid uuid;
|
|
140
|
+
|
|
141
|
+
_sentinel constant uuid := '00000000-0000-0000-0000-000000000000'::uuid;
|
|
142
|
+
_granted boolean := false;
|
|
143
|
+
|
|
144
|
+
_jwt jsonb;
|
|
145
|
+
begin
|
|
146
|
+
-- Level 1: final decision cache
|
|
147
|
+
if current_setting(_res_key, true) is not null then
|
|
148
|
+
return current_setting(_res_key) = 'true';
|
|
149
|
+
end if;
|
|
150
|
+
|
|
151
|
+
_jwt := auth.jwt();
|
|
152
|
+
|
|
153
|
+
-- Level 2: cache role_ids(uuid[]) for target app (never 500; fail-closed)
|
|
154
|
+
if current_setting(_t_roles_key, true) is null then
|
|
155
|
+
begin
|
|
156
|
+
select coalesce(array_agg((x.value)::uuid), '{}'::uuid[])
|
|
157
|
+
into _target_role_ids
|
|
158
|
+
from jsonb_array_elements_text(
|
|
159
|
+
coalesce(_jwt #> array['app_metadata','role_ids', target_app_id], '[]'::jsonb)
|
|
160
|
+
) as x(value);
|
|
161
|
+
exception when others then
|
|
162
|
+
_target_role_ids := '{}'::uuid[];
|
|
163
|
+
end;
|
|
164
|
+
perform set_config(_t_roles_key, _target_role_ids::text, true);
|
|
165
|
+
else
|
|
166
|
+
begin
|
|
167
|
+
_target_role_ids := current_setting(_t_roles_key)::uuid[];
|
|
168
|
+
exception when others then
|
|
169
|
+
_target_role_ids := '{}'::uuid[];
|
|
170
|
+
end;
|
|
171
|
+
end if;
|
|
172
|
+
|
|
173
|
+
-- Level 2b: cache role_ids(uuid[]) for global
|
|
174
|
+
if current_setting(_g_roles_key, true) is null then
|
|
175
|
+
begin
|
|
176
|
+
select coalesce(array_agg((x.value)::uuid), '{}'::uuid[])
|
|
177
|
+
into _global_role_ids
|
|
178
|
+
from jsonb_array_elements_text(
|
|
179
|
+
coalesce(_jwt #> array['app_metadata','role_ids','global'], '[]'::jsonb)
|
|
180
|
+
) as x(value);
|
|
181
|
+
exception when others then
|
|
182
|
+
_global_role_ids := '{}'::uuid[];
|
|
183
|
+
end;
|
|
184
|
+
perform set_config(_g_roles_key, _global_role_ids::text, true);
|
|
185
|
+
else
|
|
186
|
+
begin
|
|
187
|
+
_global_role_ids := current_setting(_g_roles_key)::uuid[];
|
|
188
|
+
exception when others then
|
|
189
|
+
_global_role_ids := '{}'::uuid[];
|
|
190
|
+
end;
|
|
191
|
+
end if;
|
|
192
|
+
|
|
193
|
+
-- Level 3: permission_id cache for target app (sentinel if not found)
|
|
194
|
+
if current_setting(_t_pid_key, true) is null then
|
|
195
|
+
select p.id
|
|
196
|
+
into _target_pid
|
|
197
|
+
from rbac.permissions p
|
|
198
|
+
where p.app_id = target_app_id
|
|
199
|
+
and p.name = required_perm_name;
|
|
200
|
+
|
|
201
|
+
_target_pid := coalesce(_target_pid, _sentinel);
|
|
202
|
+
perform set_config(_t_pid_key, _target_pid::text, true);
|
|
203
|
+
else
|
|
204
|
+
_target_pid := current_setting(_t_pid_key)::uuid;
|
|
205
|
+
end if;
|
|
206
|
+
|
|
207
|
+
-- Level 3b: permission_id cache for global mirror (sentinel if not found)
|
|
208
|
+
if current_setting(_g_pid_key, true) is null then
|
|
209
|
+
select p.id
|
|
210
|
+
into _global_pid
|
|
211
|
+
from rbac.permissions p
|
|
212
|
+
where p.app_id = 'global'
|
|
213
|
+
and p.name = required_perm_name;
|
|
214
|
+
|
|
215
|
+
_global_pid := coalesce(_global_pid, _sentinel);
|
|
216
|
+
perform set_config(_g_pid_key, _global_pid::text, true);
|
|
217
|
+
else
|
|
218
|
+
_global_pid := current_setting(_g_pid_key)::uuid;
|
|
219
|
+
end if;
|
|
220
|
+
|
|
221
|
+
-- Path A: target roles ↔ target permission
|
|
222
|
+
if _target_pid <> _sentinel and cardinality(_target_role_ids) > 0 then
|
|
223
|
+
select exists (
|
|
224
|
+
select 1
|
|
225
|
+
from rbac.role_permissions rp
|
|
226
|
+
where rp.app_id = target_app_id
|
|
227
|
+
and rp.permission_id = _target_pid
|
|
228
|
+
and rp.role_id = any(_target_role_ids)
|
|
229
|
+
) into _granted;
|
|
230
|
+
end if;
|
|
231
|
+
|
|
232
|
+
-- Path B: global roles ↔ global mirrored permission
|
|
233
|
+
if not _granted and _global_pid <> _sentinel and cardinality(_global_role_ids) > 0 then
|
|
234
|
+
select exists (
|
|
235
|
+
select 1
|
|
236
|
+
from rbac.role_permissions rp
|
|
237
|
+
where rp.app_id = 'global'
|
|
238
|
+
and rp.permission_id = _global_pid
|
|
239
|
+
and rp.role_id = any(_global_role_ids)
|
|
240
|
+
) into _granted;
|
|
241
|
+
end if;
|
|
242
|
+
|
|
243
|
+
perform set_config(_res_key, _granted::text, true);
|
|
244
|
+
return _granted;
|
|
245
|
+
end;
|
|
246
|
+
$$;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* =================================================================================================
|
|
2
|
+
ENGLISH COMMENT BLOCK (Architecture + Ops + Frontend Guidance)
|
|
3
|
+
====================================================================================================
|
|
4
|
+
|
|
5
|
+
FILE PURPOSE
|
|
6
|
+
- Applies security hardening (GRANT/REVOKE) to minimize PostgREST exposure:
|
|
7
|
+
- RBAC tables/views/functions are not readable by public/anon/authenticated.
|
|
8
|
+
- service_role gets RBAC management privileges (admin backend).
|
|
9
|
+
- supabase_auth_admin gets minimum read + hook execution rights.
|
|
10
|
+
- authenticated/anon get schema USAGE + has_perm EXECUTE to allow RLS evaluation.
|
|
11
|
+
|
|
12
|
+
WHY THIS MATTERS
|
|
13
|
+
- Schema isolation + REVOKE/GRANT is the most reliable long-term defense against:
|
|
14
|
+
- permission dictionary enumeration
|
|
15
|
+
- accidental API exposure
|
|
16
|
+
- policy drift / FORCE RLS operational hazards on RBAC base tables
|
|
17
|
+
|
|
18
|
+
IMPORTANT: Schema USAGE for RLS
|
|
19
|
+
- Even if has_perm is SECURITY DEFINER, some environments require caller USAGE on schema rbac
|
|
20
|
+
during RLS evaluation. We grant USAGE on schema rbac to authenticated/anon.
|
|
21
|
+
- This does NOT expose tables because SELECT remains revoked.
|
|
22
|
+
|
|
23
|
+
DEPLOYMENT CHECKLIST
|
|
24
|
+
- Also remove rbac/hooks from Supabase Dashboard → API → Exposed Schemas.
|
|
25
|
+
- Wire the Auth hook in Dashboard:
|
|
26
|
+
Auth → Hooks → Custom access token hook → hooks.custom_access_token_hook
|
|
27
|
+
|
|
28
|
+
==================================================================================================== */
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/* =================================================================================================
|
|
32
|
+
中文注释区块(架构 + 运维 + 前端指引)
|
|
33
|
+
====================================================================================================
|
|
34
|
+
|
|
35
|
+
文件目的
|
|
36
|
+
- 统一执行安全硬化(GRANT/REVOKE),最小化 PostgREST 暴露面:
|
|
37
|
+
- public/anon/authenticated 不能读 RBAC 表/视图/函数
|
|
38
|
+
- service_role 具备 RBAC 管理权限(后台管理端)
|
|
39
|
+
- supabase_auth_admin 具备最小读取权限(roles/user_roles)+ Hook 执行权限
|
|
40
|
+
- authenticated/anon 具备 schema USAGE + has_perm EXECUTE,保证 RLS 可正常评估
|
|
41
|
+
|
|
42
|
+
为何关键
|
|
43
|
+
- Schema 隔离 + 授权收紧是长期最稳的防线:
|
|
44
|
+
- 防止权限字典被枚举
|
|
45
|
+
- 防止 API 误暴露
|
|
46
|
+
- 避免在 RBAC 表上启用/漂移 RLS 带来的运维灾难
|
|
47
|
+
|
|
48
|
+
重要:RLS 执行需要 schema USAGE
|
|
49
|
+
- 即使 has_perm 是 SECURITY DEFINER,有些环境下 RLS 评估仍要求调用者对 rbac schema 有 USAGE。
|
|
50
|
+
- 因此授予 authenticated/anon 对 rbac 的 USAGE。
|
|
51
|
+
- 这不会暴露 RBAC 表,因为 SELECT 仍然被 revoke。
|
|
52
|
+
|
|
53
|
+
部署清单
|
|
54
|
+
- Supabase Dashboard → API → Exposed Schemas 中移除 rbac/hooks
|
|
55
|
+
- Dashboard → Auth → Hooks 绑定 custom access token hook
|
|
56
|
+
|
|
57
|
+
==================================================================================================== */
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
-- Security hardening:
|
|
61
|
+
-- - lock down RBAC tables from PostgREST (public/anon/authenticated)
|
|
62
|
+
-- - allow auth hook runner to read minimal RBAC tables
|
|
63
|
+
-- - allow authenticated/anon to evaluate RLS via schema USAGE + function EXECUTE
|
|
64
|
+
|
|
65
|
+
-- rbac schema/table lockdown
|
|
66
|
+
revoke all on all tables in schema rbac from public;
|
|
67
|
+
revoke all on all sequences in schema rbac from public;
|
|
68
|
+
revoke all on all functions in schema rbac from public;
|
|
69
|
+
|
|
70
|
+
revoke all on all tables in schema rbac from anon, authenticated;
|
|
71
|
+
revoke all on all sequences in schema rbac from anon, authenticated;
|
|
72
|
+
revoke all on all functions in schema rbac from anon, authenticated;
|
|
73
|
+
|
|
74
|
+
-- service_role: RBAC management (admin backend)
|
|
75
|
+
grant usage on schema rbac to service_role;
|
|
76
|
+
grant select, insert, update, delete on all tables in schema rbac to service_role;
|
|
77
|
+
|
|
78
|
+
-- auth hook runner: minimal reads
|
|
79
|
+
grant usage on schema rbac to supabase_auth_admin;
|
|
80
|
+
grant select on rbac.roles, rbac.user_roles to supabase_auth_admin;
|
|
81
|
+
|
|
82
|
+
-- IMPORTANT: schema USAGE for RLS evaluation (does NOT expose tables)
|
|
83
|
+
grant usage on schema rbac to authenticated, anon;
|
|
84
|
+
|
|
85
|
+
-- hooks schema hardening + hook execute only for auth service
|
|
86
|
+
revoke all on schema hooks from public;
|
|
87
|
+
revoke all on schema hooks from anon, authenticated;
|
|
88
|
+
|
|
89
|
+
revoke all on function hooks.custom_access_token_hook(jsonb) from public;
|
|
90
|
+
revoke all on function hooks.custom_access_token_hook(jsonb) from anon, authenticated;
|
|
91
|
+
|
|
92
|
+
grant usage on schema hooks to supabase_auth_admin;
|
|
93
|
+
grant execute on function hooks.custom_access_token_hook(jsonb) to supabase_auth_admin;
|
|
94
|
+
|
|
95
|
+
-- Allow RLS to call has_perm (function execute only; RBAC tables still hidden)
|
|
96
|
+
grant execute on function rbac.has_perm(text, text) to authenticated;
|
|
97
|
+
grant execute on function rbac.has_perm(text, text) to anon;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/* =================================================================================================
|
|
2
|
+
ENGLISH COMMENT BLOCK (Architecture + Ops + Frontend Guidance)
|
|
3
|
+
====================================================================================================
|
|
4
|
+
|
|
5
|
+
FILE PURPOSE
|
|
6
|
+
- Example business table (app1-private) + RLS policies demonstrating how to use rbac.has_perm().
|
|
7
|
+
- This file represents how each app/module should apply RLS on its own private tables.
|
|
8
|
+
|
|
9
|
+
HOW TO USE THIS PATTERN (Recommended)
|
|
10
|
+
- For each app module:
|
|
11
|
+
- Create its private tables (no need to add app_id column if the table is truly app-private).
|
|
12
|
+
- Enable RLS.
|
|
13
|
+
- Write policies that call:
|
|
14
|
+
(select rbac.has_perm('<app_id>', '<permission_name>'))
|
|
15
|
+
|
|
16
|
+
WHY WRAP WITH (SELECT ...)
|
|
17
|
+
- Encourages PostgreSQL optimizer to evaluate the function once per statement (InitPlan best-effort).
|
|
18
|
+
- Even if the optimizer does not fully InitPlan it, has_perm has transaction-local caching to prevent
|
|
19
|
+
N+1 RBAC lookups.
|
|
20
|
+
|
|
21
|
+
FRONTEND BEHAVIOR
|
|
22
|
+
- Client does normal CRUD calls. If permission is missing:
|
|
23
|
+
- SELECT returns 0 rows or 403 depending on query/path.
|
|
24
|
+
- INSERT/UPDATE/DELETE returns 403.
|
|
25
|
+
- After admin changes roles/permissions:
|
|
26
|
+
- user must refresh JWT: supabase.auth.refreshSession()
|
|
27
|
+
|
|
28
|
+
==================================================================================================== */
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/* =================================================================================================
|
|
32
|
+
中文注释区块(架构 + 运维 + 前端指引)
|
|
33
|
+
====================================================================================================
|
|
34
|
+
|
|
35
|
+
文件目的
|
|
36
|
+
- 示例业务表(app1 私有)及 RLS 策略,演示如何调用 rbac.has_perm()。
|
|
37
|
+
- 真实项目中建议每个 App/模块的业务表和策略独立迁移文件维护。
|
|
38
|
+
|
|
39
|
+
推荐用法
|
|
40
|
+
- 每个 App:
|
|
41
|
+
- 建立自己的私有业务表(如果确实是 app-private 表,不需要 app_id 字段)。
|
|
42
|
+
- 开启 RLS。
|
|
43
|
+
- 策略中调用:
|
|
44
|
+
(select rbac.has_perm('<app_id>', '<permission_name>'))
|
|
45
|
+
|
|
46
|
+
为什么用 (SELECT ...) 包裹
|
|
47
|
+
- 诱导优化器把函数当作 InitPlan(尽力做到一次计算)。
|
|
48
|
+
- 即使没有完全 InitPlan,has_perm 内部也有事务级缓存,能避免 N+1 权限查询。
|
|
49
|
+
|
|
50
|
+
前端表现
|
|
51
|
+
- 前端正常 CRUD。无权限时:
|
|
52
|
+
- SELECT 可能返回 0 行或 403(取决于接口路径/查询方式)
|
|
53
|
+
- INSERT/UPDATE/DELETE 返回 403
|
|
54
|
+
- 管理端改完角色/权限后:
|
|
55
|
+
- 用户需刷新 JWT:supabase.auth.refreshSession()
|
|
56
|
+
|
|
57
|
+
==================================================================================================== */
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
-- Example business table (app1-private) and RLS policies.
|
|
61
|
+
-- In real projects, you usually create one migration per module/table group.
|
|
62
|
+
|
|
63
|
+
create table if not exists public.orders (
|
|
64
|
+
id uuid primary key default gen_random_uuid(),
|
|
65
|
+
created_at timestamptz not null default now(),
|
|
66
|
+
customer text,
|
|
67
|
+
amount_cents int not null default 0
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
alter table public.orders enable row level security;
|
|
71
|
+
|
|
72
|
+
drop policy if exists app1_order_select on public.orders;
|
|
73
|
+
create policy app1_order_select
|
|
74
|
+
on public.orders
|
|
75
|
+
for select
|
|
76
|
+
using (
|
|
77
|
+
(select rbac.has_perm('app1', 'order.read'))
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
drop policy if exists app1_order_insert on public.orders;
|
|
81
|
+
create policy app1_order_insert
|
|
82
|
+
on public.orders
|
|
83
|
+
for insert
|
|
84
|
+
with check (
|
|
85
|
+
(select rbac.has_perm('app1', 'order.create'))
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
drop policy if exists app1_order_update on public.orders;
|
|
89
|
+
create policy app1_order_update
|
|
90
|
+
on public.orders
|
|
91
|
+
for update
|
|
92
|
+
using (
|
|
93
|
+
(select rbac.has_perm('app1', 'order.update'))
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
drop policy if exists app1_order_delete on public.orders;
|
|
97
|
+
create policy app1_order_delete
|
|
98
|
+
on public.orders
|
|
99
|
+
for delete
|
|
100
|
+
using (
|
|
101
|
+
(select rbac.has_perm('app1', 'order.delete'))
|
|
102
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sunboyoo/supabase-rbac",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Industrial-grade RBAC helper for Supabase multi-app projects (JWT role_ids + optional permission RPC + React hooks + pg manager + CLI).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/sunboyoo/supabase-rbac.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/sunboyoo/supabase-rbac#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/sunboyoo/supabase-rbac/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"supabase",
|
|
17
|
+
"rbac",
|
|
18
|
+
"postgres",
|
|
19
|
+
"rls",
|
|
20
|
+
"auth",
|
|
21
|
+
"permissions",
|
|
22
|
+
"roles",
|
|
23
|
+
"react"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"require": "./dist/index.cjs"
|
|
31
|
+
},
|
|
32
|
+
"./shared": {
|
|
33
|
+
"types": "./dist/shared.d.ts",
|
|
34
|
+
"import": "./dist/shared.js",
|
|
35
|
+
"require": "./dist/shared.cjs"
|
|
36
|
+
},
|
|
37
|
+
"./client": {
|
|
38
|
+
"types": "./dist/client.d.ts",
|
|
39
|
+
"import": "./dist/client.js",
|
|
40
|
+
"require": "./dist/client.cjs"
|
|
41
|
+
},
|
|
42
|
+
"./server": {
|
|
43
|
+
"types": "./dist/server.d.ts",
|
|
44
|
+
"import": "./dist/server.js",
|
|
45
|
+
"require": "./dist/server.cjs"
|
|
46
|
+
},
|
|
47
|
+
"./package.json": "./package.json"
|
|
48
|
+
},
|
|
49
|
+
"main": "./dist/index.cjs",
|
|
50
|
+
"module": "./dist/index.js",
|
|
51
|
+
"types": "./dist/index.d.ts",
|
|
52
|
+
"files": [
|
|
53
|
+
"dist",
|
|
54
|
+
"bin",
|
|
55
|
+
"migrations",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"bin": {
|
|
60
|
+
"rbac-init": "./bin/rbac-init.js"
|
|
61
|
+
},
|
|
62
|
+
"sideEffects": false,
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsup src/index.ts src/shared.ts src/client.tsx src/server.ts --format esm,cjs --dts --clean",
|
|
65
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
66
|
+
"test": "vitest",
|
|
67
|
+
"test:run": "vitest run",
|
|
68
|
+
"lint": "eslint .",
|
|
69
|
+
"prepublishOnly": "pnpm test:run && pnpm build"
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"jose": "^5.0.0",
|
|
73
|
+
"pg": "^8.11.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependencies": {
|
|
76
|
+
"@supabase/supabase-js": "^2.0.0",
|
|
77
|
+
"react": ">=16.8.0"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"react": {
|
|
81
|
+
"optional": true
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"devDependencies": {
|
|
85
|
+
"@eslint/js": "^9.39.2",
|
|
86
|
+
"@testing-library/react": "^14.3.1",
|
|
87
|
+
"@types/pg": "^8.11.0",
|
|
88
|
+
"@types/react": "^18.0.0",
|
|
89
|
+
"eslint": "^9.39.2",
|
|
90
|
+
"globals": "^16.5.0",
|
|
91
|
+
"jsdom": "^24.1.3",
|
|
92
|
+
"tsup": "^8.1.0",
|
|
93
|
+
"typescript": "^5.6.3",
|
|
94
|
+
"typescript-eslint": "^8.51.0",
|
|
95
|
+
"vitest": "^2.1.9"
|
|
96
|
+
},
|
|
97
|
+
"packageManager": "pnpm@10.27.0",
|
|
98
|
+
"engines": {
|
|
99
|
+
"node": ">=18"
|
|
100
|
+
},
|
|
101
|
+
"publishConfig": {
|
|
102
|
+
"access": "public",
|
|
103
|
+
"registry": "https://registry.npmjs.org/"
|
|
104
|
+
}
|
|
105
|
+
}
|