From fca7e2053ffd175b913ed8310e340bae47cb9183 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Mon, 23 Mar 2026 00:50:00 +0100 Subject: [PATCH] feat(rules): add preemptive warning when creating conflicting rules --- docs/DOCUMENTATION.md | 29 +++++++++ src/obligations.sql | 8 +-- src/validation.sql | 92 +++++++++++++++++++++++++++ tests/04_constraints.sql | 130 +++++++++++++++++++++++++++++++++++++++ tools/bump_version.sh | 2 +- 5 files changed, 256 insertions(+), 5 deletions(-) diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 85e6cc7..4ce01b4 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -498,6 +498,33 @@ SELECT morbac.check_cardinality_violation(admin_role_id, org_id); -- Returns: error message if would violate constraint, NULL if valid ``` +### Rule Conflict Detection + +When inserting or updating a rule, pgmorbac automatically warns if the new rule conflicts with an existing one due to modality precedence. Conflicts are non-blocking (the insert succeeds) but a `WARNING` is emitted so you can catch unintentional policy contradictions. + +A conflict is flagged when two rules share the same `(org, role, activity, view, context)` tuple and their modalities create a dead rule: + +| New modality | Existing modality | Issue | +|---|---|---| +| `permission` | `prohibition` | permission will always be overridden | +| `obligation` | `prohibition` | obligation will always be voided | +| `recommendation` | `prohibition` or `obligation` | recommendation will always be voided | +| `prohibition` | any | the existing rule(s) become dead or voided | + +Hierarchy-based overlaps (e.g. a permission on `manage` and a prohibition on `write`, where `write` is a sub-activity of `manage`) are intentional design and not flagged. + +You can also call `detect_rule_conflicts()` explicitly before inserting: + +```sql +-- Check for conflicts before inserting +SELECT * FROM morbac.detect_rule_conflicts( + org_id, role_id, 'read', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); +-- Returns empty if no conflict, or rows describing each conflicting rule +``` + ### Derived Roles Roles computed dynamically from application data. @@ -698,6 +725,8 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2); **`check_cardinality_violation(role_id, org_id)`**: Returns error message if constraint violated, NULL if valid. +**`detect_rule_conflicts(org_id, role_id, activity, view, context_id, modality, exclude_id?)`**: Returns rows describing rules that directly conflict with the given tuple due to modality precedence. `exclude_id` is used on UPDATE to skip the rule being modified. Also fires automatically as a non-blocking trigger warning on every INSERT or UPDATE to `morbac.rules`. + ### Administration Functions **`is_admin_allowed(user_id, org_id, capability)`**: Check administrative permissions. Capabilities: `manage_policies`, `manage_roles`, `manage_users`. diff --git a/src/obligations.sql b/src/obligations.sql index 8f116c2..5b3b38b 100644 --- a/src/obligations.sql +++ b/src/obligations.sql @@ -51,8 +51,8 @@ BEGIN FROM morbac.rules p WHERE p.org_id = p_org_id AND p.modality = 'prohibition' - AND p.activity IN (SELECT activity FROM morbac.get_effective_activities(r.activity)) - AND p.view IN (SELECT view FROM morbac.get_effective_views(r.view)) + AND p.activity IN (SELECT ea.activity FROM morbac.get_effective_activities(r.activity) ea) + AND p.view IN (SELECT ev.view FROM morbac.get_effective_views(r.view) ev) AND p.role_id IN ( SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) ) @@ -108,8 +108,8 @@ BEGIN FROM morbac.rules p WHERE p.org_id = p_org_id AND p.modality IN ('prohibition', 'obligation') - AND p.activity IN (SELECT activity FROM morbac.get_effective_activities(r.activity)) - AND p.view IN (SELECT view FROM morbac.get_effective_views(r.view)) + AND p.activity IN (SELECT ea.activity FROM morbac.get_effective_activities(r.activity) ea) + AND p.view IN (SELECT ev.view FROM morbac.get_effective_views(r.view) ev) AND p.role_id IN ( SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) ) diff --git a/src/validation.sql b/src/validation.sql index 9392dee..ca26298 100644 --- a/src/validation.sql +++ b/src/validation.sql @@ -1,6 +1,16 @@ -- ============================================================================= -- VALIDATION FUNCTIONS -- ============================================================================= +-- +-- Rule conflict detection (from Multi-OrBAC paper): +-- Conflicts exist when two rules share the same (org, role, activity, view, context) +-- but their modalities create a situation where one permanently overrides the other: +-- - prohibition + permission -> permission is dead (always overridden) +-- - prohibition + obligation -> obligation is always voided +-- - prohibition + recommendation -> recommendation is always voided +-- - obligation + recommendation -> recommendation is always voided +-- +-- Note: hierarchy-based overlaps are intentional design, not flagged here. -- Check if role assignment would violate Separation of Duty CREATE OR REPLACE FUNCTION morbac.check_sod_violation( @@ -80,3 +90,85 @@ $$; COMMENT ON FUNCTION morbac.check_cardinality_violation(UUID, BOOLEAN) IS 'Returns error message if cardinality constraint would be violated, NULL otherwise'; + +-- Detect direct modality conflicts for a candidate rule +CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts( + p_org_id UUID, + p_role_id UUID, + p_activity TEXT, + p_view TEXT, + p_context_id UUID, + p_modality morbac.modality, + p_exclude_id UUID DEFAULT NULL -- exclude self on UPDATE +) +RETURNS TABLE( + conflicting_rule_id UUID, + conflicting_modality morbac.modality, + description TEXT +) +LANGUAGE plpgsql +STABLE +AS $$ +BEGIN + RETURN QUERY + SELECT + r.id, + r.modality, + CASE + WHEN p_modality = 'prohibition' + THEN 'prohibition voids existing ' || r.modality::text + WHEN r.modality = 'prohibition' + THEN p_modality::text || ' will always be overridden by existing prohibition' + WHEN p_modality = 'obligation' AND r.modality = 'recommendation' + THEN 'obligation voids existing recommendation' + ELSE + 'recommendation will always be overridden by existing obligation' + END + FROM morbac.rules r + WHERE r.org_id = p_org_id + AND r.role_id = p_role_id + AND r.activity = p_activity + AND r.view = p_view + AND r.context_id = p_context_id + AND r.modality != p_modality + AND (p_exclude_id IS NULL OR r.id != p_exclude_id) + AND ( + (p_modality = 'prohibition' AND r.modality IN ('permission', 'obligation', 'recommendation')) + OR (r.modality = 'prohibition' AND p_modality IN ('permission', 'obligation', 'recommendation')) + OR (p_modality = 'obligation' AND r.modality = 'recommendation') + OR (p_modality = 'recommendation' AND r.modality = 'obligation') + ); +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).'; + +-- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one +CREATE OR REPLACE FUNCTION morbac.trg_warn_rule_conflicts() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_conflict RECORD; +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 + ) + LOOP + RAISE WARNING 'Rule conflict: % (conflicts with rule %)', + v_conflict.description, v_conflict.conflicting_rule_id; + END LOOP; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_rules_conflict_check ON morbac.rules; +CREATE TRIGGER trg_rules_conflict_check +BEFORE INSERT OR UPDATE ON morbac.rules +FOR EACH ROW EXECUTE FUNCTION morbac.trg_warn_rule_conflicts(); + +COMMENT ON FUNCTION morbac.trg_warn_rule_conflicts() IS +'Trigger function: emits a WARNING (non-blocking) when a rule conflicts with an existing rule due to modality precedence.'; diff --git a/tests/04_constraints.sql b/tests/04_constraints.sql index e9c00e3..ab05676 100644 --- a/tests/04_constraints.sql +++ b/tests/04_constraints.sql @@ -5,6 +5,7 @@ -- 1. Separation of Duty (SoD) — mutually exclusive roles -- 2. Negative role assignments — explicit blocking of a role -- 3. Role cardinality constraints — min/max users per role +-- 4. Rule conflict detection — modality conflicts on same tuple -- -- Scenario: -- - auditor and accountant are mutually exclusive (no one can hold both) @@ -245,5 +246,134 @@ DELETE FROM morbac.user_roles WHERE role_id = '20000000-0001-0000-0000-000000000010' AND user_id = '30000000-0000-0000-0000-000000000004'; +-- --------------------------------------------------------------------------- +-- Section 4: Rule conflict detection +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. Rule conflict detection ---' + +-- Insert a permission rule for employee on read/contracts (no existing conflict) +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'f0000000-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'), + 'permission' +); + +-- No conflict yet — only a permission exists +SELECT morbac.t_eq('detect_rule_conflicts: permission alone — no conflicts', + (SELECT COUNT(*) FROM morbac.detect_rule_conflicts( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' + ))::bigint, + 0); + +-- Insert a prohibition for the same tuple (conflicts with the permission) +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'f0000000-0000-0000-0000-000000000002', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +-- Prohibition conflicts with the existing permission +SELECT morbac.t_eq('detect_rule_conflicts: prohibition conflicts with existing permission', + (SELECT COUNT(*) FROM morbac.detect_rule_conflicts( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' + ))::bigint, + 1); + +-- The conflicting rule is the permission +SELECT morbac.t('detect_rule_conflicts: conflicting rule is the permission', + EXISTS( + SELECT 1 FROM morbac.detect_rule_conflicts( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' + ) + WHERE conflicting_modality = 'permission' + ), TRUE); + +-- Insert an obligation for the same tuple — conflicts with the prohibition +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'f0000000-0000-0000-0000-000000000003', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'obligation' +); + +-- Obligation conflicts with existing prohibition +SELECT morbac.t_eq('detect_rule_conflicts: obligation conflicts with existing prohibition', + (SELECT COUNT(*) FROM morbac.detect_rule_conflicts( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'obligation' + ))::bigint, + 1); + +-- Insert a recommendation — conflicts with both obligation and prohibition +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'f0000000-0000-0000-0000-000000000004', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'recommendation' +); + +-- Recommendation conflicts with both prohibition and obligation +SELECT morbac.t_eq('detect_rule_conflicts: recommendation conflicts with prohibition and obligation', + (SELECT COUNT(*) FROM morbac.detect_rule_conflicts( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'recommendation' + ))::bigint, + 2); + +-- No conflict between permission and recommendation (they coexist meaningfully) +SELECT morbac.t_eq('detect_rule_conflicts: permission vs recommendation — no conflict', + (SELECT COUNT(*) FROM morbac.detect_rule_conflicts( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + 'f0000000-0000-0000-0000-000000000001' -- exclude self + ) + WHERE conflicting_modality = 'recommendation')::bigint, + 0); + +-- Clean up +DELETE FROM morbac.rules +WHERE id IN ( + 'f0000000-0000-0000-0000-000000000001', + 'f0000000-0000-0000-0000-000000000002', + 'f0000000-0000-0000-0000-000000000003', + 'f0000000-0000-0000-0000-000000000004' +); + \echo '' \echo '=== Constraint Tests Completed ===' diff --git a/tools/bump_version.sh b/tools/bump_version.sh index 9990667..a4769e6 100755 --- a/tools/bump_version.sh +++ b/tools/bump_version.sh @@ -33,7 +33,7 @@ if [ "$OLD_VERSION" = "$NEW_VERSION" ]; then exit 0 fi -echo "Bumping version: $OLD_VERSION → $NEW_VERSION" +echo "Bumping version: $OLD_VERSION -> $NEW_VERSION" # --- pgmorbac.control --- sed -i.bak "s/default_version = '$OLD_VERSION'/default_version = '$NEW_VERSION'/" "$CONTROL_FILE"