From cba741fd9d4586d0aec1812ba6d73a1cf861bce3 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Mon, 23 Mar 2026 01:00:04 +0100 Subject: [PATCH] feat(rules): add priority field --- src/authorization.sql | 122 +++++++++++++++++-------------------- src/cross_org_rules.sql | 1 + src/obligations.sql | 6 +- src/rules.sql | 2 + tests/01_authorization.sql | 76 +++++++++++++++++++++++ 5 files changed, 139 insertions(+), 68 deletions(-) diff --git a/src/authorization.sql b/src/authorization.sql index 78f93e2..acdba3e 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -6,11 +6,14 @@ -- Semantics (from Multi-OrBAC paper): -- - Access is allowed if and only if: -- 1. At least one applicable permission exists --- 2. AND no applicable prohibition exists --- - Prohibitions have precedence over permissions --- - Contexts must be evaluated +-- 2. AND no applicable prohibition exists (or permission has strictly higher priority) -- - Default deny (no permission = deny) -- - Obligations and recommendations do NOT affect authorization +-- +-- Priority resolution: +-- - Each rule has an optional integer priority (NULL = 0, lowest) +-- - When both a prohibition and a permission apply, the higher-priority rule wins +-- - Tie goes to prohibition (modality precedence from the paper) CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( p_user_id UUID, @@ -23,112 +26,99 @@ LANGUAGE plpgsql STABLE AS $$ DECLARE - v_rule RECORD; + v_rule RECORD; + v_max_prohibition_priority INTEGER := NULL; + v_max_permission_priority INTEGER := NULL; BEGIN - -- STEP 1: Check for prohibitions first (prohibition precedence) - -- If any applicable prohibition exists, deny immediately - -- Checks: - -- - Direct rules for (activity, view) combinations - -- - Activity hierarchy (senior activities imply junior) - -- - View hierarchy (senior views imply junior) - -- - Comprehensive roles (direct, delegated, derived, inherited) - -- - Temporal validity (if rule has time constraints) - + -- STEP 1: Local prohibitions — find the highest-priority applicable one FOR v_rule IN - SELECT r.context_id + SELECT r.context_id, COALESCE(r.priority, 0) AS prio FROM morbac.rules r WHERE r.org_id = p_org_id AND r.modality = 'prohibition' - -- Match activity or any senior activity in hierarchy - AND r.activity IN ( - SELECT activity FROM morbac.get_effective_activities(p_activity) - ) - -- Match view or any senior view in hierarchy - AND r.view IN ( - SELECT view FROM morbac.get_effective_views(p_view) - ) - -- Match comprehensive roles (direct, delegated, derived, inherited) - AND r.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) - ) - -- Check temporal validity + AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) + AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)) AND morbac.is_rule_valid(r.valid_from, r.valid_until) + ORDER BY COALESCE(r.priority, 0) DESC LOOP - -- Evaluate context IF morbac.eval_context(v_rule.context_id) THEN - -- Prohibition found - immediate deny (prohibition precedence) - RETURN FALSE; + v_max_prohibition_priority := v_rule.prio; + EXIT; -- Highest-priority prohibition found; lower ones can't change the outcome END IF; END LOOP; - -- STEP 2: Check cross-organizational prohibitions + -- STEP 2: Cross-org prohibitions — update max if a higher priority is found FOR v_rule IN - SELECT cr.context_id + SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio FROM morbac.cross_org_rules cr WHERE cr.target_org_id = p_org_id AND cr.modality = 'prohibition' AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - -- User has role in source org - AND cr.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id) - ) - -- Check temporal validity + AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) + ORDER BY COALESCE(cr.priority, 0) DESC LOOP IF morbac.eval_context(v_rule.context_id) THEN - RETURN FALSE; + IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN + v_max_prohibition_priority := v_rule.prio; + END IF; + EXIT; END IF; END LOOP; - -- STEP 3: No prohibitions found, check for permissions + -- STEP 3: Local permissions — find the highest-priority applicable one FOR v_rule IN - SELECT r.context_id + SELECT r.context_id, COALESCE(r.priority, 0) AS prio FROM morbac.rules r WHERE r.org_id = p_org_id AND r.modality = 'permission' - -- Match activity or any senior activity in hierarchy - AND r.activity IN ( - SELECT activity FROM morbac.get_effective_activities(p_activity) - ) - -- Match view or any senior view in hierarchy - AND r.view IN ( - SELECT view FROM morbac.get_effective_views(p_view) - ) - -- Match comprehensive roles - AND r.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) - ) - -- Check temporal validity + AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) + AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)) AND morbac.is_rule_valid(r.valid_from, r.valid_until) + ORDER BY COALESCE(r.priority, 0) DESC LOOP - -- Evaluate context IF morbac.eval_context(v_rule.context_id) THEN - -- Permission found and no prohibition - allow - RETURN TRUE; + v_max_permission_priority := v_rule.prio; + EXIT; END IF; END LOOP; - -- STEP 4: Check cross-organizational permissions + -- STEP 4: Cross-org permissions — update max if higher found FOR v_rule IN - SELECT cr.context_id + SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio FROM morbac.cross_org_rules cr WHERE cr.target_org_id = p_org_id AND cr.modality = 'permission' AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND cr.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id) - ) - -- Check temporal validity + AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) + ORDER BY COALESCE(cr.priority, 0) DESC LOOP IF morbac.eval_context(v_rule.context_id) THEN - RETURN TRUE; + IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN + v_max_permission_priority := v_rule.prio; + END IF; + EXIT; END IF; END LOOP; - -- No permission found - deny (default deny) + -- STEP 5: Priority resolution + -- No prohibition at all: allow if any permission was found + IF v_max_prohibition_priority IS NULL THEN + RETURN v_max_permission_priority IS NOT NULL; + END IF; + + -- Prohibition exists: a permission with strictly higher priority overrides it + IF v_max_permission_priority IS NOT NULL + AND v_max_permission_priority > v_max_prohibition_priority THEN + RETURN TRUE; + END IF; + + -- Prohibition wins (no permission, equal priority, or lower-priority permission) RETURN FALSE; END; $$; diff --git a/src/cross_org_rules.sql b/src/cross_org_rules.sql index 831a00b..67595b4 100644 --- a/src/cross_org_rules.sql +++ b/src/cross_org_rules.sql @@ -13,6 +13,7 @@ CREATE TABLE morbac.cross_org_rules ( view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, modality morbac.modality NOT NULL, + priority INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), valid_from TIMESTAMPTZ, valid_until TIMESTAMPTZ, diff --git a/src/obligations.sql b/src/obligations.sql index 5b3b38b..9dce187 100644 --- a/src/obligations.sql +++ b/src/obligations.sql @@ -45,7 +45,7 @@ BEGIN ) AND morbac.is_rule_valid(r.valid_from, r.valid_until) AND morbac.eval_context(r.context_id) - -- Prohibition voids obligation: exclude if an applicable prohibition exists + -- Prohibition voids obligation unless the obligation has strictly higher priority AND NOT EXISTS ( SELECT 1 FROM morbac.rules p @@ -58,6 +58,7 @@ BEGIN ) AND morbac.is_rule_valid(p.valid_from, p.valid_until) AND morbac.eval_context(p.context_id) + AND COALESCE(p.priority, 0) >= COALESCE(r.priority, 0) ) ORDER BY r.created_at; END; @@ -102,7 +103,7 @@ BEGIN ) AND morbac.is_rule_valid(r.valid_from, r.valid_until) AND morbac.eval_context(r.context_id) - -- Prohibition or obligation voids recommendation + -- Prohibition or obligation voids recommendation unless recommendation has strictly higher priority AND NOT EXISTS ( SELECT 1 FROM morbac.rules p @@ -115,6 +116,7 @@ BEGIN ) AND morbac.is_rule_valid(p.valid_from, p.valid_until) AND morbac.eval_context(p.context_id) + AND COALESCE(p.priority, 0) >= COALESCE(r.priority, 0) ) ORDER BY r.created_at; END; diff --git a/src/rules.sql b/src/rules.sql index 4d715d7..09de5df 100644 --- a/src/rules.sql +++ b/src/rules.sql @@ -15,6 +15,7 @@ CREATE TABLE morbac.rules ( view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, modality morbac.modality NOT NULL, + priority INTEGER, valid_from TIMESTAMPTZ, valid_until TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -60,6 +61,7 @@ COMMENT ON COLUMN morbac.rules.activity IS 'Activity (abstract action)'; COMMENT ON COLUMN morbac.rules.view IS 'View (abstract object category)'; COMMENT ON COLUMN morbac.rules.context_id IS 'Context condition'; 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.'; COMMENT ON COLUMN morbac.rules.valid_from IS 'Optional: Rule valid from this timestamp'; COMMENT ON COLUMN morbac.rules.valid_until IS 'Optional: Rule valid until this timestamp'; diff --git a/tests/01_authorization.sql b/tests/01_authorization.sql index 6f9e0d6..e49641c 100644 --- a/tests/01_authorization.sql +++ b/tests/01_authorization.sql @@ -414,5 +414,81 @@ SELECT morbac.t('Frank reads financial_data (prohibited): cached = nocache', 'read', 'financial_data' ), TRUE); +-- --------------------------------------------------------------------------- +-- Section N: Priority-based conflict resolution +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- Priority: permission overrides prohibition when strictly higher ---' + +-- Dave (employee) already has permission to read documents. +-- Add a prohibition with priority=5 and a permission with priority=10 on 'read contracts'. +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, priority) +VALUES ( + 'e0000000-0000-0000-0000-000000000001', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition', 5 +); + +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, priority) +VALUES ( + 'e0000000-0000-0000-0000-000000000002', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', 10 +); + +-- Permission (priority=10) beats prohibition (priority=5) -> ALLOW +SELECT morbac.t('Dave reads contracts: permission priority=10 overrides prohibition priority=5 [allowed]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'contracts' + ), TRUE); + +-- Flip priorities: prohibition=10, permission=5 -> prohibition wins +UPDATE morbac.rules SET priority = 10 WHERE id = 'e0000000-0000-0000-0000-000000000001'; +UPDATE morbac.rules SET priority = 5 WHERE id = 'e0000000-0000-0000-0000-000000000002'; + +SELECT morbac.t('Dave reads contracts: prohibition priority=10 beats permission priority=5 [denied]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'contracts' + ), FALSE); + +-- Equal priorities: prohibition wins (modality tiebreaker) +UPDATE morbac.rules SET priority = 5 WHERE id = 'e0000000-0000-0000-0000-000000000001'; + +SELECT morbac.t('Dave reads contracts: equal priority — prohibition wins by modality precedence [denied]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'contracts' + ), FALSE); + +-- No priority on either (NULL = 0): prohibition still wins +UPDATE morbac.rules SET priority = NULL WHERE id IN ( + 'e0000000-0000-0000-0000-000000000001', + 'e0000000-0000-0000-0000-000000000002' +); + +SELECT morbac.t('Dave reads contracts: no priority set — prohibition wins by default [denied]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'contracts' + ), FALSE); + +-- Clean up +DELETE FROM morbac.rules WHERE id IN ( + 'e0000000-0000-0000-0000-000000000001', + 'e0000000-0000-0000-0000-000000000002' +); + \echo '' \echo '=== Authorization Decision Tests Completed ==='