Files
pgmorbac/tests/09_utilities.sql
T
marc cc3d65a013 docs: complete function reference, drop unicode punctuation
Documents refresh_hierarchy_cache, is_rule_valid and org_in_scope, and
corrects the get_org_scope scope list which still omitted unattributed
and all.

Replaces em dashes and other typographic unicode with ASCII throughout
the schema comments, the test suite and the documentation. Comments and
prose are ASCII only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 07:26:54 +02:00

555 lines
22 KiB
PL/PgSQL

-- =============================================================================
-- Utilities, Derived Roles, and RLS Tests
-- =============================================================================
-- Tests miscellaneous utility functions and advanced features:
--
-- 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
-- 7. Derived roles - computed via evaluator function
-- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping
--
-- Prerequisites: 00_setup.sql -> 08_system_access.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '09 - UTILITIES, DERIVED ROLES, RLS'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: pending_obligations
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. pending_obligations ---'
-- Dave (employee) has obligation: 'read reports' (set up in 00_setup via Policy DSL)
-- The context is 'always' so it evaluates TRUE
SELECT morbac.t('Dave (employee) has at least 1 pending obligation',
(SELECT COUNT(*) FROM morbac.pending_obligations(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)) >= 1,
TRUE);
-- Dave's obligation is for 'read reports'
SELECT morbac.t('Dave has pending obligation: read reports',
EXISTS(
SELECT 1 FROM morbac.pending_obligations(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)
WHERE activity = 'read' AND view = 'reports'
), TRUE);
-- Karl (no role) has no obligations
SELECT morbac.t_eq('Karl (no role) has 0 pending obligations',
(SELECT COUNT(*) FROM morbac.pending_obligations(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
))::bigint,
0);
-- ---------------------------------------------------------------------------
-- Section 2: pending_recommendations
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. pending_recommendations ---'
-- 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,
'10000000-0000-0000-0000-000000000001'::uuid
)) >= 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(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
))::bigint,
0);
-- ---------------------------------------------------------------------------
-- Section 3: Obligations / Recommendations do NOT affect is_allowed
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. Obligations do not affect authorization ---'
-- Add a pure obligation for a view Dave has no permission rule for
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'),
'obligation'
);
-- Obligation alone doesn't grant access
SELECT morbac.t('Dave has obligation for read contracts but no permission [denied]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
-- Recommendations similarly do not grant access
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'),
'recommendation'
);
-- Recommendation alone doesn't grant access
SELECT morbac.t('Dave has recommendation for read audit_logs but no permission [denied]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'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
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. user_has_role ---'
-- Alice has the CEO role at GlobalTech
SELECT morbac.t('Alice has ceo role at GlobalTech',
morbac.user_has_role(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'ceo'
), TRUE);
-- Alice does NOT have employee role (user_has_role checks direct assignment, not hierarchy)
SELECT morbac.t('Alice does NOT have employee role directly at GlobalTech',
morbac.user_has_role(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'employee'
), FALSE);
-- Dave has employee role
SELECT morbac.t('Dave has employee role at GlobalTech',
morbac.user_has_role(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'employee'
), TRUE);
-- Dave does not have auditor role
SELECT morbac.t('Dave does not have auditor role at GlobalTech',
morbac.user_has_role(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'auditor'
), FALSE);
-- Karl has no role
SELECT morbac.t('Karl does not have employee role at GlobalTech [no role]',
morbac.user_has_role(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'employee'
), FALSE);
-- Judy has engineer at Engineering but not at GlobalTech
SELECT morbac.t('Judy does not have engineer role at GlobalTech [wrong org]',
morbac.user_has_role(
'30000000-0000-0000-0000-000000000010'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'engineer'
), FALSE);
SELECT morbac.t('Judy has engineer role at Engineering [correct org]',
morbac.user_has_role(
'30000000-0000-0000-0000-000000000010'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'engineer'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 5: user_roles_in_org
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. user_roles_in_org ---'
-- Alice has exactly 1 role at GlobalTech (ceo)
SELECT morbac.t_eq('Alice has exactly 1 role at GlobalTech (ceo)',
(SELECT COUNT(*) FROM morbac.user_roles_in_org(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
))::bigint,
1);
SELECT morbac.t('Alice role at GlobalTech is ceo',
EXISTS(
SELECT 1 FROM morbac.user_roles_in_org(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)
WHERE role_name = 'ceo'
), TRUE);
-- Judy has 1 role at Engineering (engineer)
SELECT morbac.t_eq('Judy has 1 role at Engineering',
(SELECT COUNT(*) FROM morbac.user_roles_in_org(
'30000000-0000-0000-0000-000000000010'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid
))::bigint,
1);
-- Judy has 1 role at Sales (sales_rep)
SELECT morbac.t_eq('Judy has 1 role at Sales',
(SELECT COUNT(*) FROM morbac.user_roles_in_org(
'30000000-0000-0000-0000-000000000010'::uuid,
'10000000-0000-0000-0000-000000000003'::uuid
))::bigint,
1);
-- Karl has no roles at GlobalTech
SELECT morbac.t_eq('Karl has 0 roles at GlobalTech',
(SELECT COUNT(*) FROM morbac.user_roles_in_org(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
))::bigint,
0);
-- ---------------------------------------------------------------------------
-- Section 6: eval_context
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. eval_context ---'
-- 'always' context always returns TRUE
SELECT morbac.t('eval_context(always) = TRUE',
morbac.eval_context((SELECT id FROM morbac.contexts WHERE name = 'always')),
TRUE);
-- 'business_hours' context returns TRUE (our test implementation)
SELECT morbac.t('eval_context(business_hours) = TRUE',
morbac.eval_context((SELECT id FROM morbac.contexts WHERE name = 'business_hours')),
TRUE);
-- 'after_hours' context returns FALSE (our test implementation)
SELECT morbac.t('eval_context(after_hours) = FALSE',
morbac.eval_context((SELECT id FROM morbac.contexts WHERE name = 'after_hours')),
FALSE);
-- 'end_of_quarter' context returns TRUE (our test implementation)
SELECT morbac.t('eval_context(end_of_quarter) = TRUE',
morbac.eval_context((SELECT id FROM morbac.contexts WHERE name = 'end_of_quarter')),
TRUE);
-- ---------------------------------------------------------------------------
-- Section 7: Derived roles
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. Derived roles ---'
-- Create a derived role: 'senior_employee' - dynamically granted to Dave (only)
INSERT INTO morbac.roles (id, org_id, name, description)
VALUES (
'20000000-0001-0000-0000-000000000011',
'10000000-0000-0000-0000-000000000001',
'senior_employee',
'Senior employee - granted dynamically based on tenure'
);
-- Evaluator function: returns TRUE only for Dave at GlobalTech
CREATE OR REPLACE FUNCTION morbac.eval_senior_employee(p_user_id UUID, p_org_id UUID)
RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$
BEGIN
-- In a real system, this would query app.employees for tenure >= 2 years.
-- For testing, we hardcode Dave's UUID.
RETURN p_user_id = '30000000-0000-0000-0000-000000000004'::UUID
AND p_org_id = '10000000-0000-0000-0000-000000000001'::UUID;
END;
$$;
INSERT INTO morbac.derived_roles (role_id, condition_evaluator, description)
VALUES (
'20000000-0001-0000-0000-000000000011',
'morbac.eval_senior_employee'::regproc,
'Senior employee role: granted to employees with 2+ years tenure'
);
-- Grant senior_employee permission to read financial_data directly
-- (separate from the view hierarchy path, to test derived role specifically)
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
VALUES (
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000011', -- senior_employee role
'read', 'financial_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
-- Dave should have senior_employee via derived role in get_comprehensive_roles
SELECT morbac.t('Dave has senior_employee derived role in comprehensive roles',
EXISTS(
SELECT 1 FROM morbac.get_comprehensive_roles(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)
WHERE role_id = '20000000-0001-0000-0000-000000000011'
AND source = 'derived'
), TRUE);
-- Eve (intern) does NOT satisfy the evaluator - no derived role
SELECT morbac.t('Eve has no derived roles in comprehensive roles',
NOT EXISTS(
SELECT 1 FROM morbac.get_comprehensive_roles(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)
WHERE source = 'derived'
), TRUE);
-- Karl (no role) does not get the derived role
SELECT morbac.t('Karl has no derived roles in comprehensive roles',
NOT EXISTS(
SELECT 1 FROM morbac.get_comprehensive_roles(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)
WHERE source = 'derived'
), TRUE);
-- Negative assignment overrides derived role
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason)
VALUES (
'30000000-0000-0000-0000-000000000004', -- Dave
'20000000-0001-0000-0000-000000000011', -- senior_employee
'10000000-0000-0000-0000-000000000001',
'Derived role explicitly blocked for testing'
);
-- Derived role is blocked by negative assignment
SELECT morbac.t('Dave: senior_employee derived role absent after negative assignment',
NOT EXISTS(
SELECT 1 FROM morbac.get_comprehensive_roles(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)
WHERE role_id = '20000000-0001-0000-0000-000000000011'
), TRUE);
-- Dave's direct employee role is still present (negative only blocks senior_employee)
SELECT morbac.t('Dave: employee role still present after senior_employee negated',
EXISTS(
SELECT 1 FROM morbac.get_comprehensive_roles(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
)
WHERE role_id = '20000000-0001-0000-0000-000000000004'
AND source = 'direct'
), TRUE);
-- Clean up negative assignment
DELETE FROM morbac.negative_role_assignments
WHERE user_id = '30000000-0000-0000-0000-000000000004'
AND role_id = '20000000-0001-0000-0000-000000000011';
-- ---------------------------------------------------------------------------
-- Section 8: RLS helper functions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check ---'
-- Without session variables set, current_user_id / current_org_id / current_org_ids return NULL
SELECT morbac.t_null('current_user_id() returns NULL without session var',
morbac.current_user_id()::text);
SELECT morbac.t_null('current_org_id() returns NULL without session var',
morbac.current_org_id()::text);
SELECT morbac.t_null('current_org_ids() returns NULL without session var',
morbac.current_org_ids()::text);
-- rls_check without any session context returns FALSE
SELECT morbac.t('rls_check without session context returns FALSE',
morbac.rls_check('read', 'documents'),
FALSE);
-- get_user_orgs: Judy has roles in Engineering and Sales (2 orgs)
SELECT morbac.t_eq('get_user_orgs(Judy) returns 2 orgs',
(SELECT COUNT(*) FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000010'::uuid))::bigint,
2);
SELECT morbac.t('get_user_orgs(Judy) includes Engineering',
EXISTS(SELECT 1 FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000010'::uuid)
WHERE org_id = '10000000-0000-0000-0000-000000000002'),
TRUE);
SELECT morbac.t('get_user_orgs(Judy) includes Sales',
EXISTS(SELECT 1 FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000010'::uuid)
WHERE org_id = '10000000-0000-0000-0000-000000000003'),
TRUE);
-- get_user_orgs: Karl has no roles anywhere
SELECT morbac.t_eq('get_user_orgs(Karl) returns 0 orgs',
(SELECT COUNT(*) FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000011'::uuid))::bigint,
0);
-- Single-org mode: morbac.org_id set
SET morbac.user_id = '30000000-0000-0000-0000-000000000004';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
SELECT morbac.t('rls_check(read, documents) as Dave at GlobalTech',
morbac.rls_check('read', 'documents'),
TRUE);
SELECT morbac.t('rls_check(approve, documents) as Dave at GlobalTech [no permission]',
morbac.rls_check('approve', 'documents'),
FALSE);
-- Row org matches session org -> allowed
SELECT morbac.t('rls_check with matching row org_id allows access',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'),
TRUE);
-- Row org does not match session org -> denied regardless of permissions
SELECT morbac.t('rls_check with non-matching row org_id denies access',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'),
FALSE);
RESET morbac.org_id;
-- Org-list mode: morbac.org_ids set (Judy in Engineering + Sales)
SET morbac.user_id = '30000000-0000-0000-0000-000000000010';
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000002","10000000-0000-0000-0000-000000000003"]';
-- Row in Engineering: Judy is engineer there -> can read documents
SELECT morbac.t('rls_check org_ids mode: Judy reads documents in Engineering [allowed]',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'),
TRUE);
-- Row in Sales: Judy is sales_rep -> can read documents there too
SELECT morbac.t('rls_check org_ids mode: Judy reads documents in Sales [allowed]',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000003'),
TRUE);
-- Row in GlobalTech: not in org_ids list -> denied
SELECT morbac.t('rls_check org_ids mode: row org not in list [denied]',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'),
FALSE);
-- No row org provided with org_ids set -> denied
SELECT morbac.t('rls_check org_ids mode: no row org_id [denied]',
morbac.rls_check('read', 'documents'),
FALSE);
RESET morbac.org_ids;
-- All-orgs mode: no org_id / org_ids set, row org_id provided
-- Judy can read documents in Engineering
SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in Engineering [allowed]',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'),
TRUE);
-- Judy has no access to contracts at GlobalTech (no cross-org rule, no view hierarchy) -> denied
SELECT morbac.t('rls_check all-orgs mode: Judy reads contracts in GlobalTech [no rule, denied]',
morbac.rls_check('read', 'contracts', '10000000-0000-0000-0000-000000000001'),
FALSE);
-- No row org and no session org -> denied
SELECT morbac.t('rls_check all-orgs mode: no row org_id [denied]',
morbac.rls_check('read', 'documents'),
FALSE);
-- Karl has no role anywhere -> denied in all modes
SET morbac.user_id = '30000000-0000-0000-0000-000000000011';
SELECT morbac.t('rls_check(read, documents) as Karl (no role) [denied]',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'),
FALSE);
RESET morbac.user_id;
\echo ''
\echo '=== Utilities, Derived Roles, and RLS Tests Completed ==='