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:
+37
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 %)',
|
||||
|
||||
Reference in New Issue
Block a user