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
|
-- 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
|
### Derived Roles
|
||||||
|
|
||||||
Roles computed dynamically from application data.
|
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.
|
**`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
|
### Administration Functions
|
||||||
|
|
||||||
**`is_admin_allowed(user_id, org_id, capability)`**: Check administrative permissions. Capabilities: `manage_policies`, `manage_roles`, `manage_users`.
|
**`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
|
FROM morbac.rules p
|
||||||
WHERE p.org_id = p_org_id
|
WHERE p.org_id = p_org_id
|
||||||
AND p.modality = 'prohibition'
|
AND p.modality = 'prohibition'
|
||||||
AND p.activity IN (SELECT activity FROM morbac.get_effective_activities(r.activity))
|
AND p.activity IN (SELECT ea.activity FROM morbac.get_effective_activities(r.activity) ea)
|
||||||
AND p.view IN (SELECT view FROM morbac.get_effective_views(r.view))
|
AND p.view IN (SELECT ev.view FROM morbac.get_effective_views(r.view) ev)
|
||||||
AND p.role_id IN (
|
AND p.role_id IN (
|
||||||
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
|
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
|
||||||
)
|
)
|
||||||
@@ -108,8 +108,8 @@ BEGIN
|
|||||||
FROM morbac.rules p
|
FROM morbac.rules p
|
||||||
WHERE p.org_id = p_org_id
|
WHERE p.org_id = p_org_id
|
||||||
AND p.modality IN ('prohibition', 'obligation')
|
AND p.modality IN ('prohibition', 'obligation')
|
||||||
AND p.activity IN (SELECT activity FROM morbac.get_effective_activities(r.activity))
|
AND p.activity IN (SELECT ea.activity FROM morbac.get_effective_activities(r.activity) ea)
|
||||||
AND p.view IN (SELECT view FROM morbac.get_effective_views(r.view))
|
AND p.view IN (SELECT ev.view FROM morbac.get_effective_views(r.view) ev)
|
||||||
AND p.role_id IN (
|
AND p.role_id IN (
|
||||||
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
|
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- VALIDATION FUNCTIONS
|
-- 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
|
-- Check if role assignment would violate Separation of Duty
|
||||||
CREATE OR REPLACE FUNCTION morbac.check_sod_violation(
|
CREATE OR REPLACE FUNCTION morbac.check_sod_violation(
|
||||||
@@ -80,3 +90,85 @@ $$;
|
|||||||
|
|
||||||
COMMENT ON FUNCTION morbac.check_cardinality_violation(UUID, BOOLEAN) IS
|
COMMENT ON FUNCTION morbac.check_cardinality_violation(UUID, BOOLEAN) IS
|
||||||
'Returns error message if cardinality constraint would be violated, NULL otherwise';
|
'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
|
-- 1. Separation of Duty (SoD) — mutually exclusive roles
|
||||||
-- 2. Negative role assignments — explicit blocking of a role
|
-- 2. Negative role assignments — explicit blocking of a role
|
||||||
-- 3. Role cardinality constraints — min/max users per role
|
-- 3. Role cardinality constraints — min/max users per role
|
||||||
|
-- 4. Rule conflict detection — modality conflicts on same tuple
|
||||||
--
|
--
|
||||||
-- Scenario:
|
-- Scenario:
|
||||||
-- - auditor and accountant are mutually exclusive (no one can hold both)
|
-- - 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'
|
WHERE role_id = '20000000-0001-0000-0000-000000000010'
|
||||||
AND user_id = '30000000-0000-0000-0000-000000000004';
|
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 ''
|
||||||
\echo '=== Constraint Tests Completed ==='
|
\echo '=== Constraint Tests Completed ==='
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ if [ "$OLD_VERSION" = "$NEW_VERSION" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Bumping version: $OLD_VERSION → $NEW_VERSION"
|
echo "Bumping version: $OLD_VERSION -> $NEW_VERSION"
|
||||||
|
|
||||||
# --- pgmorbac.control ---
|
# --- pgmorbac.control ---
|
||||||
sed -i.bak "s/default_version = '$OLD_VERSION'/default_version = '$NEW_VERSION'/" "$CONTROL_FILE"
|
sed -i.bak "s/default_version = '$OLD_VERSION'/default_version = '$NEW_VERSION'/" "$CONTROL_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user