snipe-auth-rbac 0.5.0 → 0.6.1

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.
@@ -176,6 +176,35 @@ CREATE TABLE IF NOT EXISTS rbac.resource_dependencies (
176
176
  CONSTRAINT resource_dependencies_no_self CHECK (parent_resource <> child_resource)
177
177
  );
178
178
 
179
+ -- 0.6.0+: Per-role admin override of implied permissions.
180
+ --
181
+ -- Granting a parent like `properties:read` implies a read on all of
182
+ -- properties.dependsOn at lookup time. Sometimes an admin wants a
183
+ -- role to have the parent but NOT a specific dependent — Anwalt
184
+ -- gets tenants/leases but must not see payments, even though
185
+ -- tenants.dependsOn includes payments.
186
+ --
187
+ -- A row here is a suppression: "this role does NOT have this
188
+ -- (resource, action), even if rbac.resource_dependencies would
189
+ -- otherwise imply it from a direct grant on the parent." Resolver
190
+ -- functions (rbac.user_can, rbac.user_profile) subtract these rows
191
+ -- from the expanded grant set.
192
+ --
193
+ -- Direct grants are unaffected — if the role has a direct
194
+ -- rbac.role_permissions row granting the action, that row takes
195
+ -- precedence over any override on the same cell.
196
+ CREATE TABLE IF NOT EXISTS rbac.role_permission_overrides (
197
+ role_id uuid NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE,
198
+ resource text NOT NULL REFERENCES rbac.resources(resource) ON DELETE CASCADE,
199
+ action text NOT NULL CHECK (action IN ('read','write','update','delete')),
200
+ created_at timestamptz NOT NULL DEFAULT now(),
201
+ created_by uuid,
202
+ PRIMARY KEY (role_id, resource, action)
203
+ );
204
+
205
+ CREATE INDEX IF NOT EXISTS role_permission_overrides_resource_action_idx
206
+ ON rbac.role_permission_overrides (resource, action);
207
+
179
208
  CREATE OR REPLACE FUNCTION rbac.check_role_resource_scope()
180
209
  RETURNS trigger
181
210
  LANGUAGE plpgsql
@@ -284,7 +313,11 @@ BEGIN
284
313
 
285
314
  IF v_resource_scope = 'system' THEN
286
315
  RETURN EXISTS (
287
- -- Direct grant on the resource at system scope.
316
+ -- Direct grant on the resource at system scope, on a
317
+ -- role without an override on this (resource, action).
318
+ -- 0.6.0+: overrides apply to direct grants too — if an
319
+ -- admin explicitly suppresses payments:read on a role,
320
+ -- the role doesn't get it even if a direct row exists.
288
321
  SELECT 1
289
322
  FROM rbac.user_system_roles usr
290
323
  JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
@@ -296,12 +329,19 @@ BEGIN
296
329
  WHEN 'update' THEN rp.can_update
297
330
  WHEN 'delete' THEN rp.can_delete
298
331
  END
332
+ AND NOT EXISTS (
333
+ SELECT 1 FROM rbac.role_permission_overrides ovr
334
+ WHERE ovr.role_id = rp.role_id
335
+ AND ovr.resource = p_resource
336
+ AND ovr.action = p_action
337
+ )
299
338
  ) OR EXISTS (
300
339
  -- Implied via a direct grant on any parent at system
301
340
  -- scope. resource_dependencies edges are scope-agnostic
302
341
  -- (declared per-(parent,child,action)); the SCOPE of the
303
342
  -- inheritance is governed by where the parent grant
304
- -- lives.
343
+ -- lives. Override on the child suppresses the implied
344
+ -- access.
305
345
  SELECT 1
306
346
  FROM rbac.resource_dependencies d
307
347
  JOIN rbac.user_system_roles usr ON true
@@ -316,6 +356,12 @@ BEGIN
316
356
  WHEN 'update' THEN rp.can_update
317
357
  WHEN 'delete' THEN rp.can_delete
318
358
  END
359
+ AND NOT EXISTS (
360
+ SELECT 1 FROM rbac.role_permission_overrides ovr
361
+ WHERE ovr.role_id = rp.role_id
362
+ AND ovr.resource = p_resource
363
+ AND ovr.action = p_action
364
+ )
319
365
  );
320
366
  END IF;
321
367
 
@@ -338,6 +384,12 @@ BEGIN
338
384
  WHEN 'update' THEN rp.can_update
339
385
  WHEN 'delete' THEN rp.can_delete
340
386
  END
387
+ AND NOT EXISTS (
388
+ SELECT 1 FROM rbac.role_permission_overrides ovr
389
+ WHERE ovr.role_id = rp.role_id
390
+ AND ovr.resource = p_resource
391
+ AND ovr.action = p_action
392
+ )
341
393
  ) OR EXISTS (
342
394
  -- Implied via a direct grant on any parent in the same
343
395
  -- company.
@@ -356,6 +408,12 @@ BEGIN
356
408
  WHEN 'update' THEN rp.can_update
357
409
  WHEN 'delete' THEN rp.can_delete
358
410
  END
411
+ AND NOT EXISTS (
412
+ SELECT 1 FROM rbac.role_permission_overrides ovr
413
+ WHERE ovr.role_id = rp.role_id
414
+ AND ovr.resource = p_resource
415
+ AND ovr.action = p_action
416
+ )
359
417
  );
360
418
  END $$;
361
419
 
@@ -384,6 +442,10 @@ STABLE
384
442
  SECURITY DEFINER
385
443
  SET search_path = rbac, public
386
444
  AS $$
445
+ -- 0.6.0+: every grant is computed per-(role,resource) so that
446
+ -- rbac.role_permission_overrides can subtract suppressions on a
447
+ -- per-role basis. Aggregating to per-resource happens AFTER the
448
+ -- per-role overrides filter.
387
449
  WITH
388
450
  system_roles AS (
389
451
  SELECT r.id, r.name, r.is_super, r.frontend_config
@@ -391,32 +453,72 @@ AS $$
391
453
  JOIN rbac.roles r ON r.id = usr.role_id
392
454
  WHERE usr.user_id = p_user_id
393
455
  ),
394
- -- Direct grants at system scope, aggregated per resource.
395
- system_direct AS (
396
- SELECT rp.resource,
397
- bool_or(rp.can_read) AS can_read,
398
- bool_or(rp.can_write) AS can_write,
399
- bool_or(rp.can_update) AS can_update,
400
- bool_or(rp.can_delete) AS can_delete
456
+ system_role_grants AS (
457
+ SELECT usr.role_id, rp.resource,
458
+ rp.can_read AND NOT EXISTS (
459
+ SELECT 1 FROM rbac.role_permission_overrides ovr
460
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'read'
461
+ ) AS can_read,
462
+ rp.can_write AND NOT EXISTS (
463
+ SELECT 1 FROM rbac.role_permission_overrides ovr
464
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'write'
465
+ ) AS can_write,
466
+ rp.can_update AND NOT EXISTS (
467
+ SELECT 1 FROM rbac.role_permission_overrides ovr
468
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'update'
469
+ ) AS can_update,
470
+ rp.can_delete AND NOT EXISTS (
471
+ SELECT 1 FROM rbac.role_permission_overrides ovr
472
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'delete'
473
+ ) AS can_delete
401
474
  FROM rbac.user_system_roles usr
402
475
  JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
403
476
  WHERE usr.user_id = p_user_id
404
- GROUP BY rp.resource
405
477
  ),
406
- -- For every direct grant on a parent, fan out to children via
407
- -- rbac.resource_dependencies and AND-mask by the cascading action.
478
+ system_direct AS (
479
+ SELECT resource,
480
+ bool_or(can_read) AS can_read,
481
+ bool_or(can_write) AS can_write,
482
+ bool_or(can_update) AS can_update,
483
+ bool_or(can_delete) AS can_delete
484
+ FROM system_role_grants
485
+ GROUP BY resource
486
+ ),
487
+ -- Implied per role: for each parent direct-grant on a role, fan
488
+ -- out via resource_dependencies. Override on (role, child, action)
489
+ -- suppresses the child for that role.
490
+ system_implied_per_role AS (
491
+ SELECT g.role_id,
492
+ d.child_resource AS resource,
493
+ bool_or(d.action = 'read' AND g.can_read AND NOT EXISTS (
494
+ SELECT 1 FROM rbac.role_permission_overrides ovr
495
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'read'
496
+ )) AS can_read,
497
+ bool_or(d.action = 'write' AND g.can_write AND NOT EXISTS (
498
+ SELECT 1 FROM rbac.role_permission_overrides ovr
499
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'write'
500
+ )) AS can_write,
501
+ bool_or(d.action = 'update' AND g.can_update AND NOT EXISTS (
502
+ SELECT 1 FROM rbac.role_permission_overrides ovr
503
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'update'
504
+ )) AS can_update,
505
+ bool_or(d.action = 'delete' AND g.can_delete AND NOT EXISTS (
506
+ SELECT 1 FROM rbac.role_permission_overrides ovr
507
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'delete'
508
+ )) AS can_delete
509
+ FROM system_role_grants g
510
+ JOIN rbac.resource_dependencies d ON d.parent_resource = g.resource
511
+ GROUP BY g.role_id, d.child_resource
512
+ ),
408
513
  system_implied AS (
409
- SELECT d.child_resource AS resource,
410
- bool_or(d.action = 'read' AND sd.can_read) AS can_read,
411
- bool_or(d.action = 'write' AND sd.can_write) AS can_write,
412
- bool_or(d.action = 'update' AND sd.can_update) AS can_update,
413
- bool_or(d.action = 'delete' AND sd.can_delete) AS can_delete
414
- FROM system_direct sd
415
- JOIN rbac.resource_dependencies d ON d.parent_resource = sd.resource
416
- GROUP BY d.child_resource
514
+ SELECT resource,
515
+ bool_or(can_read) AS can_read,
516
+ bool_or(can_write) AS can_write,
517
+ bool_or(can_update) AS can_update,
518
+ bool_or(can_delete) AS can_delete
519
+ FROM system_implied_per_role
520
+ GROUP BY resource
417
521
  ),
418
- -- Expanded = direct UNION implied. bool_or merges duplicate
419
- -- (resource, action) hits across the two sources.
420
522
  system_expanded AS (
421
523
  SELECT resource,
422
524
  bool_or(can_read) AS can_read,
@@ -444,29 +546,67 @@ AS $$
444
546
  WHERE ucr.user_id = p_user_id
445
547
  GROUP BY c.id, c.name, c.slug
446
548
  ),
447
- -- Direct grants at company scope.
448
- company_direct AS (
449
- SELECT ucr.company_id,
450
- rp.resource,
451
- bool_or(rp.can_read) AS can_read,
452
- bool_or(rp.can_write) AS can_write,
453
- bool_or(rp.can_update) AS can_update,
454
- bool_or(rp.can_delete) AS can_delete
549
+ company_role_grants AS (
550
+ SELECT ucr.company_id, ucr.role_id, rp.resource,
551
+ rp.can_read AND NOT EXISTS (
552
+ SELECT 1 FROM rbac.role_permission_overrides ovr
553
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'read'
554
+ ) AS can_read,
555
+ rp.can_write AND NOT EXISTS (
556
+ SELECT 1 FROM rbac.role_permission_overrides ovr
557
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'write'
558
+ ) AS can_write,
559
+ rp.can_update AND NOT EXISTS (
560
+ SELECT 1 FROM rbac.role_permission_overrides ovr
561
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'update'
562
+ ) AS can_update,
563
+ rp.can_delete AND NOT EXISTS (
564
+ SELECT 1 FROM rbac.role_permission_overrides ovr
565
+ WHERE ovr.role_id = rp.role_id AND ovr.resource = rp.resource AND ovr.action = 'delete'
566
+ ) AS can_delete
455
567
  FROM rbac.user_company_roles ucr
456
568
  JOIN rbac.role_permissions rp ON rp.role_id = ucr.role_id
457
569
  WHERE ucr.user_id = p_user_id
458
- GROUP BY ucr.company_id, rp.resource
570
+ ),
571
+ company_direct AS (
572
+ SELECT company_id, resource,
573
+ bool_or(can_read) AS can_read,
574
+ bool_or(can_write) AS can_write,
575
+ bool_or(can_update) AS can_update,
576
+ bool_or(can_delete) AS can_delete
577
+ FROM company_role_grants
578
+ GROUP BY company_id, resource
579
+ ),
580
+ company_implied_per_role AS (
581
+ SELECT g.company_id, g.role_id, d.child_resource AS resource,
582
+ bool_or(d.action = 'read' AND g.can_read AND NOT EXISTS (
583
+ SELECT 1 FROM rbac.role_permission_overrides ovr
584
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'read'
585
+ )) AS can_read,
586
+ bool_or(d.action = 'write' AND g.can_write AND NOT EXISTS (
587
+ SELECT 1 FROM rbac.role_permission_overrides ovr
588
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'write'
589
+ )) AS can_write,
590
+ bool_or(d.action = 'update' AND g.can_update AND NOT EXISTS (
591
+ SELECT 1 FROM rbac.role_permission_overrides ovr
592
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'update'
593
+ )) AS can_update,
594
+ bool_or(d.action = 'delete' AND g.can_delete AND NOT EXISTS (
595
+ SELECT 1 FROM rbac.role_permission_overrides ovr
596
+ WHERE ovr.role_id = g.role_id AND ovr.resource = d.child_resource AND ovr.action = 'delete'
597
+ )) AS can_delete
598
+ FROM company_role_grants g
599
+ JOIN rbac.resource_dependencies d ON d.parent_resource = g.resource
600
+ GROUP BY g.company_id, g.role_id, d.child_resource
459
601
  ),
460
602
  company_implied AS (
461
- SELECT cd.company_id,
462
- d.child_resource AS resource,
463
- bool_or(d.action = 'read' AND cd.can_read) AS can_read,
464
- bool_or(d.action = 'write' AND cd.can_write) AS can_write,
465
- bool_or(d.action = 'update' AND cd.can_update) AS can_update,
466
- bool_or(d.action = 'delete' AND cd.can_delete) AS can_delete
467
- FROM company_direct cd
468
- JOIN rbac.resource_dependencies d ON d.parent_resource = cd.resource
469
- GROUP BY cd.company_id, d.child_resource
603
+ SELECT company_id, resource,
604
+ bool_or(can_read) AS can_read,
605
+ bool_or(can_write) AS can_write,
606
+ bool_or(can_update) AS can_update,
607
+ bool_or(can_delete) AS can_delete
608
+ FROM company_implied_per_role
609
+ GROUP BY company_id, resource
470
610
  ),
471
611
  company_expanded AS (
472
612
  SELECT company_id, resource,
@@ -481,22 +621,32 @@ AS $$
481
621
  ) u
482
622
  GROUP BY company_id, resource
483
623
  ),
484
- -- Direct-only stays the source of truth for canAccessSection().
624
+ -- direct_<action> maps reflect the underlying row set on
625
+ -- rbac.role_permissions — NOT post-override. canAccessSection
626
+ -- asks "is there a direct row" for navigation purposes, not "does
627
+ -- the role effectively have the action" — those are different
628
+ -- questions and the UI needs both.
485
629
  system_perms AS (
486
- SELECT resource,
487
- can_read AS direct_read,
488
- can_write AS direct_write,
489
- can_update AS direct_update,
490
- can_delete AS direct_delete
491
- FROM system_direct
630
+ SELECT rp.resource,
631
+ bool_or(rp.can_read) AS direct_read,
632
+ bool_or(rp.can_write) AS direct_write,
633
+ bool_or(rp.can_update) AS direct_update,
634
+ bool_or(rp.can_delete) AS direct_delete
635
+ FROM rbac.user_system_roles usr
636
+ JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
637
+ WHERE usr.user_id = p_user_id
638
+ GROUP BY rp.resource
492
639
  ),
493
640
  company_perms AS (
494
- SELECT company_id, resource,
495
- can_read AS direct_read,
496
- can_write AS direct_write,
497
- can_update AS direct_update,
498
- can_delete AS direct_delete
499
- FROM company_direct
641
+ SELECT ucr.company_id, rp.resource,
642
+ bool_or(rp.can_read) AS direct_read,
643
+ bool_or(rp.can_write) AS direct_write,
644
+ bool_or(rp.can_update) AS direct_update,
645
+ bool_or(rp.can_delete) AS direct_delete
646
+ FROM rbac.user_company_roles ucr
647
+ JOIN rbac.role_permissions rp ON rp.role_id = ucr.role_id
648
+ WHERE ucr.user_id = p_user_id
649
+ GROUP BY ucr.company_id, rp.resource
500
650
  )
501
651
  SELECT jsonb_build_object(
502
652
  'user_id', p_user_id,
@@ -807,6 +957,15 @@ DO $$ BEGIN
807
957
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
808
958
  EXECUTE 'GRANT EXECUTE ON FUNCTION rbac.is_super_admin() TO authenticated';
809
959
  EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA rbac TO authenticated';
960
+ -- 0.6.1+: ALTER DEFAULT PRIVILEGES so any future tables
961
+ -- added to the rbac schema (by adopters or by a later
962
+ -- version of this package) automatically get the same
963
+ -- authenticated CRUD grant. Prevents the
964
+ -- "rbac.role_permission_overrides missing UPDATE → upsert
965
+ -- silently rejected" class of bug that hit adopters who
966
+ -- ran 0001 on an existing DB before role_permission_overrides
967
+ -- existed.
968
+ EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA rbac GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO authenticated';
810
969
  END IF;
811
970
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
812
971
  EXECUTE 'GRANT ALL ON ALL TABLES IN SCHEMA rbac TO service_role';
@@ -815,12 +974,27 @@ DO $$ BEGIN
815
974
  END IF;
816
975
  END $$;
817
976
 
818
- ALTER TABLE rbac.companies ENABLE ROW LEVEL SECURITY;
819
- ALTER TABLE rbac.resources ENABLE ROW LEVEL SECURITY;
820
- ALTER TABLE rbac.roles ENABLE ROW LEVEL SECURITY;
821
- ALTER TABLE rbac.role_permissions ENABLE ROW LEVEL SECURITY;
822
- ALTER TABLE rbac.user_system_roles ENABLE ROW LEVEL SECURITY;
823
- ALTER TABLE rbac.user_company_roles ENABLE ROW LEVEL SECURITY;
977
+ -- 0.6.1+: explicit per-table re-grant for the two 0.4.0+ tables.
978
+ -- Belt-and-braces for adopters whose DB has the tables but whose
979
+ -- earlier 0001 run pre-dated the tables (so the schema-wide GRANT
980
+ -- above didn't cover them, and ALTER DEFAULT PRIVILEGES only
981
+ -- applies to tables created AFTER it). Idempotent re-granting
982
+ -- existing privileges is a no-op.
983
+ DO $$ BEGIN
984
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
985
+ EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON rbac.resource_dependencies TO authenticated';
986
+ EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON rbac.role_permission_overrides TO authenticated';
987
+ END IF;
988
+ END $$;
989
+
990
+ ALTER TABLE rbac.companies ENABLE ROW LEVEL SECURITY;
991
+ ALTER TABLE rbac.resources ENABLE ROW LEVEL SECURITY;
992
+ ALTER TABLE rbac.roles ENABLE ROW LEVEL SECURITY;
993
+ ALTER TABLE rbac.role_permissions ENABLE ROW LEVEL SECURITY;
994
+ ALTER TABLE rbac.user_system_roles ENABLE ROW LEVEL SECURITY;
995
+ ALTER TABLE rbac.user_company_roles ENABLE ROW LEVEL SECURITY;
996
+ ALTER TABLE rbac.resource_dependencies ENABLE ROW LEVEL SECURITY;
997
+ ALTER TABLE rbac.role_permission_overrides ENABLE ROW LEVEL SECURITY;
824
998
 
825
999
  -- READ — open to any authenticated user for static config tables;
826
1000
  -- own-row only on assignment tables (super-admin sees all).
@@ -870,6 +1044,31 @@ DROP POLICY IF EXISTS rbac_user_company_roles_write ON rbac.user_company_roles;
870
1044
  CREATE POLICY rbac_user_company_roles_write ON rbac.user_company_roles FOR ALL TO authenticated
871
1045
  USING (rbac.is_super_admin()) WITH CHECK (rbac.is_super_admin());
872
1046
 
1047
+ -- 0.4.0+ rbac.resource_dependencies — readable to any authenticated
1048
+ -- user (the admin matrix UI consults it on every render to compute
1049
+ -- implied state). Direct writes are super-admin only as defence-in-
1050
+ -- depth; in practice the SECURITY DEFINER RPC
1051
+ -- rbac.replace_resource_dependencies handles every write.
1052
+ DROP POLICY IF EXISTS rbac_resource_dependencies_read ON rbac.resource_dependencies;
1053
+ CREATE POLICY rbac_resource_dependencies_read ON rbac.resource_dependencies
1054
+ FOR SELECT TO authenticated USING (true);
1055
+
1056
+ DROP POLICY IF EXISTS rbac_resource_dependencies_write ON rbac.resource_dependencies;
1057
+ CREATE POLICY rbac_resource_dependencies_write ON rbac.resource_dependencies
1058
+ FOR ALL TO authenticated
1059
+ USING (rbac.is_super_admin()) WITH CHECK (rbac.is_super_admin());
1060
+
1061
+ -- 0.6.0+ rbac.role_permission_overrides — same read-anyone /
1062
+ -- write-super-admin pattern as the rest of the matrix tables.
1063
+ DROP POLICY IF EXISTS rbac_role_permission_overrides_read ON rbac.role_permission_overrides;
1064
+ CREATE POLICY rbac_role_permission_overrides_read ON rbac.role_permission_overrides
1065
+ FOR SELECT TO authenticated USING (true);
1066
+
1067
+ DROP POLICY IF EXISTS rbac_role_permission_overrides_write ON rbac.role_permission_overrides;
1068
+ CREATE POLICY rbac_role_permission_overrides_write ON rbac.role_permission_overrides
1069
+ FOR ALL TO authenticated
1070
+ USING (rbac.is_super_admin()) WITH CHECK (rbac.is_super_admin());
1071
+
873
1072
  COMMIT;
874
1073
 
875
1074
  -- Tell PostgREST to refresh schema cache so the policies + grants