feat(rules): add preemptive warning when creating conflicting rules
This commit is contained in:
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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 ==='
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user