feat(rules): add preemptive warning when creating conflicting rules

This commit is contained in:
2026-03-23 00:50:00 +01:00
parent 09082ea0d3
commit fca7e2053f
5 changed files with 256 additions and 5 deletions
+29
View File
@@ -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`.
+4 -4
View File
@@ -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)
)
+92
View File
@@ -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.';
+130
View File
@@ -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 ==='
+1 -1
View File
@@ -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"