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
+37 -4
View File
@@ -11,11 +11,17 @@
-- - When both a prohibition and a permission apply, the higher-priority rule wins
-- - Tie goes to prohibition (modality precedence from the Multi-OrBAC paper)
--
-- Org target (p_org_id): a specific org, or NULL meaning the object is
-- unattributed (has no org). NULL never means "any org".
--
-- Scope:
-- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...).
-- - rules.scope selects which objects a rule covers: a specific org
-- (self/subtree/descendants/...), 'unattributed', or 'all'.
-- Evaluated at query time via org_in_scope() — new orgs are covered automatically.
-- - cross_org_rules.source_org_id is always required: user must hold the role there.
-- - user_rules target a specific user directly (no role required).
-- - user_rules target a specific user directly (no role required); their org_id
-- is a specific org, or NULL for unattributed objects.
-- - global_rules apply to every org, unattributed included.
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
p_user_id UUID,
@@ -84,7 +90,7 @@ BEGIN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id = p_org_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
@@ -163,7 +169,7 @@ BEGIN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id = p_org_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
@@ -275,3 +281,30 @@ $$;
COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS
'Complete OrBAC authorization with caching (default) - use is_allowed_nocache() for debugging';
-- Capability probe: does the user hold the permission in ANY context (any org
-- they are a member of, or the no-org bucket, or via global rules)? Intended
-- for UI feature gating, not object-level enforcement. Prohibitions are honored
-- per context: a context counts only if is_allowed() returns true there.
-- Pure cross-org grants into a non-member org are not counted.
CREATE OR REPLACE FUNCTION morbac.has_permission(
p_user_id UUID,
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM morbac.get_user_orgs(p_user_id) o
WHERE morbac.is_allowed(p_user_id, o.org_id, p_activity, p_view)
) OR morbac.is_allowed(p_user_id, NULL, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.has_permission(UUID, TEXT, TEXT) IS
'Capability probe for UI gating: TRUE if the user is allowed the activity/view in any member org, the no-org bucket, or via global rules. Not a substitute for object-level is_allowed().';
+35 -11
View File
@@ -53,14 +53,17 @@ COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS
-- Get a named scope of organizations relative to a given org.
--
-- Supported scopes:
-- 'self' — the org itself only (depth = 0)
-- 'children' — direct children only (descendants at depth = 1)
-- 'descendants' — all descendants, excluding self (depth > 0)
-- 'subtree' — self + all descendants (equivalent to get_org_descendants)
-- 'parent' — direct parent only (ancestor at depth = 1)
-- 'ancestors' — all ancestors, excluding self (depth > 0)
-- 'lineage' — self + all ancestors (equivalent to get_org_ancestors)
-- 'root' — topmost ancestor only (max depth ancestor)
-- 'self' — the org itself only (depth = 0)
-- 'children' — direct children only (descendants at depth = 1)
-- 'descendants' — all descendants, excluding self (depth > 0)
-- 'subtree' — self + all descendants (equivalent to get_org_descendants)
-- 'parent' — direct parent only (ancestor at depth = 1)
-- 'ancestors' — all ancestors, excluding self (depth > 0)
-- 'lineage' — self + all ancestors (equivalent to get_org_ancestors)
-- 'root' — topmost ancestor only (max depth ancestor)
-- 'unattributed' — the no-org bucket; resolves to no real orgs (empty set)
-- 'all' — every organization (unattributed is not an org, so it is
-- not listed here; org_in_scope('all') does cover it)
--
-- Optional p_max_depth limits how many levels are traversed (NULL = unlimited).
CREATE OR REPLACE FUNCTION morbac.get_org_scope(
@@ -126,14 +129,22 @@ BEGIN
ORDER BY a.depth DESC
LIMIT 1;
WHEN 'unattributed' THEN
RETURN QUERY
SELECT NULL::UUID, 0 WHERE FALSE;
WHEN 'all' THEN
RETURN QUERY
SELECT o.id, 0 FROM morbac.orgs o;
ELSE
RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root', p_scope;
RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all', p_scope;
END CASE;
END;
$$;
COMMENT ON FUNCTION morbac.get_org_scope(UUID, TEXT, INTEGER) IS
'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root. Optional p_max_depth limits traversal depth.';
'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all. Optional p_max_depth limits traversal depth.';
CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER)
@@ -345,6 +356,19 @@ STABLE
SECURITY DEFINER
AS $$
BEGIN
-- Org target vocabulary: all (every org, unattributed included),
-- unattributed (no-org objects only), or a specific org via the tree scopes.
-- Tree scopes never match an unattributed object.
IF p_scope = 'all' THEN
RETURN TRUE;
END IF;
IF p_scope = 'unattributed' THEN
RETURN p_target_org_id IS NULL;
END IF;
IF p_target_org_id IS NULL THEN
RETURN FALSE;
END IF;
IF p_scope = 'self' THEN
RETURN p_target_org_id = p_rule_org_id;
END IF;
@@ -356,4 +380,4 @@ END;
$$;
COMMENT ON FUNCTION morbac.org_in_scope(UUID, UUID, TEXT) IS
'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). SECURITY DEFINER to bypass RLS on morbac.orgs.';
'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). Scope unattributed matches only a NULL target; other scopes never match NULL. SECURITY DEFINER to bypass RLS on morbac.orgs.';
+100 -22
View File
@@ -72,30 +72,56 @@ $$;
COMMENT ON FUNCTION morbac.current_target_user_id() IS
'Returns target user ID filter from morbac.target_user_id session variable';
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]'
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
RETURNS UUID[]
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' or '["uuid1", null]' or '[null]'.
-- A JSON null element names the no-org (unattributed) bucket, distinct from
-- the real org UUIDs which populate org_ids.
CREATE OR REPLACE FUNCTION morbac.current_org_filter(
OUT org_ids UUID[],
OUT include_unattributed BOOLEAN
)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_raw TEXT;
v_raw TEXT := current_setting('morbac.org_ids', TRUE);
v_json JSONB;
BEGIN
v_raw := current_setting('morbac.org_ids', TRUE);
org_ids := NULL;
include_unattributed := FALSE;
IF v_raw IS NULL OR v_raw = '' THEN
RETURN NULL;
RETURN;
END IF;
RETURN ARRAY(SELECT jsonb_array_elements_text(v_raw::jsonb)::UUID);
v_json := v_raw::jsonb;
org_ids := ARRAY(
SELECT x::UUID
FROM jsonb_array_elements_text(v_json) x
WHERE x IS NOT NULL
);
include_unattributed := EXISTS (
SELECT 1 FROM jsonb_array_elements(v_json) e WHERE e = 'null'::jsonb
);
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
org_ids := NULL;
include_unattributed := FALSE;
END;
$$;
COMMENT ON FUNCTION morbac.current_org_filter() IS
'Parses morbac.org_ids (JSON array) once into real org UUIDs plus a flag for whether the no-org bucket (JSON null element) was requested.';
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
RETURNS UUID[]
LANGUAGE sql
STABLE
AS $$
SELECT org_ids FROM morbac.current_org_filter();
$$;
COMMENT ON FUNCTION morbac.current_org_ids() IS
'Returns org ID list from morbac.org_ids session variable (JSON array)';
'Returns the real org UUIDs from morbac.org_ids (JSON null elements excluded). See current_org_filter() for the no-org bucket flag.';
CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID)
RETURNS TABLE(org_id UUID)
@@ -117,13 +143,53 @@ $$;
COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
'Returns all org IDs the user has any direct role or active delegation in';
-- rls_check has two forms, distinguished by arity so a NULL never carries two
-- meanings:
--
-- rls_check(activity, view) -- table has no org column
-- rls_check(activity, view, row_org_id[, row_user_id]) -- row-scoped by org
--
-- In the 3/4-arg form row_org_id is the record's org, and a NULL value means the
-- record is unattributed (no org) -- never "no org dimension". The 2-arg form
-- carries no org dimension and evaluates against the session org context.
DROP FUNCTION IF EXISTS morbac.rls_check(TEXT, TEXT, UUID, UUID);
CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_user_id UUID;
v_org_id UUID;
BEGIN
v_user_id := morbac.current_user_id();
IF v_user_id IS NULL THEN
RETURN FALSE;
END IF;
v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END IF;
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT) IS
'RLS helper for tables without an org column. Uses the session org context (morbac.org_id), else org-independent rules. For row-scoped tables use the 3/4-arg form.';
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
-- User scoping: morbac.target_user_id filters rows to a specific user.
-- Pass row columns to enable scoping: rls_check('read', 'docs', org_id, user_id)
CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT,
p_view TEXT,
p_row_org_id UUID DEFAULT NULL,
p_row_org_id UUID,
p_row_user_id UUID DEFAULT NULL
)
RETURNS BOOLEAN
@@ -131,10 +197,11 @@ LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_user_id UUID;
v_org_id UUID;
v_org_ids UUID[];
v_target_user_id UUID;
v_user_id UUID;
v_org_id UUID;
v_org_ids UUID[];
v_include_unattr BOOLEAN;
v_target_user_id UUID;
BEGIN
v_user_id := morbac.current_user_id();
@@ -150,29 +217,40 @@ BEGIN
END IF;
END IF;
-- Single org selection: exact bucket only. An unattributed row (NULL org) is
-- DISTINCT from the pin and is filtered out (use the org_ids list for both).
v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN
IF p_row_org_id IS NOT NULL AND p_row_org_id <> v_org_id THEN
IF p_row_org_id IS DISTINCT FROM v_org_id THEN
RETURN FALSE;
END IF;
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END IF;
v_org_ids := morbac.current_org_ids();
-- Org list selection: real orgs and/or the unattributed bucket (JSON null element).
SELECT f.org_ids, f.include_unattributed
INTO v_org_ids, v_include_unattr
FROM morbac.current_org_filter() f;
IF v_org_ids IS NOT NULL THEN
IF p_row_org_id IS NOT NULL AND NOT (p_row_org_id = ANY(v_org_ids)) THEN
IF v_org_ids IS NOT NULL OR v_include_unattr THEN
IF p_row_org_id IS NULL THEN
IF NOT v_include_unattr THEN
RETURN FALSE;
END IF;
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
END IF;
IF v_org_ids IS NULL OR NOT (p_row_org_id = ANY(v_org_ids)) THEN
RETURN FALSE;
END IF;
-- p_row_org_id NULL: global row — is_allowed(NULL) checks global_rules only
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END IF;
-- No org context: use row's org (or NULL for global rows — global_rules only)
-- No selection set: all authorized rows. The row's own org drives the decision
-- (unattributed row -> unattributed + org-independent rules).
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS
'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id for org scoping (single org, org list, or all orgs). Pass row user_id to filter by morbac.target_user_id session variable.';
'RLS helper for row-scoped tables. p_row_org_id is the record org; NULL means unattributed. Select records via morbac.org_id (single org, unattributed excluded) or morbac.org_ids (JSON array; a null element adds the unattributed bucket). No selection = all authorized rows including unattributed. Pass row user_id to filter by morbac.target_user_id.';
+6 -2
View File
@@ -9,7 +9,11 @@
-- 'ancestors' — all ancestors, excluding self
-- 'lineage' — self + all ancestors
-- 'root' — topmost ancestor only
-- 'unattributed' — no-org objects only (org_id column above is the declaring authority)
-- 'all' — every org, unattributed included
-- Evaluated at query time via org_in_scope() — new orgs are picked up automatically.
--
-- Org target vocabulary: a specific org (the tree scopes), 'unattributed', or 'all'.
CREATE TABLE morbac.rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -28,7 +32,7 @@ CREATE TABLE morbac.rules (
metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, activity, view, context_id, modality, scope),
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root'))
CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'))
);
CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id);
@@ -40,7 +44,7 @@ INCLUDE (role_id, context_id)
WHERE is_active = true;
COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
COMMENT ON COLUMN morbac.rules.scope IS 'Org scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root. Evaluated at query time — new orgs are covered automatically.';
COMMENT ON COLUMN morbac.rules.scope IS 'Object scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root, unattributed, all. Selects which objects (by org) the rule reaches: a specific org via the tree scopes, unattributed for no-org objects only, or all for every org including unattributed. Evaluated at query time — new orgs are covered automatically.';
COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.';
+10 -2
View File
@@ -3,11 +3,14 @@
-- Grants or prohibits access for a specific user in an org, bypassing the role system.
-- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache().
-- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition.
--
-- Org target: a specific org, or NULL for unattributed (no-org) objects.
-- For the same user across every org, use morbac.global_rules.
CREATE TABLE morbac.user_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
org_id UUID REFERENCES morbac.orgs(id) ON DELETE CASCADE,
activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
@@ -21,6 +24,11 @@ CREATE TABLE morbac.user_rules (
UNIQUE(user_id, org_id, activity, view, context_id, modality)
);
-- NULLs are distinct in UNIQUE, so the unattributed target needs its own index
CREATE UNIQUE INDEX idx_user_rules_unattributed_unique
ON morbac.user_rules(user_id, activity, view, context_id, modality)
WHERE org_id IS NULL;
CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id);
CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view);
CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality);
@@ -28,6 +36,6 @@ CREATE INDEX idx_user_rules_lookup ON morbac.user_rules(user_id, org_id, activit
COMMENT ON TABLE morbac.user_rules IS 'Direct user-level rules — grant or prohibit access for a specific user, bypassing the role system';
COMMENT ON COLUMN morbac.user_rules.user_id IS 'User this rule applies to directly';
COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides';
COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides; NULL targets unattributed (no-org) objects';
COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.';
+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 %)',