@sunboyoo/supabase-rbac 1.0.3 → 1.1.0

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/README.md CHANGED
@@ -86,12 +86,10 @@ npx rbac-init --dest ./supabase/migrations
86
86
  npx rbac-init --dry-run
87
87
  npx rbac-init --force
88
88
  npx rbac-init --verbose
89
- npx rbac-init --with-examples
90
89
  ```
91
90
 
92
91
  > ✅ **推荐**:默认不覆盖已有文件(避免误伤本地改动)。
93
92
  > 如需覆盖,请显式 `--force`。
94
- > 说明:默认不复制示例迁移(包含 app1 orders 例子)。如需示例,请加 `--with-examples`。
95
93
 
96
94
  ---
97
95
 
@@ -155,6 +153,62 @@ await supabase.auth.refreshSession();
155
153
 
156
154
  否则用户 token 仍是旧 claims。
157
155
 
156
+ ### 4) 业务表 RLS 配置示例
157
+
158
+ 在业务表上配置 RLS 策略调用 `rbac.has_perm()`:
159
+
160
+ ```sql
161
+ -- Example business table (app1-private) and RLS policies.
162
+ -- In real projects, you usually create one migration per module/table group.
163
+
164
+ create table if not exists public.orders (
165
+ id uuid primary key default gen_random_uuid(),
166
+ created_at timestamptz not null default now(),
167
+ customer text,
168
+ amount_cents int not null default 0
169
+ );
170
+
171
+ alter table public.orders enable row level security;
172
+
173
+ drop policy if exists app1_order_select on public.orders;
174
+ create policy app1_order_select
175
+ on public.orders
176
+ for select
177
+ using (
178
+ (select rbac.has_perm('app1', 'order.read'))
179
+ );
180
+
181
+ drop policy if exists app1_order_insert on public.orders;
182
+ create policy app1_order_insert
183
+ on public.orders
184
+ for insert
185
+ with check (
186
+ (select rbac.has_perm('app1', 'order.create'))
187
+ );
188
+
189
+ drop policy if exists app1_order_update on public.orders;
190
+ create policy app1_order_update
191
+ on public.orders
192
+ for update
193
+ using (
194
+ (select rbac.has_perm('app1', 'order.update'))
195
+ );
196
+
197
+ drop policy if exists app1_order_delete on public.orders;
198
+ create policy app1_order_delete
199
+ on public.orders
200
+ for delete
201
+ using (
202
+ (select rbac.has_perm('app1', 'order.delete'))
203
+ );
204
+ ```
205
+
206
+ **要点:**
207
+
208
+ * 用 `(select rbac.has_perm(...))` 包裹:诱导 PostgreSQL 优化器做 InitPlan(一次计算)
209
+ * 即使未完全 InitPlan,`has_perm` 内部也有事务级缓存,避免 N+1 查询
210
+ * 客户端正常 CRUD;无权限时:SELECT 返回 0 行,INSERT/UPDATE/DELETE 返回 403
211
+
158
212
  ---
159
213
 
160
214
  ## 前端集成(React)
package/bin/rbac-init.js CHANGED
@@ -1,80 +1,99 @@
1
1
  #!/usr/bin/env node
2
2
  /* ================================================================================================
3
- EN: rbac-init CLI
4
- - Safely copy bundled SQL migrations into target supabase/migrations folder.
5
- 中文:rbac-init 命令
6
- - 将包内 migrations 安全复制到项目的 supabase/migrations
3
+ EN: rbac-init CLI - Copy bundled SQL migrations into target supabase/migrations folder.
4
+ 中文:rbac-init 命令 - 将包内 migrations 复制到项目的 supabase/migrations
7
5
  ================================================================================================ */
8
6
 
9
7
  import fs from "node:fs";
10
8
  import fsp from "node:fs/promises";
11
9
  import path from "node:path";
12
- import process from "node:process";
13
10
  import { fileURLToPath } from "node:url";
14
11
 
12
+ const PREFIX = "[rbac-init]";
13
+
15
14
  function parseArgs(argv) {
16
- const out = { dest: null, force: false, dryRun: false, verbose: false, withExamples: false };
15
+ const out = { dest: null, force: false, dryRun: false, verbose: false };
17
16
  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
17
+ const arg = argv[i];
18
+ switch (arg) {
19
+ case "--dest":
20
+ out.dest = argv[++i] ?? null;
21
+ break;
22
+ case "--force":
23
+ case "-f":
24
+ out.force = true;
25
+ break;
26
+ case "--dry-run":
27
+ out.dryRun = true;
28
+ break;
29
+ case "--verbose":
30
+ case "-v":
31
+ out.verbose = true;
32
+ break;
33
+ case "--help":
34
+ case "-h":
35
+ console.log(`rbac-init
26
36
 
27
37
  Usage:
28
- rbac-init [--dest <path>] [--force] [--dry-run] [--verbose] [--with-examples]
38
+ rbac-init [--dest <path>] [--force] [--dry-run] [--verbose]
29
39
 
30
40
  Defaults:
31
41
  --dest ./supabase/migrations
32
42
 
33
43
  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)
44
+ -f, --force Overwrite existing files if content differs
45
+ --dry-run Print actions without writing
46
+ -v, --verbose Print detailed logs
38
47
  `);
39
- process.exit(0);
48
+ process.exit(0);
40
49
  }
41
50
  }
42
51
  return out;
43
52
  }
44
53
 
45
- async function exists(p) {
46
- try { await fsp.access(p, fs.constants.F_OK); return true; } catch { return false; }
47
- }
54
+ const exists = async (p) => {
55
+ try {
56
+ await fsp.access(p, fs.constants.F_OK);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ };
48
62
 
49
- async function readText(p) {
50
- try { return await fsp.readFile(p, "utf8"); } catch { return null; }
51
- }
63
+ const readText = async (p) => {
64
+ try {
65
+ return await fsp.readFile(p, "utf8");
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
52
70
 
53
71
  async function main() {
54
72
  const args = parseArgs(process.argv);
55
- const cwd = process.cwd();
56
- const destDir = path.resolve(cwd, args.dest ?? path.join("supabase", "migrations"));
73
+ const destDir = path.resolve(process.cwd(), args.dest ?? path.join("supabase", "migrations"));
57
74
 
58
- const __filename = fileURLToPath(import.meta.url);
59
- const __dirname = path.dirname(__filename);
75
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
60
76
  const srcDir = path.resolve(__dirname, "..", "migrations");
61
77
 
62
78
  if (!(await exists(srcDir))) {
63
- console.error(`[rbac-init] migrations folder not found in package: ${srcDir}`);
79
+ console.error(`${PREFIX} migrations folder not found: ${srcDir}`);
64
80
  process.exit(1);
65
81
  }
66
82
 
67
83
  if (!(await exists(destDir))) {
68
- if (args.dryRun) console.log(`[dry-run] mkdir -p ${destDir}`);
69
- else await fsp.mkdir(destDir, { recursive: true });
84
+ if (args.dryRun) {
85
+ console.log(`[dry-run] mkdir -p ${destDir}`);
86
+ } else {
87
+ await fsp.mkdir(destDir, { recursive: true });
88
+ }
70
89
  }
71
90
 
72
91
  const files = (await fsp.readdir(srcDir))
73
92
  .filter((f) => f.endsWith(".sql"))
74
- .filter((f) => args.withExamples || !/example/i.test(f))
75
93
  .sort();
94
+
76
95
  if (files.length === 0) {
77
- console.log("[rbac-init] no .sql files found.");
96
+ console.log(`${PREFIX} no .sql files found.`);
78
97
  return;
79
98
  }
80
99
 
@@ -88,17 +107,17 @@ async function main() {
88
107
  const srcText = await readText(src);
89
108
  const dstText = dstExists ? await readText(dst) : null;
90
109
 
91
- const same = dstExists && srcText != null && dstText != null && srcText === dstText;
92
-
93
- if (same) {
110
+ // Skip if content is identical
111
+ if (dstExists && srcText === dstText) {
94
112
  skipped++;
95
- if (args.verbose) console.log(`[rbac-init] skip same: ${file}`);
113
+ if (args.verbose) console.log(`${PREFIX} skip (same): ${file}`);
96
114
  continue;
97
115
  }
98
116
 
117
+ // Skip if exists and not forced
99
118
  if (dstExists && !args.force) {
100
119
  skipped++;
101
- console.log(`[rbac-init] skip exists (use --force): ${file}`);
120
+ console.log(`${PREFIX} skip (use --force): ${file}`);
102
121
  continue;
103
122
  }
104
123
 
@@ -108,15 +127,19 @@ async function main() {
108
127
  }
109
128
 
110
129
  await fsp.copyFile(src, dst);
111
- if (dstExists) overwritten++; else copied++;
112
- console.log(`[rbac-init] ${dstExists ? "overwrote" : "copied"}: ${file}`);
130
+ if (dstExists) {
131
+ overwritten++;
132
+ } else {
133
+ copied++;
134
+ }
135
+ console.log(`${PREFIX} ${dstExists ? "overwrote" : "copied"}: ${file}`);
113
136
  }
114
137
 
115
- console.log(`[rbac-init] done. copied=${copied}, overwritten=${overwritten}, skipped=${skipped}`);
116
- console.log(`[rbac-init] next: run "supabase db push"`);
138
+ console.log(`${PREFIX} done. copied=${copied}, overwritten=${overwritten}, skipped=${skipped}`);
139
+ console.log(`${PREFIX} next: run "supabase db push"`);
117
140
  }
118
141
 
119
142
  main().catch((e) => {
120
- console.error("[rbac-init] error:", e);
143
+ console.error(`${PREFIX} error:`, e);
121
144
  process.exit(1);
122
145
  });
@@ -126,11 +126,11 @@ security definer
126
126
  set search_path = ''
127
127
  as $$
128
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,''));
129
+ _res_key text := 'rbac.res.h' || md5(coalesce(target_app_id,'') || '|' || coalesce(required_perm_name,''));
130
+ _t_roles_key text := 'rbac.rids.h' || md5('t|' || coalesce(target_app_id,''));
131
+ _g_roles_key text := 'rbac.rids.h' || md5('g|global');
132
+ _t_pid_key text := 'rbac.pid.h' || md5('t|' || coalesce(target_app_id,'') || '|' || coalesce(required_perm_name,''));
133
+ _g_pid_key text := 'rbac.pid.h' || md5('g|global|' || coalesce(required_perm_name,''));
134
134
 
135
135
  _target_role_ids uuid[];
136
136
  _global_role_ids uuid[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunboyoo/supabase-rbac",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Industrial-grade RBAC helper for Supabase multi-app projects (JWT role_ids + optional permission RPC + React hooks + pg manager + CLI).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -106,4 +106,4 @@
106
106
  "access": "public",
107
107
  "registry": "https://registry.npmjs.org/"
108
108
  }
109
- }
109
+ }
@@ -1,102 +0,0 @@
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
- );