@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 sunboyoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,404 @@
1
+ # @sunboyoo/supabase-rbac 集成指南(README)
2
+
3
+ > 工业级、多 App、多模块 RBAC(Supabase + PostgreSQL + TypeScript/React)
4
+ > **数据库 RLS 才是最终权威**;前端仅用于 UI 显隐(可选),避免“前端鉴权等于安全”的误区。
5
+
6
+ ---
7
+
8
+ ## 目录
9
+
10
+ * [功能概览](#功能概览)
11
+ * [核心设计原则](#核心设计原则)
12
+ * [安装与初始化](#安装与初始化)
13
+ * [Supabase Dashboard 必做配置](#supabase-dashboard-必做配置)
14
+ * [数据库部署与验证](#数据库部署与验证)
15
+ * [前端集成(React)](#前端集成react)
16
+ * [服务端/管理端集成(Node)](#服务端管理端集成node)
17
+ * [可选增强:permission-name 语义化判定](#可选增强permission-name-语义化判定)
18
+ * [最佳实践:权限命名与角色管理](#最佳实践权限命名与角色管理)
19
+ * [常见问题(FAQ)](#常见问题faq)
20
+ * [安全边界与威胁模型](#安全边界与威胁模型)
21
+ * [版本与兼容性](#版本与兼容性)
22
+
23
+ ---
24
+
25
+ ## 功能概览
26
+
27
+ * ✅ **多应用支持(app_id)**:同一 Supabase 项目支持多个业务 App/模块。
28
+ * ✅ **自动合并 global 角色**:`app1` + `global` 角色集合合并用于判定。
29
+ * ✅ **数据库强制鉴权(RLS)**:策略调用 `rbac.has_perm(app_id, perm_name)`。
30
+ * ✅ **JWT 注入 role_ids(UUID)**:通过 Supabase Auth Custom Access Token Hook 注入。
31
+ * ✅ **React 集成**:`RBACProvider` + `useRBAC()`。
32
+ * ✅ **Server 管理端**:`RBACManager`(pg 直连,不依赖 PostgREST 暴露 rbac schema)。
33
+ * ✅ **CLI 初始化**:`rbac-init` 将 migrations 复制到项目 `supabase/migrations`。
34
+ * 🧩(可选)**permission-name 前端语义化判定**:通过受控 RPC 拉取“我的权限名列表”。
35
+
36
+ ---
37
+
38
+ ## 核心设计原则
39
+
40
+ ### 1) DB 是权威:安全必须由 RLS 强制执行
41
+
42
+ * 前端的 `hasRole()` / `hasPermission()` **只能用于 UI 显隐或交互优化**。
43
+ * **真正的安全**来自业务表的 RLS:没有权限时数据库返回 403/0 rows。
44
+
45
+ ### 2) RBAC schema 默认不暴露
46
+
47
+ * 强烈建议:Supabase Dashboard → **API → Exposed Schemas** 中移除 `rbac` / `hooks`。
48
+ * 这样可最大化降低:
49
+
50
+ * 权限字典枚举
51
+ * 意外暴露 RBAC 表
52
+ * 运维期间策略漂移导致的风险
53
+
54
+ ### 3) role_ids 放 JWT,稳定且 rename-safe
55
+
56
+ * JWT 中存的是 role UUID,而不是 role name:
57
+
58
+ * 角色重命名不会影响鉴权
59
+ * 不依赖 role name join
60
+ * token 体积小
61
+
62
+ ---
63
+
64
+ ## 安装与初始化
65
+
66
+ ### 1) 安装
67
+
68
+ ```bash
69
+ pnpm add @sunboyoo/supabase-rbac
70
+ ```
71
+
72
+ ### 2) 初始化 migrations(复制 SQL)
73
+
74
+ 在你的项目根目录执行:
75
+
76
+ ```bash
77
+ npx rbac-init
78
+ # 或
79
+ pnpm exec rbac-init
80
+ ```
81
+
82
+ 常用参数:
83
+
84
+ ```bash
85
+ npx rbac-init --dest ./supabase/migrations
86
+ npx rbac-init --dry-run
87
+ npx rbac-init --force
88
+ npx rbac-init --verbose
89
+ npx rbac-init --with-examples
90
+ ```
91
+
92
+ > ✅ **推荐**:默认不覆盖已有文件(避免误伤本地改动)。
93
+ > 如需覆盖,请显式 `--force`。
94
+ > 说明:默认不复制示例迁移(包含 app1 orders 例子)。如需示例,请加 `--with-examples`。
95
+
96
+ ---
97
+
98
+ ## Supabase Dashboard 必做配置
99
+
100
+ ### 1) 移除 Exposed Schemas
101
+
102
+ 路径:**Supabase Dashboard → API → Exposed Schemas**
103
+
104
+ * ✅ **保留**:`public`(以及你业务需要的 schema)
105
+ * ❌ **移除**:`rbac`、`hooks`
106
+
107
+ > 这是安全关键点:避免 PostgREST 暴露 RBAC 表/视图。
108
+
109
+ ### 2) 绑定 Custom Access Token Hook
110
+
111
+ 路径:**Dashboard → Auth → Hooks → Custom access token hook**
112
+
113
+ 选择:
114
+
115
+ * Function:`hooks.custom_access_token_hook`
116
+
117
+ 此 Hook 会在登录/refresh token 时执行,把每个 app 的 role_ids 注入到 JWT claims。
118
+
119
+ ---
120
+
121
+ ## 数据库部署与验证
122
+
123
+ ### 1) 推送迁移
124
+
125
+ ```bash
126
+ supabase db push
127
+ ```
128
+
129
+ ### 2) 验证 Hook 注入是否成功
130
+
131
+ 用户登录后,在前端拿到 session 的 `access_token` 并 decode(仅用于验证):
132
+
133
+ 你应能看到类似结构(示意):
134
+
135
+ ```json
136
+ {
137
+ "app_metadata": {
138
+ "role_ids": {
139
+ "app1": ["uuid-..."],
140
+ "global": ["uuid-..."]
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ > 若看不到 `role_ids`:通常是 **Hook 没绑定** 或用户还未刷新 session。
147
+
148
+ ### 3) 角色变更后必须 refresh token
149
+
150
+ 当你在管理端更新用户角色后:
151
+
152
+ ```ts
153
+ await supabase.auth.refreshSession();
154
+ ```
155
+
156
+ 否则用户 token 仍是旧 claims。
157
+
158
+ ---
159
+
160
+ ## 前端集成(React)
161
+
162
+ ### 1) 包裹 Provider
163
+
164
+ ```tsx
165
+ import { RBACProvider } from "@sunboyoo/supabase-rbac/client";
166
+ import { supabase } from "./supabaseClient";
167
+
168
+ export function Root() {
169
+ return (
170
+ <RBACProvider client={supabase} appId="app1">
171
+ <App />
172
+ </RBACProvider>
173
+ );
174
+ }
175
+ ```
176
+
177
+ ### 2) useRBAC 基础用法
178
+
179
+ ```tsx
180
+ import { useRBAC } from "@sunboyoo/supabase-rbac/client";
181
+
182
+ export function Toolbar() {
183
+ const {
184
+ isAuthenticated,
185
+ isRBACReady,
186
+ roleIds,
187
+ hasRole,
188
+ refresh
189
+ } = useRBAC();
190
+
191
+ if (!isRBACReady) return null;
192
+
193
+ const ADMIN_ROLE_ID = "00000000-0000-0000-0000-000000000001"; // 示例:建议来自你的常量映射
194
+
195
+ return (
196
+ <div>
197
+ {isAuthenticated && <span>Signed in</span>}
198
+
199
+ {hasRole([ADMIN_ROLE_ID]) && (
200
+ <button>Admin Only</button>
201
+ )}
202
+
203
+ <button onClick={() => refresh(true)}>Force Refresh Token</button>
204
+ </div>
205
+ );
206
+ }
207
+ ```
208
+
209
+ ### 3) 推荐:role UUID 不要硬编码
210
+
211
+ 你有 3 种推荐方式:
212
+
213
+ 1. **Seed 输出常量文件**(推荐)
214
+ 2. 管理端查询后写入配置/环境变量
215
+ 3. 业务端后端 API 下发(适用于动态系统)
216
+
217
+ ---
218
+
219
+ ## 服务端/管理端集成(Node)
220
+
221
+ > RBACManager 采用 **pg 直连数据库**,不依赖 PostgREST 暴露 `rbac` schema,符合安全最佳实践。
222
+
223
+ ### 1) 初始化
224
+
225
+ ```ts
226
+ import { RBACManager } from "@sunboyoo/supabase-rbac/server";
227
+
228
+ const rbac = new RBACManager({
229
+ connectionString: process.env.SUPABASE_DB_URL!, // 推荐使用 Supabase 提供的数据库连接串
230
+ rbacSchema: "rbac" // 可省略
231
+ });
232
+ ```
233
+
234
+ ### 2) 创建权限/角色/绑定
235
+
236
+ ```ts
237
+ const appId = "app1";
238
+
239
+ // 0) 确保 app 已存在(外键约束)
240
+ await rbac.createApp({ id: appId, name: "App 1" });
241
+
242
+ // 1) 创建权限字典
243
+ const permId = await rbac.createPermission({
244
+ appId,
245
+ name: "order.delete",
246
+ description: "Delete orders"
247
+ });
248
+
249
+ // 2) 创建角色
250
+ const roleId = await rbac.createRole({
251
+ appId,
252
+ name: "Admin"
253
+ });
254
+
255
+ // 3) 绑定角色-权限
256
+ await rbac.grantPermissionToRole({ appId, roleId, permissionId: permId });
257
+
258
+ // 4) 给用户分配角色
259
+ await rbac.assignRole({ userId: targetUserId, roleId });
260
+ ```
261
+
262
+ ### 3) 关闭连接
263
+
264
+ ```ts
265
+ await rbac.close();
266
+ ```
267
+
268
+ ---
269
+
270
+ ## 可选增强:permission-name 语义化判定
271
+
272
+ ### 为什么是“可选”?
273
+
274
+ * 优点:业务代码更可维护:`hasPermission('order.delete')`
275
+ * 代价:增加一个 RPC 查询(你需要接受额外 DB 负载 + 一定暴露面)
276
+
277
+ ### 如何启用
278
+
279
+ 1. 确保数据库已部署 permission-name RPC(例如 `get_my_permissions`)
280
+ 2. Provider 开启 permissions 选项:
281
+
282
+ ```tsx
283
+ <RBACProvider
284
+ client={supabase}
285
+ appId="app1"
286
+ permissions={{
287
+ enabled: true,
288
+ rpcName: "get_my_permissions",
289
+ ttlMs: 60000,
290
+ nonBlocking: true
291
+ }}
292
+ >
293
+ <App />
294
+ </RBACProvider>
295
+ ```
296
+
297
+ ### RPC 参考实现(可选)
298
+
299
+ 将 RPC 放在你 **已暴露的 schema**(例如 `public`),RBAC 表仍保持隐藏:
300
+
301
+ ```sql
302
+ create or replace function public.get_my_permissions(target_app_id text)
303
+ returns table(permission_name text)
304
+ language sql
305
+ security definer
306
+ set search_path = ''
307
+ as $$
308
+ select distinct p.name as permission_name
309
+ from rbac.user_roles ur
310
+ join rbac.roles r on r.id = ur.role_id
311
+ join rbac.role_permissions rp on rp.role_id = r.id and rp.app_id = r.app_id
312
+ join rbac.permissions p on p.id = rp.permission_id and p.app_id = r.app_id
313
+ where ur.user_id = auth.uid()
314
+ and r.app_id in (target_app_id, 'global');
315
+ $$;
316
+
317
+ grant execute on function public.get_my_permissions(text) to authenticated;
318
+ ```
319
+
320
+ > 注意:如需自定义 `global` key,请同步调整 RPC 与前端 `globalAppId`。
321
+
322
+ 使用:
323
+
324
+ ```tsx
325
+ const { hasPermission, isPermissionReady } = useRBAC();
326
+ if (!isPermissionReady) return null;
327
+
328
+ return hasPermission("order.delete") ? <DeleteBtn /> : null;
329
+ ```
330
+
331
+ ### 强提醒:permission-name 仍然不是安全边界
332
+
333
+ 即使前端显示了按钮,最终能否 DELETE 仍由 RLS 决定。
334
+
335
+ ---
336
+
337
+ ## 最佳实践:权限命名与角色管理
338
+
339
+ ### 权限命名规范
340
+
341
+ * 全小写
342
+ * 点号分层
343
+ * 稳定、不频繁改动
344
+
345
+ 示例:
346
+
347
+ * `order.read`
348
+ * `order.create`
349
+ * `order.update`
350
+ * `order.delete`
351
+ * `payroll.export`
352
+
353
+ ### global-mirror 语义
354
+
355
+ * `global` 是真实 app_id(不是 NULL)
356
+ * 如果 global 角色要跨 App 赋权:
357
+
358
+ * 必须在 `rbac.permissions(app_id='global')` 中创建“同名镜像权限”
359
+
360
+ ---
361
+
362
+ ## 常见问题(FAQ)
363
+
364
+ ### Q1:为什么前端看不到 role_ids?
365
+
366
+ * Hook 未绑定或用户未 refresh session。
367
+ * 解决:
368
+
369
+ 1. Dashboard 绑定 hook
370
+ 2. 用户端执行 `supabase.auth.refreshSession()`
371
+
372
+ ### Q2:为什么 server.ts 用 pg 而不是 supabase-js?
373
+
374
+ 因为你最佳实践要求 **rbac schema 不暴露**,而 supabase-js(PostgREST)访问不了隐藏 schema。pg 直连是最可靠路径。
375
+
376
+ ### Q3:我开启了 hasPermission,但 RPC 报错怎么办?
377
+
378
+ * 库会优雅降级:permissionNames 置空,仍可用 hasRole。
379
+ * 你应检查:
380
+
381
+ * permission-name RPC 是否已部署
382
+ * rpcName 是否匹配
383
+ * GRANT EXECUTE 是否给 authenticated
384
+
385
+ ---
386
+
387
+ ## 安全边界与威胁模型
388
+
389
+ * ✅ DB/RLS:安全权威
390
+ * ⚠️ Client gating:只做体验
391
+ * ✅ RBAC schema 不暴露:降低枚举风险
392
+ * ⚠️ 可选 RPC:增加暴露面,但控制在“仅返回自己权限”范围内
393
+ * ⚠️ 不要给不受信任用户直连 SQL 或“SQL 执行器”类 RPC;`has_perm` 使用事务级缓存(`set_config`/`current_setting`),需在受控 SQL 环境使用
394
+
395
+ ---
396
+
397
+ ## 版本与兼容性
398
+
399
+ * Node:建议 18+
400
+ * React:>=16.8
401
+ * supabase-js:^2.x
402
+ * PostgreSQL:Supabase 托管 PG(带 pgcrypto)
403
+
404
+ ---
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /* ================================================================================================
3
+ EN: rbac-init CLI
4
+ - Safely copy bundled SQL migrations into target supabase/migrations folder.
5
+ 中文:rbac-init 命令
6
+ - 将包内 migrations 安全复制到项目的 supabase/migrations
7
+ ================================================================================================ */
8
+
9
+ import fs from "node:fs";
10
+ import fsp from "node:fs/promises";
11
+ import path from "node:path";
12
+ import process from "node:process";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ function parseArgs(argv) {
16
+ const out = { dest: null, force: false, dryRun: false, verbose: false, withExamples: false };
17
+ for (let i = 2; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === "--dest") out.dest = argv[++i] ?? null;
20
+ else if (a === "--force" || a === "-f") out.force = true;
21
+ else if (a === "--dry-run") out.dryRun = true;
22
+ else if (a === "--verbose" || a === "-v") out.verbose = true;
23
+ else if (a === "--with-examples") out.withExamples = true;
24
+ else if (a === "--help" || a === "-h") {
25
+ console.log(`rbac-init
26
+
27
+ Usage:
28
+ rbac-init [--dest <path>] [--force] [--dry-run] [--verbose] [--with-examples]
29
+
30
+ Defaults:
31
+ --dest ./supabase/migrations
32
+
33
+ Options:
34
+ --force overwrite if exists and content differs
35
+ --dry-run print actions without writing
36
+ --verbose print detailed logs
37
+ --with-examples include example migrations (off by default)
38
+ `);
39
+ process.exit(0);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ async function exists(p) {
46
+ try { await fsp.access(p, fs.constants.F_OK); return true; } catch { return false; }
47
+ }
48
+
49
+ async function readText(p) {
50
+ try { return await fsp.readFile(p, "utf8"); } catch { return null; }
51
+ }
52
+
53
+ async function main() {
54
+ const args = parseArgs(process.argv);
55
+ const cwd = process.cwd();
56
+ const destDir = path.resolve(cwd, args.dest ?? path.join("supabase", "migrations"));
57
+
58
+ const __filename = fileURLToPath(import.meta.url);
59
+ const __dirname = path.dirname(__filename);
60
+ const srcDir = path.resolve(__dirname, "..", "migrations");
61
+
62
+ if (!(await exists(srcDir))) {
63
+ console.error(`[rbac-init] migrations folder not found in package: ${srcDir}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ if (!(await exists(destDir))) {
68
+ if (args.dryRun) console.log(`[dry-run] mkdir -p ${destDir}`);
69
+ else await fsp.mkdir(destDir, { recursive: true });
70
+ }
71
+
72
+ const files = (await fsp.readdir(srcDir))
73
+ .filter((f) => f.endsWith(".sql"))
74
+ .filter((f) => args.withExamples || !/example/i.test(f))
75
+ .sort();
76
+ if (files.length === 0) {
77
+ console.log("[rbac-init] no .sql files found.");
78
+ return;
79
+ }
80
+
81
+ let copied = 0, overwritten = 0, skipped = 0;
82
+
83
+ for (const file of files) {
84
+ const src = path.join(srcDir, file);
85
+ const dst = path.join(destDir, file);
86
+
87
+ const dstExists = await exists(dst);
88
+ const srcText = await readText(src);
89
+ const dstText = dstExists ? await readText(dst) : null;
90
+
91
+ const same = dstExists && srcText != null && dstText != null && srcText === dstText;
92
+
93
+ if (same) {
94
+ skipped++;
95
+ if (args.verbose) console.log(`[rbac-init] skip same: ${file}`);
96
+ continue;
97
+ }
98
+
99
+ if (dstExists && !args.force) {
100
+ skipped++;
101
+ console.log(`[rbac-init] skip exists (use --force): ${file}`);
102
+ continue;
103
+ }
104
+
105
+ if (args.dryRun) {
106
+ console.log(`[dry-run] ${dstExists ? "overwrite" : "copy"} ${file}`);
107
+ continue;
108
+ }
109
+
110
+ await fsp.copyFile(src, dst);
111
+ if (dstExists) overwritten++; else copied++;
112
+ console.log(`[rbac-init] ${dstExists ? "overwrote" : "copied"}: ${file}`);
113
+ }
114
+
115
+ console.log(`[rbac-init] done. copied=${copied}, overwritten=${overwritten}, skipped=${skipped}`);
116
+ console.log(`[rbac-init] next: run "supabase db push"`);
117
+ }
118
+
119
+ main().catch((e) => {
120
+ console.error("[rbac-init] error:", e);
121
+ process.exit(1);
122
+ });
@@ -0,0 +1,116 @@
1
+ // src/server.ts
2
+ import pg from "pg";
3
+ var { Pool } = pg;
4
+ function qIdent(ident) {
5
+ return `"${String(ident).replace(/"/g, '""')}"`;
6
+ }
7
+ var RBACManager = class {
8
+ pool;
9
+ schema;
10
+ constructor(opts) {
11
+ this.pool = new Pool({ connectionString: opts.connectionString });
12
+ this.schema = opts.rbacSchema ?? "rbac";
13
+ }
14
+ async close() {
15
+ await this.pool.end();
16
+ }
17
+ /** EN: Run a transaction.
18
+ * 中文:事务封装
19
+ */
20
+ async tx(fn) {
21
+ const client = await this.pool.connect();
22
+ try {
23
+ await client.query("begin");
24
+ const res = await fn(client);
25
+ await client.query("commit");
26
+ return res;
27
+ } catch (e) {
28
+ await client.query("rollback");
29
+ throw e;
30
+ } finally {
31
+ client.release();
32
+ }
33
+ }
34
+ async createApp(params) {
35
+ const s = qIdent(this.schema);
36
+ await this.pool.query(
37
+ `insert into ${s}.apps (id, name) values ($1, $2)
38
+ on conflict (id) do update set name = excluded.name`,
39
+ [params.id, params.name]
40
+ );
41
+ }
42
+ async createPermission(params) {
43
+ const s = qIdent(this.schema);
44
+ const res = await this.pool.query(
45
+ `insert into ${s}.permissions (app_id, name, description)
46
+ values ($1, $2, $3)
47
+ on conflict (app_id, name) do update set description = excluded.description
48
+ returning id`,
49
+ [params.appId, params.name, params.description ?? null]
50
+ );
51
+ return res.rows[0].id;
52
+ }
53
+ async createRole(params) {
54
+ const s = qIdent(this.schema);
55
+ const res = await this.pool.query(
56
+ `insert into ${s}.roles (app_id, name, description)
57
+ values ($1, $2, $3)
58
+ on conflict (app_id, name) do update set description = excluded.description
59
+ returning id`,
60
+ [params.appId, params.name, params.description ?? null]
61
+ );
62
+ return res.rows[0].id;
63
+ }
64
+ async grantPermissionToRole(params) {
65
+ const s = qIdent(this.schema);
66
+ await this.pool.query(
67
+ `insert into ${s}.role_permissions (app_id, role_id, permission_id)
68
+ values ($1, $2, $3)
69
+ on conflict do nothing`,
70
+ [params.appId, params.roleId, params.permissionId]
71
+ );
72
+ }
73
+ async revokePermissionFromRole(params) {
74
+ const s = qIdent(this.schema);
75
+ await this.pool.query(
76
+ `delete from ${s}.role_permissions
77
+ where app_id = $1 and role_id = $2 and permission_id = $3`,
78
+ [params.appId, params.roleId, params.permissionId]
79
+ );
80
+ }
81
+ async assignRole(params) {
82
+ const s = qIdent(this.schema);
83
+ await this.pool.query(
84
+ `insert into ${s}.user_roles (user_id, role_id)
85
+ values ($1, $2)
86
+ on conflict do nothing`,
87
+ [params.userId, params.roleId]
88
+ );
89
+ }
90
+ async removeRole(params) {
91
+ const s = qIdent(this.schema);
92
+ await this.pool.query(
93
+ `delete from ${s}.user_roles where user_id = $1 and role_id = $2`,
94
+ [params.userId, params.roleId]
95
+ );
96
+ }
97
+ async getUserRoleIds(params) {
98
+ const s = qIdent(this.schema);
99
+ const includeGlobal = params.includeGlobal ?? true;
100
+ const globalAppId = params.globalAppId ?? "global";
101
+ const appIds = includeGlobal ? [params.appId, globalAppId] : [params.appId];
102
+ const res = await this.pool.query(
103
+ `select r.id as role_id
104
+ from ${s}.user_roles ur
105
+ join ${s}.roles r on r.id = ur.role_id
106
+ where ur.user_id = $1 and r.app_id = any($2::text[])
107
+ order by r.app_id, r.id`,
108
+ [params.userId, appIds]
109
+ );
110
+ return res.rows.map((x) => x.role_id);
111
+ }
112
+ };
113
+
114
+ export {
115
+ RBACManager
116
+ };
@@ -0,0 +1,54 @@
1
+ // src/shared.ts
2
+ import { decodeJwt } from "jose";
3
+ var IS_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
+ function isUuid(v) {
5
+ return typeof v === "string" && IS_UUID.test(v);
6
+ }
7
+ function uniqueStable(arr) {
8
+ const seen = /* @__PURE__ */ new Set();
9
+ const out = [];
10
+ for (const x of arr) {
11
+ if (seen.has(x)) continue;
12
+ seen.add(x);
13
+ out.push(x);
14
+ }
15
+ return out;
16
+ }
17
+ function decodeClaims(token) {
18
+ if (!token) return null;
19
+ try {
20
+ return decodeJwt(token);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function getMergedRoleIds(params) {
26
+ const { token, appId, includeGlobal = true, globalAppId = "global" } = params;
27
+ const claims = decodeClaims(token);
28
+ const roleMap = claims?.app_metadata?.role_ids ?? {};
29
+ const appRoles = Array.isArray(roleMap[appId]) ? roleMap[appId] : [];
30
+ const gRoles = includeGlobal && Array.isArray(roleMap[globalAppId]) ? roleMap[globalAppId] : [];
31
+ const merged = uniqueStable([...appRoles, ...gRoles]).filter(isUuid);
32
+ const userId = isUuid(claims?.sub) ? claims.sub : null;
33
+ const exp = typeof claims?.exp === "number" ? claims.exp : null;
34
+ return { roleIds: merged, claims, userId, exp };
35
+ }
36
+ function hasRole(userRoleIds, required, mode = "any") {
37
+ if (!required || required.length === 0) return true;
38
+ if (!userRoleIds || userRoleIds.length === 0) return false;
39
+ const set = new Set(userRoleIds);
40
+ if (mode === "all") {
41
+ for (const r of required) if (!set.has(r)) return false;
42
+ return true;
43
+ }
44
+ for (const r of required) if (set.has(r)) return true;
45
+ return false;
46
+ }
47
+
48
+ export {
49
+ isUuid,
50
+ uniqueStable,
51
+ decodeClaims,
52
+ getMergedRoleIds,
53
+ hasRole
54
+ };