feat(scope): unattributed and all org targets

Adds a first-class org target vocabulary shared by every rule kind: a
specific organization, unattributed (objects whose org is NULL), or all.
A role can now be granted the unassigned pile without a global rule.

- rules.scope gains 'unattributed' and 'all'
- user_rules.org_id accepts NULL to target unattributed objects
- org_in_scope partitions the classes: 'unattributed' matches only a NULL
  target, tree scopes never match one
- has_permission(user, activity, view) capability probe for UI gating
- current_org_filter() parses morbac.org_ids once into org UUIDs plus the
  unattributed-bucket flag (a JSON null element requests it)
- rls_check split by arity so NULL never carries two meanings:
  rls_check(activity, view) for tables with no org column,
  rls_check(activity, view, row_org_id[, row_user_id]) where a NULL
  row_org_id means the record is unattributed
- detect_rule_conflicts is scope-aware, so rules targeting different
  object sets no longer collide
This commit is contained in:
2026-07-24 22:35:03 +02:00
parent 6dd1026c5a
commit 03236e67d5
11 changed files with 630 additions and 72 deletions
+7 -4
View File
@@ -90,7 +90,8 @@ CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts(
p_view TEXT,
p_context_id UUID,
p_modality morbac.modality,
p_exclude_id UUID DEFAULT NULL
p_exclude_id UUID DEFAULT NULL,
p_scope TEXT DEFAULT 'self'
)
RETURNS TABLE(
conflicting_rule_id UUID,
@@ -121,6 +122,7 @@ BEGIN
AND r.activity = p_activity
AND r.view = p_view
AND r.context_id = p_context_id
AND r.scope = p_scope
AND r.modality != p_modality
AND (p_exclude_id IS NULL OR r.id != p_exclude_id)
AND (
@@ -132,8 +134,8 @@ BEGIN
END;
$$;
COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID) IS
'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission).';
COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID, TEXT) IS
'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission). Conflicts are scoped: only rules sharing the same scope compete, since different scopes target different object sets.';
-- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one
@@ -147,7 +149,8 @@ BEGIN
FOR v_conflict IN
SELECT * FROM morbac.detect_rule_conflicts(
NEW.org_id, NEW.role_id, NEW.activity, NEW.view, NEW.context_id, NEW.modality,
CASE WHEN TG_OP = 'UPDATE' THEN NEW.id ELSE NULL END
CASE WHEN TG_OP = 'UPDATE' THEN NEW.id ELSE NULL END,
NEW.scope
)
LOOP
RAISE WARNING 'Rule conflict: % (conflicts with rule %)',