diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 3d4756c..85e6cc7 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -219,6 +219,17 @@ flowchart LR D -->|No| F[DENY] ``` +**Obligations and Recommendations** + +Obligations and recommendations do not affect access control, they are informational signals your application reads and acts on. + +- **Obligation**: the user *must* perform this action. Use it to model required tasks, workflow steps, or compliance actions (e.g. a doctor must file a discharge report, an employee must review the security policy weekly). +- **Recommendation**: the user *should* consider this action. Use it for suggested next steps, nudges, or guidance (e.g. a manager is recommended to review team performance reports monthly). + +Query them via `pending_obligations()` and `pending_recommendations()` to drive task lists, alerts, or workflow gates in your application. + +**Conflict resolution**: a prohibition voids any applicable obligation. A prohibition or obligation voids any applicable recommendation. This mirrors the deontic priority order from the Multi-OrBAC paper: prohibition > obligation > recommendation. + **Example: Employee Document Access** ```sql diff --git a/src/obligations.sql b/src/obligations.sql index c01a494..8f116c2 100644 --- a/src/obligations.sql +++ b/src/obligations.sql @@ -3,8 +3,14 @@ -- ============================================================================= -- Obligations and recommendations do NOT affect authorization -- They are queryable for informational purposes +-- +-- Conflict resolution (from Multi-OrBAC paper): +-- - A prohibition voids any applicable obligation for the same (activity, view) +-- - A prohibition or obligation voids any applicable recommendation for the same (activity, view) +-- - Both functions use comprehensive roles (direct, delegated, derived, inherited) -- Pending obligations for a user in an organization +-- Returns only obligations not voided by an applicable prohibition CREATE OR REPLACE FUNCTION morbac.pending_obligations( p_user_id UUID, p_org_id UUID @@ -30,22 +36,38 @@ BEGIN c.name, r.created_at FROM morbac.rules r - INNER JOIN morbac.user_roles ur ON ur.role_id = r.role_id INNER JOIN morbac.roles ro ON ro.id = r.role_id INNER JOIN morbac.contexts c ON c.id = r.context_id - WHERE ur.user_id = p_user_id - AND r.org_id = p_org_id - AND ur.org_id = p_org_id + WHERE r.org_id = p_org_id AND r.modality = 'obligation' - AND morbac.eval_context(r.context_id) = TRUE + 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) + AND morbac.eval_context(r.context_id) + -- Prohibition voids obligation: exclude if an applicable prohibition exists + AND NOT EXISTS ( + SELECT 1 + 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.role_id IN ( + SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) + ) + AND morbac.is_rule_valid(p.valid_from, p.valid_until) + AND morbac.eval_context(p.context_id) + ) ORDER BY r.created_at; END; $$; COMMENT ON FUNCTION morbac.pending_obligations(UUID, UUID) IS -'Returns pending obligations for a user in an organization (informational only)'; +'Returns pending obligations for a user in an organization (informational only). Prohibitions void applicable obligations per Multi-OrBAC conflict resolution.'; -- Recommendations for a user in an organization +-- Returns only recommendations not voided by an applicable prohibition or obligation CREATE OR REPLACE FUNCTION morbac.pending_recommendations( p_user_id UUID, p_org_id UUID @@ -71,20 +93,35 @@ BEGIN c.name, r.created_at FROM morbac.rules r - INNER JOIN morbac.user_roles ur ON ur.role_id = r.role_id INNER JOIN morbac.roles ro ON ro.id = r.role_id INNER JOIN morbac.contexts c ON c.id = r.context_id - WHERE ur.user_id = p_user_id - AND r.org_id = p_org_id - AND ur.org_id = p_org_id + WHERE r.org_id = p_org_id AND r.modality = 'recommendation' - AND morbac.eval_context(r.context_id) = TRUE + 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) + AND morbac.eval_context(r.context_id) + -- Prohibition or obligation voids recommendation + AND NOT EXISTS ( + SELECT 1 + 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.role_id IN ( + SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) + ) + AND morbac.is_rule_valid(p.valid_from, p.valid_until) + AND morbac.eval_context(p.context_id) + ) ORDER BY r.created_at; END; $$; COMMENT ON FUNCTION morbac.pending_recommendations(UUID, UUID) IS -'Returns recommendations for a user in an organization (informational only)'; +'Returns recommendations for a user in an organization (informational only). Prohibitions and obligations void applicable recommendations per Multi-OrBAC conflict resolution.'; -- Get all roles for a user in an organization CREATE OR REPLACE FUNCTION morbac.user_roles_in_org( diff --git a/tests/00_setup.sql b/tests/00_setup.sql index c55ccfc..be5380e 100644 --- a/tests/00_setup.sql +++ b/tests/00_setup.sql @@ -267,7 +267,11 @@ INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, contex ('GlobalTech HQ', 'compliance_officer', 'read', 'financial_data', 'permission', 'always'), -- Recommendation: staff should review reports end of quarter - ('GlobalTech HQ', 'employee', 'read', 'reports', 'recommendation','end_of_quarter'); + -- Note: voided by the obligation above (conflict resolution: obligation > recommendation) + ('GlobalTech HQ', 'employee', 'read', 'reports', 'recommendation','end_of_quarter'), + + -- Recommendation: staff should stay informed on public data (no conflicting obligation) + ('GlobalTech HQ', 'employee', 'read', 'public_data', 'recommendation','always'); -- Engineering Dept policies INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES diff --git a/tests/09_utilities.sql b/tests/09_utilities.sql index b3ad519..7f650d2 100644 --- a/tests/09_utilities.sql +++ b/tests/09_utilities.sql @@ -6,6 +6,7 @@ -- 1. pending_obligations — returns obligation rules for a user -- 2. pending_recommendations — returns recommendation rules for a user -- 3. Obligations/recommendations do NOT affect is_allowed() +-- 3b. Conflict resolution: prohibition voids obligation; prohibition/obligation voids recommendation -- 4. user_has_role — checks if user holds a named role -- 5. user_roles_in_org — lists all roles for user in org -- 6. eval_context — evaluates context predicates directly @@ -61,7 +62,7 @@ SELECT morbac.t_eq('Karl (no role) has 0 pending obligations', \echo '' \echo '--- 2. pending_recommendations ---' --- Dave (employee) has recommendation: 'read reports' during end_of_quarter (evaluates TRUE) +-- Dave (employee) has recommendation for 'read public_data' (no conflicting obligation/prohibition) SELECT morbac.t('Dave (employee) has at least 1 pending recommendation', (SELECT COUNT(*) FROM morbac.pending_recommendations( '30000000-0000-0000-0000-000000000004'::uuid, @@ -69,6 +70,26 @@ SELECT morbac.t('Dave (employee) has at least 1 pending recommendation', )) >= 1, TRUE); +-- 'read public_data' recommendation appears (no obligation or prohibition conflicts) +SELECT morbac.t('Dave has pending recommendation: read public_data', + EXISTS( + SELECT 1 FROM morbac.pending_recommendations( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE activity = 'read' AND view = 'public_data' + ), TRUE); + +-- 'read reports' recommendation is voided by the existing obligation (conflict resolution) +SELECT morbac.t('Dave: read reports recommendation is voided by obligation', + NOT EXISTS( + SELECT 1 FROM morbac.pending_recommendations( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE activity = 'read' AND view = 'reports' + ), TRUE); + -- Eve (intern) has no recommendations SELECT morbac.t_eq('Eve (intern) has 0 pending recommendations', (SELECT COUNT(*) FROM morbac.pending_recommendations( @@ -119,6 +140,58 @@ SELECT morbac.t('Dave has recommendation for read audit_logs but no permission [ 'read', 'audit_logs' ), FALSE); +-- --------------------------------------------------------------------------- +-- Section 3b: Conflict resolution — prohibition voids obligation/recommendation +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3b. Conflict resolution: prohibition voids obligation and recommendation ---' + +-- Add a prohibition for employee on read contracts (Dave already has an obligation for this) +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +-- Prohibition voids the obligation: read contracts obligation no longer pending +SELECT morbac.t('Dave: read contracts obligation is voided by prohibition', + NOT EXISTS( + SELECT 1 FROM morbac.pending_obligations( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE activity = 'read' AND view = 'contracts' + ), TRUE); + +-- Prohibition also voids the recommendation for read audit_logs +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'read', 'audit_logs', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +SELECT morbac.t('Dave: read audit_logs recommendation is voided by prohibition', + NOT EXISTS( + SELECT 1 FROM morbac.pending_recommendations( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE activity = 'read' AND view = 'audit_logs' + ), TRUE); + +-- Clean up: remove the temporary prohibitions added for this test +DELETE FROM morbac.rules +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000004' + AND modality = 'prohibition' + AND view IN ('contracts', 'audit_logs'); + -- --------------------------------------------------------------------------- -- Section 4: user_has_role -- ---------------------------------------------------------------------------