snipe-auth-rbac 0.1.0 → 0.2.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.
@@ -0,0 +1,512 @@
1
+ -- snipe-auth-rbac — initial schema and resolver
2
+ --
3
+ -- Two-layer RBAC: system roles + per-company roles, sharing one
4
+ -- resource registry and one resolver function.
5
+ --
6
+ -- Schema isolation
7
+ -- ────────────────
8
+ -- All package objects live under a dedicated `rbac` schema instead
9
+ -- of the `public` namespace. Adopters get a clean separation from
10
+ -- their own tables and zero risk of name collisions.
11
+ --
12
+ -- For Supabase / PostgREST adopters: add `rbac` to the project's
13
+ -- "Exposed schemas" setting (Studio → Settings → API → Exposed
14
+ -- schemas → add `rbac`). The TS + Python clients address the
15
+ -- functions via `supabase.schema('rbac').rpc('user_can', …)`.
16
+ --
17
+ -- Idempotent: every statement uses CREATE … IF NOT EXISTS or
18
+ -- CREATE OR REPLACE so the file can be re-applied on an existing
19
+ -- DB without errors.
20
+
21
+ BEGIN;
22
+
23
+ -- ─────────────────────────────────────────────────────────────────
24
+ -- 0. The schema itself
25
+ -- ─────────────────────────────────────────────────────────────────
26
+
27
+ CREATE SCHEMA IF NOT EXISTS rbac;
28
+
29
+ -- Make the rbac schema usable by Supabase's roles. anon needs
30
+ -- USAGE so PostgREST can introspect; authenticated needs USAGE +
31
+ -- SELECT/INSERT/UPDATE/DELETE rights are *not* granted by default
32
+ -- — every read goes through SECURITY DEFINER functions, so the
33
+ -- role only needs EXECUTE on those (granted at the bottom).
34
+ DO $$ BEGIN
35
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
36
+ EXECUTE 'GRANT USAGE ON SCHEMA rbac TO anon';
37
+ END IF;
38
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
39
+ EXECUTE 'GRANT USAGE ON SCHEMA rbac TO authenticated';
40
+ END IF;
41
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
42
+ EXECUTE 'GRANT ALL ON SCHEMA rbac TO service_role';
43
+ END IF;
44
+ END $$;
45
+
46
+ -- ─────────────────────────────────────────────────────────────────
47
+ -- 1. Companies (the team / tenant unit)
48
+ -- ─────────────────────────────────────────────────────────────────
49
+
50
+ CREATE TABLE IF NOT EXISTS rbac.companies (
51
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
52
+ name text NOT NULL,
53
+ slug text UNIQUE,
54
+ type text,
55
+ parent_id uuid REFERENCES rbac.companies(id),
56
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
57
+ created_at timestamptz NOT NULL DEFAULT now(),
58
+ updated_at timestamptz NOT NULL DEFAULT now()
59
+ );
60
+
61
+ -- ─────────────────────────────────────────────────────────────────
62
+ -- 2. Resource registry — the only place a "permissionable thing" is
63
+ -- declared. Two scopes: 'system' (platform-wide) or 'company'
64
+ -- (per-tenant). The resolver and the matrix UI both read from here.
65
+ -- ─────────────────────────────────────────────────────────────────
66
+
67
+ CREATE TABLE IF NOT EXISTS rbac.resources (
68
+ resource text PRIMARY KEY,
69
+ scope text NOT NULL CHECK (scope IN ('system', 'company')),
70
+ label text NOT NULL,
71
+ description text,
72
+ group_label text,
73
+ created_at timestamptz NOT NULL DEFAULT now()
74
+ );
75
+
76
+ -- ─────────────────────────────────────────────────────────────────
77
+ -- 3. Roles — one table for both layers, distinguished by scope.
78
+ -- system: platform-wide role (no company_id)
79
+ -- company + company_id NOT NULL: a role inside that company
80
+ -- company + company_id NULL: a *role template* — cloned per company
81
+ -- at company creation
82
+ -- ─────────────────────────────────────────────────────────────────
83
+
84
+ CREATE TABLE IF NOT EXISTS rbac.roles (
85
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
86
+ scope text NOT NULL CHECK (scope IN ('system', 'company')),
87
+ company_id uuid REFERENCES rbac.companies(id) ON DELETE CASCADE,
88
+ name text NOT NULL,
89
+ description text,
90
+ is_system boolean NOT NULL DEFAULT false,
91
+ is_super boolean NOT NULL DEFAULT false,
92
+ frontend_config jsonb NOT NULL DEFAULT '{}'::jsonb,
93
+ -- Permission template — what `apply_template_defaults(role_id)`
94
+ -- pre-fills when the host calls it after sync_resources(). Shape:
95
+ -- {
96
+ -- "default": ["read"],
97
+ -- "groups": { "Stammdaten": ["read","write","update"], … },
98
+ -- "resources": { "tenants": ["read","write","update","delete"] }
99
+ -- }
100
+ -- Only meaningful on template rows (company_id IS NULL). Real
101
+ -- per-company roles ignore the column.
102
+ default_permissions jsonb NOT NULL DEFAULT '{}'::jsonb,
103
+ created_by uuid,
104
+ created_at timestamptz NOT NULL DEFAULT now(),
105
+ updated_at timestamptz NOT NULL DEFAULT now(),
106
+ CONSTRAINT roles_scope_company_chk CHECK (
107
+ (scope = 'system' AND company_id IS NULL)
108
+ OR scope = 'company'
109
+ ),
110
+ CONSTRAINT roles_super_only_system_chk CHECK (
111
+ NOT is_super OR scope = 'system'
112
+ )
113
+ );
114
+
115
+ -- Backfill for adopters running this against an existing v0.2.0 DB.
116
+ -- The IF NOT EXISTS guard makes it safe on a fresh install too.
117
+ DO $$ BEGIN
118
+ IF NOT EXISTS (
119
+ SELECT 1 FROM information_schema.columns
120
+ WHERE table_schema = 'rbac' AND table_name = 'roles'
121
+ AND column_name = 'default_permissions'
122
+ ) THEN
123
+ EXECUTE 'ALTER TABLE rbac.roles ADD COLUMN default_permissions jsonb NOT NULL DEFAULT ''{}''::jsonb';
124
+ END IF;
125
+ END $$;
126
+
127
+ CREATE UNIQUE INDEX IF NOT EXISTS roles_unique_name
128
+ ON rbac.roles (scope, coalesce(company_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
129
+
130
+ -- ─────────────────────────────────────────────────────────────────
131
+ -- 4. Role permissions — the actual checkbox grid
132
+ -- ─────────────────────────────────────────────────────────────────
133
+
134
+ CREATE TABLE IF NOT EXISTS rbac.role_permissions (
135
+ role_id uuid NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE,
136
+ resource text NOT NULL REFERENCES rbac.resources(resource),
137
+ can_read boolean NOT NULL DEFAULT false,
138
+ can_write boolean NOT NULL DEFAULT false,
139
+ can_update boolean NOT NULL DEFAULT false,
140
+ can_delete boolean NOT NULL DEFAULT false,
141
+ PRIMARY KEY (role_id, resource)
142
+ );
143
+
144
+ CREATE OR REPLACE FUNCTION rbac.check_role_resource_scope()
145
+ RETURNS trigger
146
+ LANGUAGE plpgsql
147
+ SET search_path = rbac, public
148
+ AS $$
149
+ DECLARE
150
+ v_role_scope text;
151
+ v_resource_scope text;
152
+ BEGIN
153
+ SELECT scope INTO v_role_scope FROM rbac.roles WHERE id = NEW.role_id;
154
+ SELECT scope INTO v_resource_scope FROM rbac.resources WHERE resource = NEW.resource;
155
+ IF v_role_scope IS DISTINCT FROM v_resource_scope THEN
156
+ RAISE EXCEPTION
157
+ 'rbac: role scope (%) does not match resource scope (%) for resource %',
158
+ v_role_scope, v_resource_scope, NEW.resource;
159
+ END IF;
160
+ RETURN NEW;
161
+ END $$;
162
+
163
+ DROP TRIGGER IF EXISTS role_resource_scope_trg
164
+ ON rbac.role_permissions;
165
+ CREATE TRIGGER role_resource_scope_trg
166
+ BEFORE INSERT OR UPDATE ON rbac.role_permissions
167
+ FOR EACH ROW EXECUTE FUNCTION rbac.check_role_resource_scope();
168
+
169
+ -- ─────────────────────────────────────────────────────────────────
170
+ -- 5. Assignments — system + company memberships
171
+ -- ─────────────────────────────────────────────────────────────────
172
+
173
+ CREATE TABLE IF NOT EXISTS rbac.user_system_roles (
174
+ user_id uuid NOT NULL,
175
+ role_id uuid NOT NULL REFERENCES rbac.roles(id),
176
+ assigned_at timestamptz NOT NULL DEFAULT now(),
177
+ assigned_by uuid,
178
+ PRIMARY KEY (user_id, role_id)
179
+ );
180
+
181
+ CREATE TABLE IF NOT EXISTS rbac.user_company_roles (
182
+ user_id uuid NOT NULL,
183
+ company_id uuid NOT NULL REFERENCES rbac.companies(id) ON DELETE CASCADE,
184
+ role_id uuid NOT NULL REFERENCES rbac.roles(id),
185
+ assigned_at timestamptz NOT NULL DEFAULT now(),
186
+ assigned_by uuid,
187
+ PRIMARY KEY (user_id, company_id, role_id)
188
+ );
189
+
190
+ CREATE INDEX IF NOT EXISTS user_company_roles_user_idx
191
+ ON rbac.user_company_roles (user_id);
192
+
193
+ CREATE INDEX IF NOT EXISTS user_company_roles_company_idx
194
+ ON rbac.user_company_roles (company_id);
195
+
196
+ -- ─────────────────────────────────────────────────────────────────
197
+ -- 6. Resolver function — the single source of truth for "can?"
198
+ -- ─────────────────────────────────────────────────────────────────
199
+
200
+ CREATE OR REPLACE FUNCTION rbac.user_can(
201
+ p_user_id uuid,
202
+ p_resource text,
203
+ p_action text,
204
+ p_company_id uuid DEFAULT NULL
205
+ ) RETURNS boolean
206
+ LANGUAGE plpgsql
207
+ STABLE
208
+ SECURITY DEFINER
209
+ SET search_path = rbac, public
210
+ AS $$
211
+ DECLARE
212
+ v_resource_scope text;
213
+ BEGIN
214
+ -- Super-admin bypass: any system role with is_super=true grants
215
+ -- every permission everywhere.
216
+ IF EXISTS (
217
+ SELECT 1
218
+ FROM rbac.user_system_roles usr
219
+ JOIN rbac.roles r ON r.id = usr.role_id
220
+ WHERE usr.user_id = p_user_id
221
+ AND r.is_super
222
+ ) THEN
223
+ RETURN true;
224
+ END IF;
225
+
226
+ SELECT scope INTO v_resource_scope
227
+ FROM rbac.resources
228
+ WHERE resource = p_resource;
229
+
230
+ IF v_resource_scope IS NULL THEN
231
+ RETURN false;
232
+ END IF;
233
+
234
+ IF p_action NOT IN ('read', 'write', 'update', 'delete') THEN
235
+ RETURN false;
236
+ END IF;
237
+
238
+ IF v_resource_scope = 'system' THEN
239
+ RETURN EXISTS (
240
+ SELECT 1
241
+ FROM rbac.user_system_roles usr
242
+ JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
243
+ WHERE usr.user_id = p_user_id
244
+ AND rp.resource = p_resource
245
+ AND CASE p_action
246
+ WHEN 'read' THEN rp.can_read
247
+ WHEN 'write' THEN rp.can_write
248
+ WHEN 'update' THEN rp.can_update
249
+ WHEN 'delete' THEN rp.can_delete
250
+ END
251
+ );
252
+ END IF;
253
+
254
+ IF p_company_id IS NULL THEN
255
+ RETURN false;
256
+ END IF;
257
+
258
+ RETURN EXISTS (
259
+ SELECT 1
260
+ FROM rbac.user_company_roles ucr
261
+ JOIN rbac.role_permissions rp ON rp.role_id = ucr.role_id
262
+ WHERE ucr.user_id = p_user_id
263
+ AND ucr.company_id = p_company_id
264
+ AND rp.resource = p_resource
265
+ AND CASE p_action
266
+ WHEN 'read' THEN rp.can_read
267
+ WHEN 'write' THEN rp.can_write
268
+ WHEN 'update' THEN rp.can_update
269
+ WHEN 'delete' THEN rp.can_delete
270
+ END
271
+ );
272
+ END $$;
273
+
274
+ -- ─────────────────────────────────────────────────────────────────
275
+ -- 7. Convenience: hydrate a user's full profile in one call
276
+ -- ─────────────────────────────────────────────────────────────────
277
+
278
+ CREATE OR REPLACE FUNCTION rbac.user_profile(p_user_id uuid)
279
+ RETURNS jsonb
280
+ LANGUAGE sql
281
+ STABLE
282
+ SECURITY DEFINER
283
+ SET search_path = rbac, public
284
+ AS $$
285
+ WITH
286
+ system_roles AS (
287
+ SELECT r.id, r.name, r.is_super, r.frontend_config
288
+ FROM rbac.user_system_roles usr
289
+ JOIN rbac.roles r ON r.id = usr.role_id
290
+ WHERE usr.user_id = p_user_id
291
+ ),
292
+ system_perms AS (
293
+ SELECT rp.resource,
294
+ bool_or(rp.can_read) AS can_read,
295
+ bool_or(rp.can_write) AS can_write,
296
+ bool_or(rp.can_update) AS can_update,
297
+ bool_or(rp.can_delete) AS can_delete
298
+ FROM rbac.user_system_roles usr
299
+ JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
300
+ WHERE usr.user_id = p_user_id
301
+ GROUP BY rp.resource
302
+ ),
303
+ memberships AS (
304
+ SELECT c.id AS company_id,
305
+ c.name AS company_name,
306
+ c.slug AS company_slug,
307
+ jsonb_agg(DISTINCT jsonb_build_object(
308
+ 'id', r.id, 'name', r.name, 'is_system', r.is_system,
309
+ 'frontend_config', r.frontend_config
310
+ )) AS roles
311
+ FROM rbac.user_company_roles ucr
312
+ JOIN rbac.companies c ON c.id = ucr.company_id
313
+ JOIN rbac.roles r ON r.id = ucr.role_id
314
+ WHERE ucr.user_id = p_user_id
315
+ GROUP BY c.id, c.name, c.slug
316
+ ),
317
+ company_perms AS (
318
+ SELECT ucr.company_id,
319
+ rp.resource,
320
+ bool_or(rp.can_read) AS can_read,
321
+ bool_or(rp.can_write) AS can_write,
322
+ bool_or(rp.can_update) AS can_update,
323
+ bool_or(rp.can_delete) AS can_delete
324
+ FROM rbac.user_company_roles ucr
325
+ JOIN rbac.role_permissions rp ON rp.role_id = ucr.role_id
326
+ WHERE ucr.user_id = p_user_id
327
+ GROUP BY ucr.company_id, rp.resource
328
+ )
329
+ SELECT jsonb_build_object(
330
+ 'user_id', p_user_id,
331
+ 'is_super_admin', EXISTS (SELECT 1 FROM system_roles WHERE is_super),
332
+ 'system_roles', coalesce(
333
+ (SELECT jsonb_agg(jsonb_build_object('id', id, 'name', name)) FROM system_roles),
334
+ '[]'::jsonb
335
+ ),
336
+ 'system_permissions', coalesce(
337
+ (SELECT jsonb_object_agg(resource, jsonb_build_object(
338
+ 'read', can_read, 'write', can_write,
339
+ 'update', can_update, 'delete', can_delete
340
+ )) FROM system_perms),
341
+ '{}'::jsonb
342
+ ),
343
+ 'memberships', coalesce(
344
+ (SELECT jsonb_agg(jsonb_build_object(
345
+ 'company_id', m.company_id,
346
+ 'company_name', m.company_name,
347
+ 'company_slug', m.company_slug,
348
+ 'roles', m.roles,
349
+ 'permissions', coalesce(
350
+ (SELECT jsonb_object_agg(cp.resource, jsonb_build_object(
351
+ 'read', cp.can_read, 'write', cp.can_write,
352
+ 'update', cp.can_update, 'delete', cp.can_delete
353
+ ))
354
+ FROM company_perms cp
355
+ WHERE cp.company_id = m.company_id),
356
+ '{}'::jsonb
357
+ )
358
+ )) FROM memberships m),
359
+ '[]'::jsonb
360
+ )
361
+ );
362
+ $$;
363
+
364
+ -- ─────────────────────────────────────────────────────────────────
365
+ -- 7b. Template defaults — pre-fill role_permissions from a JSONB
366
+ -- pattern carried on the role row. Run after sync_resources() so
367
+ -- newly registered resources pick up the right cells without an
368
+ -- admin opening the matrix UI.
369
+ -- ─────────────────────────────────────────────────────────────────
370
+
371
+ CREATE OR REPLACE FUNCTION rbac.apply_template_defaults(
372
+ p_role_id uuid,
373
+ p_only_missing boolean DEFAULT true
374
+ ) RETURNS int
375
+ LANGUAGE plpgsql
376
+ SECURITY DEFINER
377
+ SET search_path = rbac, public
378
+ AS $$
379
+ DECLARE
380
+ v_role rbac.roles;
381
+ v_dp jsonb;
382
+ v_default jsonb;
383
+ v_groups jsonb;
384
+ v_resources jsonb;
385
+ v_resource record;
386
+ v_actions jsonb;
387
+ v_count int := 0;
388
+ BEGIN
389
+ SELECT * INTO v_role FROM rbac.roles WHERE id = p_role_id;
390
+ IF NOT FOUND THEN
391
+ RETURN 0;
392
+ END IF;
393
+
394
+ v_dp := COALESCE(v_role.default_permissions, '{}'::jsonb);
395
+ v_default := COALESCE(v_dp -> 'default', '[]'::jsonb);
396
+ v_groups := COALESCE(v_dp -> 'groups', '{}'::jsonb);
397
+ v_resources := COALESCE(v_dp -> 'resources', '{}'::jsonb);
398
+
399
+ FOR v_resource IN
400
+ SELECT resource, group_label
401
+ FROM rbac.resources
402
+ WHERE scope = v_role.scope
403
+ LOOP
404
+ -- Skip if the matrix already has any cell for this (role, resource)
405
+ IF p_only_missing AND EXISTS (
406
+ SELECT 1 FROM rbac.role_permissions
407
+ WHERE role_id = p_role_id
408
+ AND resource = v_resource.resource
409
+ ) THEN
410
+ CONTINUE;
411
+ END IF;
412
+
413
+ -- Pick action set: explicit resource override → group default → fallback default
414
+ v_actions := COALESCE(
415
+ v_resources -> v_resource.resource,
416
+ CASE WHEN v_resource.group_label IS NOT NULL
417
+ THEN v_groups -> v_resource.group_label
418
+ ELSE NULL END,
419
+ v_default
420
+ );
421
+
422
+ IF v_actions IS NULL OR jsonb_array_length(v_actions) = 0 THEN
423
+ CONTINUE;
424
+ END IF;
425
+
426
+ INSERT INTO rbac.role_permissions
427
+ (role_id, resource, can_read, can_write, can_update, can_delete)
428
+ VALUES (
429
+ p_role_id,
430
+ v_resource.resource,
431
+ v_actions ? 'read',
432
+ v_actions ? 'write',
433
+ v_actions ? 'update',
434
+ v_actions ? 'delete'
435
+ )
436
+ ON CONFLICT (role_id, resource) DO UPDATE
437
+ SET can_read = EXCLUDED.can_read OR rbac.role_permissions.can_read,
438
+ can_write = EXCLUDED.can_write OR rbac.role_permissions.can_write,
439
+ can_update = EXCLUDED.can_update OR rbac.role_permissions.can_update,
440
+ can_delete = EXCLUDED.can_delete OR rbac.role_permissions.can_delete;
441
+
442
+ v_count := v_count + 1;
443
+ END LOOP;
444
+
445
+ RETURN v_count;
446
+ END $$;
447
+
448
+ -- ─────────────────────────────────────────────────────────────────
449
+ -- 8. Block delete/rename of system rows
450
+ -- ─────────────────────────────────────────────────────────────────
451
+
452
+ CREATE OR REPLACE FUNCTION rbac.protect_system_role()
453
+ RETURNS trigger
454
+ LANGUAGE plpgsql
455
+ SET search_path = rbac, public
456
+ AS $$
457
+ BEGIN
458
+ IF TG_OP = 'DELETE' THEN
459
+ IF OLD.is_system THEN
460
+ RAISE EXCEPTION 'rbac: cannot delete system role %', OLD.name;
461
+ END IF;
462
+ RETURN OLD;
463
+ END IF;
464
+ IF OLD.is_system AND OLD.name IS DISTINCT FROM NEW.name THEN
465
+ RAISE EXCEPTION 'rbac: cannot rename system role %', OLD.name;
466
+ END IF;
467
+ IF OLD.is_super AND NOT NEW.is_super THEN
468
+ RAISE EXCEPTION 'rbac: cannot revoke super-admin flag';
469
+ END IF;
470
+ RETURN NEW;
471
+ END $$;
472
+
473
+ DROP TRIGGER IF EXISTS protect_system_role_trg ON rbac.roles;
474
+ CREATE TRIGGER protect_system_role_trg
475
+ BEFORE UPDATE OR DELETE ON rbac.roles
476
+ FOR EACH ROW EXECUTE FUNCTION rbac.protect_system_role();
477
+
478
+ -- ─────────────────────────────────────────────────────────────────
479
+ -- 9. updated_at touchers
480
+ -- ─────────────────────────────────────────────────────────────────
481
+
482
+ CREATE OR REPLACE FUNCTION rbac.touch_updated_at()
483
+ RETURNS trigger
484
+ LANGUAGE plpgsql AS $$
485
+ BEGIN
486
+ NEW.updated_at = now();
487
+ RETURN NEW;
488
+ END $$;
489
+
490
+ DROP TRIGGER IF EXISTS companies_touch_trg ON rbac.companies;
491
+ CREATE TRIGGER companies_touch_trg
492
+ BEFORE UPDATE ON rbac.companies
493
+ FOR EACH ROW EXECUTE FUNCTION rbac.touch_updated_at();
494
+
495
+ DROP TRIGGER IF EXISTS roles_touch_trg ON rbac.roles;
496
+ CREATE TRIGGER roles_touch_trg
497
+ BEFORE UPDATE ON rbac.roles
498
+ FOR EACH ROW EXECUTE FUNCTION rbac.touch_updated_at();
499
+
500
+ -- ─────────────────────────────────────────────────────────────────
501
+ -- 10. EXECUTE on the resolver/hydrator for authenticated users
502
+ -- ─────────────────────────────────────────────────────────────────
503
+
504
+ DO $$ BEGIN
505
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
506
+ EXECUTE 'GRANT EXECUTE ON FUNCTION rbac.user_can(uuid, text, text, uuid) TO authenticated';
507
+ EXECUTE 'GRANT EXECUTE ON FUNCTION rbac.user_profile(uuid) TO authenticated';
508
+ EXECUTE 'GRANT EXECUTE ON FUNCTION rbac.apply_template_defaults(uuid, boolean) TO authenticated';
509
+ END IF;
510
+ END $$;
511
+
512
+ COMMIT;
@@ -0,0 +1,57 @@
1
+ -- snipe-auth-rbac — optional default seed
2
+ --
3
+ -- Companion to 0001_initial.sql that seeds:
4
+ -- * Two system roles (System Admin with is_super=true, System Support).
5
+ -- * Four generic company-role templates (Owner / Manager / Member /
6
+ -- Viewer) with sensible default_permissions patterns.
7
+ --
8
+ -- The four templates use only the `default` action set — they don't
9
+ -- reference specific resources or groups, since those are defined by
10
+ -- the host. After registering host resources, run
11
+ -- ``rbac.apply_template_defaults(role_id)`` to materialise the matrix.
12
+ --
13
+ -- Domain-specific templates (Property Manager, Tenant Manager,
14
+ -- Sales, …) belong in the host's own seed migration where their
15
+ -- group/resource defaults can reference real registered resources.
16
+ --
17
+ -- Idempotent: every INSERT uses ON CONFLICT DO NOTHING. Re-running
18
+ -- the file leaves an existing deployment untouched.
19
+
20
+ BEGIN;
21
+
22
+ -- System roles
23
+ INSERT INTO rbac.roles (id, scope, company_id, name, description, is_system, is_super, default_permissions)
24
+ VALUES
25
+ (gen_random_uuid(), 'system', NULL, 'System Admin',
26
+ 'Plattform-Vollzugriff. Setzt jede Berechtigungsprüfung außer Kraft.',
27
+ true, true,
28
+ '{"default": ["read", "write", "update", "delete"]}'::jsonb),
29
+ (gen_random_uuid(), 'system', NULL, 'System Support',
30
+ 'Lesezugriff auf systemweite Ressourcen für Support-Aufgaben.',
31
+ true, false,
32
+ '{"default": ["read"]}'::jsonb)
33
+ ON CONFLICT DO NOTHING;
34
+
35
+ -- Company-role templates (company_id IS NULL = template).
36
+ -- Generic shapes only; domain-specific defaults are the host's job.
37
+ INSERT INTO rbac.roles (id, scope, company_id, name, description, is_system, is_super, default_permissions)
38
+ VALUES
39
+ (gen_random_uuid(), 'company', NULL, 'Owner',
40
+ 'Vollzugriff innerhalb der eigenen Company.',
41
+ true, false,
42
+ '{"default": ["read", "write", "update", "delete"]}'::jsonb),
43
+ (gen_random_uuid(), 'company', NULL, 'Manager',
44
+ 'Verwaltet Daten der Company, kann Rollen ändern. Kein Löschen.',
45
+ true, false,
46
+ '{"default": ["read", "write", "update"]}'::jsonb),
47
+ (gen_random_uuid(), 'company', NULL, 'Member',
48
+ 'Standard-Mitarbeiter mit Lese- und Schreibzugriff.',
49
+ true, false,
50
+ '{"default": ["read", "write"]}'::jsonb),
51
+ (gen_random_uuid(), 'company', NULL, 'Viewer',
52
+ 'Nur Lesezugriff.',
53
+ true, false,
54
+ '{"default": ["read"]}'::jsonb)
55
+ ON CONFLICT DO NOTHING;
56
+
57
+ COMMIT;
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/client.ts"],"sourcesContent":["/**\n * Transport-agnostic client: turns an adopter-supplied\n * `AuthRbacFetcher` into a permission resolver. The React provider\n * wraps this; non-React consumers (Node scripts, edge functions)\n * can use it directly.\n */\n\nimport type {\n Action,\n AuthRbacFetcher,\n PermissionMap,\n ResourceDescriptor,\n ResourceRegistry,\n ResourceScope,\n UserProfile,\n} from \"./types.js\";\n\nexport interface AuthRbacClientOptions {\n fetcher: AuthRbacFetcher;\n /**\n * The host project's full resource list. Required so the resolver\n * can look up a resource's scope without a DB round-trip per call.\n * Re-using the same array the host syncs into the\n * `auth_rbac_resources` table at boot keeps everything in lockstep.\n */\n resources: ResourceRegistry;\n}\n\nexport interface CanOptions {\n /**\n * Override the active company. Omit to use the company the\n * caller has currently activated (the React provider tracks\n * this; for direct client use you must pass it).\n */\n companyId?: string | null;\n}\n\n/**\n * Pure resolver. Given a hydrated profile it answers boolean\n * questions instantly — no I/O. The `resourceMap` is built once at\n * construction so per-call work is two map lookups.\n */\nexport function buildPermissionResolver(\n resources: ResourceRegistry,\n profile: UserProfile,\n defaultCompanyId: string | null,\n) {\n const scopeByResource = new Map<string, ResourceScope>(\n resources.map((r) => [r.resource, r.scope]),\n );\n\n const can = (\n resource: string,\n action: Action,\n options?: CanOptions,\n ): boolean => {\n if (profile.is_super_admin) {\n return true;\n }\n const scope = scopeByResource.get(resource);\n if (!scope) {\n // Unknown resource — fail closed.\n return false;\n }\n if (scope === \"system\") {\n return readGrid(profile.system_permissions, resource, action);\n }\n const companyId = options?.companyId ?? defaultCompanyId;\n if (!companyId) {\n return false;\n }\n const membership = profile.memberships.find(\n (m) => m.company_id === companyId,\n );\n if (!membership) {\n return false;\n }\n return readGrid(membership.permissions, resource, action);\n };\n\n return {\n can,\n /** Permission map for the active (or specified) company. */\n activePermissions: (companyId?: string | null): PermissionMap => {\n const id = companyId ?? defaultCompanyId;\n if (!id) {\n return {};\n }\n return (\n profile.memberships.find((m) => m.company_id === id)?.permissions ?? {}\n );\n },\n systemPermissions: (): PermissionMap => profile.system_permissions,\n };\n}\n\nfunction readGrid(\n map: PermissionMap,\n resource: string,\n action: Action,\n): boolean {\n const grid = map[resource];\n if (!grid) {\n return false;\n }\n return grid[action];\n}\n\n/**\n * Helper: groups a resource registry by `group` for the matrix UI.\n * Returns groups in insertion order with their resources.\n */\nexport function groupResources(\n registry: ResourceRegistry,\n): Array<{ group: string; resources: ResourceDescriptor[] }> {\n const order: string[] = [];\n const buckets = new Map<string, ResourceDescriptor[]>();\n for (const r of registry) {\n const key = r.group ?? \"Sonstige\";\n if (!buckets.has(key)) {\n buckets.set(key, []);\n order.push(key);\n }\n buckets.get(key)!.push(r);\n }\n return order.map((g) => ({ group: g, resources: buckets.get(g)! }));\n}\n\nexport type AuthRbacClient = ReturnType<typeof buildPermissionResolver>;\nexport type { AuthRbacClientOptions as ClientOptions };\n"],"mappings":";AA0CO,SAAS,wBACd,WACA,SACA,kBACA;AACA,QAAM,kBAAkB,IAAI;AAAA,IAC1B,UAAU,IAAI,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC;AAAA,EAC5C;AAEA,QAAM,MAAM,CACV,UACA,QACA,YACY;AACZ,QAAI,QAAQ,gBAAgB;AAC1B,aAAO;AAAA,IACT;AACA,UAAM,QAAQ,gBAAgB,IAAI,QAAQ;AAC1C,QAAI,CAAC,OAAO;AAEV,aAAO;AAAA,IACT;AACA,QAAI,UAAU,UAAU;AACtB,aAAO,SAAS,QAAQ,oBAAoB,UAAU,MAAM;AAAA,IAC9D;AACA,UAAM,YAAY,SAAS,aAAa;AACxC,QAAI,CAAC,WAAW;AACd,aAAO;AAAA,IACT;AACA,UAAM,aAAa,QAAQ,YAAY;AAAA,MACrC,CAAC,MAAM,EAAE,eAAe;AAAA,IAC1B;AACA,QAAI,CAAC,YAAY;AACf,aAAO;AAAA,IACT;AACA,WAAO,SAAS,WAAW,aAAa,UAAU,MAAM;AAAA,EAC1D;AAEA,SAAO;AAAA,IACL;AAAA;AAAA,IAEA,mBAAmB,CAAC,cAA6C;AAC/D,YAAM,KAAK,aAAa;AACxB,UAAI,CAAC,IAAI;AACP,eAAO,CAAC;AAAA,MACV;AACA,aACE,QAAQ,YAAY,KAAK,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,eAAe,CAAC;AAAA,IAE1E;AAAA,IACA,mBAAmB,MAAqB,QAAQ;AAAA,EAClD;AACF;AAEA,SAAS,SACP,KACA,UACA,QACS;AACT,QAAM,OAAO,IAAI,QAAQ;AACzB,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AACA,SAAO,KAAK,MAAM;AACpB;AAMO,SAAS,eACd,UAC2D;AAC3D,QAAM,QAAkB,CAAC;AACzB,QAAM,UAAU,oBAAI,IAAkC;AACtD,aAAW,KAAK,UAAU;AACxB,UAAM,MAAM,EAAE,SAAS;AACvB,QAAI,CAAC,QAAQ,IAAI,GAAG,GAAG;AACrB,cAAQ,IAAI,KAAK,CAAC,CAAC;AACnB,YAAM,KAAK,GAAG;AAAA,IAChB;AACA,YAAQ,IAAI,GAAG,EAAG,KAAK,CAAC;AAAA,EAC1B;AACA,SAAO,MAAM,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,WAAW,QAAQ,IAAI,CAAC,EAAG,EAAE;AACpE;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/fetchers.ts"],"sourcesContent":["/**\n * Built-in fetchers — adopters can use these or pass their own\n * implementation of `AuthRbacFetcher`.\n */\n\nimport type { AuthRbacFetcher, UserProfile } from \"./types.js\";\n\n/**\n * Calls the package's SQL function `auth_rbac_user_profile(uuid)` via\n * a Supabase JS client. Easiest path when the host project already\n * uses Supabase.\n *\n * @example\n * createSupabaseFetcher({ supabase, userId: session.user.id })\n */\nexport function createSupabaseFetcher(opts: {\n supabase: {\n rpc: (\n fn: string,\n args: Record<string, unknown>,\n ) => Promise<{ data: unknown; error: { message: string } | null }>;\n };\n userId: string;\n}): AuthRbacFetcher {\n return {\n async fetchProfile(): Promise<UserProfile> {\n const { data, error } = await opts.supabase.rpc(\n \"auth_rbac_user_profile\",\n { p_user_id: opts.userId },\n );\n if (error) {\n throw new Error(\n `auth-rbac: failed to load user profile via Supabase RPC: ${error.message}`,\n );\n }\n return normalizeProfile(data);\n },\n };\n}\n\n/**\n * Calls a regular HTTP endpoint that returns a `UserProfile` JSON\n * payload. Use this when the host project has its own backend that\n * wraps the package's Python helpers (or any equivalent).\n *\n * @example\n * createHttpFetcher({ url: \"/api/users/me/profile\" })\n */\nexport function createHttpFetcher(opts: {\n url: string;\n /** Forwarded as-is to `fetch`. Use this to attach auth headers. */\n init?: RequestInit;\n /** Override the global `fetch` if you're in a non-browser env. */\n fetch?: typeof fetch;\n}): AuthRbacFetcher {\n const fetchImpl = opts.fetch ?? globalThis.fetch;\n return {\n async fetchProfile(): Promise<UserProfile> {\n const res = await fetchImpl(opts.url, opts.init);\n if (!res.ok) {\n throw new Error(\n `auth-rbac: profile endpoint ${opts.url} returned ${res.status}`,\n );\n }\n const json = (await res.json()) as unknown;\n return normalizeProfile(json);\n },\n };\n}\n\n/**\n * Defensive normalisation: the Supabase RPC returns whatever the SQL\n * function emitted. We coerce missing fields into the empty defaults\n * so consumers can iterate without null checks. Throws if the shape\n * is unrecognisable.\n */\nfunction normalizeProfile(raw: unknown): UserProfile {\n if (!raw || typeof raw !== \"object\") {\n throw new Error(\"auth-rbac: profile payload is not an object\");\n }\n const p = raw as Partial<UserProfile> & Record<string, unknown>;\n if (typeof p.user_id !== \"string\") {\n throw new Error(\"auth-rbac: profile payload missing user_id\");\n }\n return {\n user_id: p.user_id,\n is_super_admin: !!p.is_super_admin,\n system_roles: Array.isArray(p.system_roles) ? p.system_roles : [],\n system_permissions:\n p.system_permissions && typeof p.system_permissions === \"object\"\n ? (p.system_permissions as UserProfile[\"system_permissions\"])\n : {},\n system_frontend_config:\n p.system_frontend_config && typeof p.system_frontend_config === \"object\"\n ? (p.system_frontend_config as UserProfile[\"system_frontend_config\"])\n : {},\n memberships: Array.isArray(p.memberships)\n ? (p.memberships as UserProfile[\"memberships\"])\n : [],\n };\n}\n"],"mappings":";AAeO,SAAS,sBAAsB,MAQlB;AAClB,SAAO;AAAA,IACL,MAAM,eAAqC;AACzC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,KAAK,SAAS;AAAA,QAC1C;AAAA,QACA,EAAE,WAAW,KAAK,OAAO;AAAA,MAC3B;AACA,UAAI,OAAO;AACT,cAAM,IAAI;AAAA,UACR,4DAA4D,MAAM,OAAO;AAAA,QAC3E;AAAA,MACF;AACA,aAAO,iBAAiB,IAAI;AAAA,IAC9B;AAAA,EACF;AACF;AAUO,SAAS,kBAAkB,MAMd;AAClB,QAAM,YAAY,KAAK,SAAS,WAAW;AAC3C,SAAO;AAAA,IACL,MAAM,eAAqC;AACzC,YAAM,MAAM,MAAM,UAAU,KAAK,KAAK,KAAK,IAAI;AAC/C,UAAI,CAAC,IAAI,IAAI;AACX,cAAM,IAAI;AAAA,UACR,+BAA+B,KAAK,GAAG,aAAa,IAAI,MAAM;AAAA,QAChE;AAAA,MACF;AACA,YAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,aAAO,iBAAiB,IAAI;AAAA,IAC9B;AAAA,EACF;AACF;AAQA,SAAS,iBAAiB,KAA2B;AACnD,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,UAAM,IAAI,MAAM,6CAA6C;AAAA,EAC/D;AACA,QAAM,IAAI;AACV,MAAI,OAAO,EAAE,YAAY,UAAU;AACjC,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AACA,SAAO;AAAA,IACL,SAAS,EAAE;AAAA,IACX,gBAAgB,CAAC,CAAC,EAAE;AAAA,IACpB,cAAc,MAAM,QAAQ,EAAE,YAAY,IAAI,EAAE,eAAe,CAAC;AAAA,IAChE,oBACE,EAAE,sBAAsB,OAAO,EAAE,uBAAuB,WACnD,EAAE,qBACH,CAAC;AAAA,IACP,wBACE,EAAE,0BAA0B,OAAO,EAAE,2BAA2B,WAC3D,EAAE,yBACH,CAAC;AAAA,IACP,aAAa,MAAM,QAAQ,EAAE,WAAW,IACnC,EAAE,cACH,CAAC;AAAA,EACP;AACF;","names":[]}