Files
pgmorbac/tests/09_utilities.sql
T

472 lines
18 KiB
PL/PgSQL

-- =============================================================================
-- Utilities, Derived Roles, RLS, and Policy DSL 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()
-- 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. Policy DSL idempotency — compile_policy() is safe to run multiple times
-- 8. Policy DSL error handling — reports errors for missing org/role
-- 9. Derived roles — computed via evaluator function
-- 10. RLS helper: rls_check() with session variables
--
-- Prerequisites: 00_setup.sql -> 08_admin.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '09 — UTILITIES, DERIVED ROLES, RLS, POLICY DSL'
\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: 'read reports' during end_of_quarter (evaluates TRUE)
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);
-- 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 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: Policy DSL — idempotency
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. Policy DSL idempotency ---'
-- All existing policies are already compiled (compiled=TRUE)
-- Running compile_policy() again should compile 0 new entries
SELECT morbac.t_eq('Re-running compile_policy() gives compiled_count=0 (all already compiled)',
(SELECT compiled_count FROM morbac.compile_policy()),
0);
SELECT morbac.t_eq('Re-running compile_policy() gives error_count=0',
(SELECT error_count FROM morbac.compile_policy()),
0);
-- Adding a new policy and verifying it compiles once but not twice
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('GlobalTech HQ', 'employee', 'read', 'contracts', 'permission', 'always');
SELECT morbac.t_eq('compile_policy() compiles 1 new policy entry',
(SELECT compiled_count FROM morbac.compile_policy()),
1);
SELECT morbac.t_eq('compile_policy() is idempotent: compiled_count=0 on second run',
(SELECT compiled_count FROM morbac.compile_policy()),
0);
-- Verify the rule was actually created
SELECT morbac.t_eq('Rule for employee read contracts was created',
(SELECT COUNT(*) FROM morbac.rules r
JOIN morbac.roles ro ON ro.id = r.role_id
WHERE ro.name = 'employee'
AND r.activity = 'read' AND r.view = 'contracts' AND r.modality = 'permission')::bigint,
1);
-- ---------------------------------------------------------------------------
-- Section 8: Policy DSL — error handling
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Policy DSL error handling ---'
-- Insert a policy for a non-existent organization
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('Nonexistent Corp', 'employee', 'read', 'documents', 'permission', 'always');
-- Should produce errors (org not found)
SELECT morbac.t('compile_policy: nonexistent org produces errors',
(SELECT error_count > 0 FROM morbac.compile_policy()),
TRUE);
-- Insert a policy with a valid org but nonexistent role
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('GlobalTech HQ', 'ghost_role', 'read', 'documents', 'permission', 'always');
-- Should produce errors (role not found)
SELECT morbac.t('compile_policy: nonexistent role produces errors',
(SELECT error_count > 0 FROM morbac.compile_policy()),
TRUE);
-- ---------------------------------------------------------------------------
-- Section 9: Derived roles
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 9. 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 10: RLS helper functions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 10. RLS helper: current_user_id, current_org_id, rls_check ---'
-- Without session variables set, current_user_id and current_org_id 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);
-- rls_check without session context returns FALSE (no user/org)
SELECT morbac.t('rls_check without session context returns FALSE',
morbac.rls_check('read', 'documents'),
FALSE);
-- Set session variables (Dave at GlobalTech)
-- rls_check reads morbac.user_id and morbac.org_id session variables
SET morbac.user_id = '30000000-0000-0000-0000-000000000004';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
-- rls_check uses morbac.user_id / morbac.org_id session vars
SELECT morbac.t('rls_check(read, documents) as Dave at GlobalTech',
morbac.rls_check('read', 'documents'),
TRUE);
-- rls_check for activity Dave cannot do
SELECT morbac.t('rls_check(approve, documents) as Dave at GlobalTech [no permission]',
morbac.rls_check('approve', 'documents'),
FALSE);
-- Switch to Karl (no role)
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'),
FALSE);
-- Reset session to avoid polluting other queries
RESET morbac.user_id;
RESET morbac.org_id;
\echo ''
\echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ==='