@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.
@@ -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
+ }