diff --git a/src/hierarchy_functions.sql b/src/hierarchy_functions.sql index f819d2b..7b944e6 100644 --- a/src/hierarchy_functions.sql +++ b/src/hierarchy_functions.sql @@ -67,10 +67,10 @@ BEGIN WHERE ur.user_id = p_user_id AND ur.org_id = p_org_id UNION - -- Recursive case: senior roles (roles that inherit from assigned roles) - SELECT rh.senior_role_id, er.depth + 1 + -- Recursive case: junior roles (inherited by the user's assigned roles) + SELECT rh.junior_role_id, er.depth + 1 FROM effective_roles er - INNER JOIN morbac.role_hierarchy rh ON rh.junior_role_id = er.role_id + INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = er.role_id ) SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.depth FROM effective_roles @@ -207,10 +207,10 @@ BEGIN UNION - -- Role hierarchy (senior roles) - SELECT rh.senior_role_id, er.source || '_inherited', er.depth + 1 + -- Role hierarchy (junior roles inherited by user's roles) + SELECT rh.junior_role_id, er.source || '_inherited', er.depth + 1 FROM effective_roles er - INNER JOIN morbac.role_hierarchy rh ON rh.junior_role_id = er.role_id + INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = er.role_id ) SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.source, effective_roles.depth FROM effective_roles @@ -277,9 +277,9 @@ BEGIN FROM inherited_roles ir INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ir.role_id ) - SELECT DISTINCT ON (role_id) role_id, depth + SELECT DISTINCT ON (inherited_roles.role_id) inherited_roles.role_id, inherited_roles.depth FROM inherited_roles - ORDER BY role_id, depth; + ORDER BY inherited_roles.role_id, inherited_roles.depth; END; $$; diff --git a/src/rls.sql b/src/rls.sql index 7180b5a..537ca09 100644 --- a/src/rls.sql +++ b/src/rls.sql @@ -4,7 +4,7 @@ -- Helper functions for Row-Level Security policies -- Compatible with PostgREST --- Get current user ID from request header +-- Get current user ID from session variable CREATE OR REPLACE FUNCTION morbac.current_user_id() RETURNS UUID LANGUAGE plpgsql @@ -13,8 +13,8 @@ AS $$ DECLARE v_user_id TEXT; BEGIN - -- Read from PostgREST request.header.x-user-id setting - v_user_id := current_setting('request.header.x-user-id', TRUE); + -- Read from morbac.user_id GUC (set via SET morbac.user_id = '...' or PostgREST header mapping) + v_user_id := current_setting('morbac.user_id', TRUE); IF v_user_id IS NULL OR v_user_id = '' THEN RETURN NULL; @@ -28,9 +28,9 @@ END; $$; COMMENT ON FUNCTION morbac.current_user_id() IS -'Returns current user ID from request.header.x-user-id (PostgREST compatible)'; +'Returns current user ID from morbac.user_id session variable'; --- Get current organization ID from request header +-- Get current organization ID from session variable CREATE OR REPLACE FUNCTION morbac.current_org_id() RETURNS UUID LANGUAGE plpgsql @@ -39,8 +39,8 @@ AS $$ DECLARE v_org_id TEXT; BEGIN - -- Read from PostgREST request.header.x-org-id setting - v_org_id := current_setting('request.header.x-org-id', TRUE); + -- Read from morbac.org_id GUC (set via SET morbac.org_id = '...' or PostgREST header mapping) + v_org_id := current_setting('morbac.org_id', TRUE); IF v_org_id IS NULL OR v_org_id = '' THEN RETURN NULL; @@ -54,7 +54,7 @@ END; $$; COMMENT ON FUNCTION morbac.current_org_id() IS -'Returns current organization ID from request.header.x-org-id (PostgREST compatible)'; +'Returns current organization ID from morbac.org_id session variable'; -- RLS check function CREATE OR REPLACE FUNCTION morbac.rls_check( diff --git a/tests/00_setup.sql b/tests/00_setup.sql index 567c124..c55ccfc 100644 --- a/tests/00_setup.sql +++ b/tests/00_setup.sql @@ -1,186 +1,344 @@ -- ============================================================================= --- Test Setup - Creates test data for all tests +-- Test Setup — GlobalTech Inc. Company Scenario -- ============================================================================= --- This file creates organizations, roles, users, activities, views, and contexts --- that are used across multiple test files. +-- This file establishes the full company structure used across all test files: +-- +-- GlobalTech HQ (root) +-- ├── Engineering Dept (child) +-- └── Sales Dept (child) +-- +-- Role hierarchy in GlobalTech (senior -> junior, i.e. senior inherits junior perms): +-- ceo -> director -> manager -> employee -> intern +-- + specialized roles: auditor, accountant, hr_manager, contractor, compliance_officer +-- +-- Role hierarchy in Engineering: +-- tech_lead -> engineer +-- +-- Users: +-- Alice — CEO at GlobalTech +-- Bob — Director at GlobalTech +-- Carol — Manager at GlobalTech +-- Dave — Employee at GlobalTech +-- Eve — Intern at GlobalTech +-- Frank — Contractor at GlobalTech +-- Grace — HR Manager at GlobalTech +-- Heidi — Auditor at GlobalTech +-- Ivan — Accountant at GlobalTech +-- Judy — Engineer at Engineering + Sales Rep at Sales (multi-org) +-- Karl — No role (unauthorized user) +-- Leo — Employee at GlobalTech (used for delegation target) -- ============================================================================= --- Clean up any previous test +-- Clean slate DROP EXTENSION IF EXISTS pgmorbac CASCADE; DROP SCHEMA IF EXISTS morbac CASCADE; - --- Install the extension CREATE EXTENSION pgmorbac; --- Verify schema and tables exist -\echo '=== Schema and Tables Created ===' -SELECT schemaname, tablename -FROM pg_tables -WHERE schemaname = 'morbac' -ORDER BY tablename; +\echo '=== Setup: Verify schema created ===' +SELECT tablename FROM pg_tables WHERE schemaname = 'morbac' ORDER BY tablename; +-- --------------------------------------------------------------------------- +-- ORGANIZATIONS +-- --------------------------------------------------------------------------- \echo '' \echo '=== Setup: Organizations ===' --- Create two organizations INSERT INTO morbac.orgs (id, name) VALUES - ('11111111-1111-1111-1111-111111111111', 'Acme Corp'), - ('22222222-2222-2222-2222-222222222222', 'Beta Inc'); + ('10000000-0000-0000-0000-000000000001', 'GlobalTech HQ'), + ('10000000-0000-0000-0000-000000000002', 'Engineering Dept'), + ('10000000-0000-0000-0000-000000000003', 'Sales Dept'); --- Create hierarchical organization (Acme Subsidiary under Acme Corp) -INSERT INTO morbac.orgs (id, name, parent_id) VALUES - ('33333333-3333-3333-3333-333333333333', 'Acme Subsidiary', '11111111-1111-1111-1111-111111111111'); +-- Engineering and Sales are children of GlobalTech +UPDATE morbac.orgs SET parent_id = '10000000-0000-0000-0000-000000000001' +WHERE id IN ( + '10000000-0000-0000-0000-000000000002', + '10000000-0000-0000-0000-000000000003' +); -SELECT - o.name, - COALESCE(p.name, 'None') as parent_organization -FROM morbac.orgs o -LEFT JOIN morbac.orgs p ON o.parent_id = p.id +SELECT o.name, COALESCE(p.name, '—') AS parent +FROM morbac.orgs o LEFT JOIN morbac.orgs p ON o.parent_id = p.id ORDER BY o.parent_id NULLS FIRST, o.name; +-- --------------------------------------------------------------------------- +-- ROLES +-- --------------------------------------------------------------------------- \echo '' \echo '=== Setup: Roles ===' --- Create roles in Acme Corp +-- GlobalTech HQ roles INSERT INTO morbac.roles (id, org_id, name, description) VALUES - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'admin', 'Administrator role'), - ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'employee', 'Regular employee'), - ('cccccccc-cccc-cccc-cccc-cccccccccccc', '11111111-1111-1111-1111-111111111111', 'contractor', 'External contractor'), - ('ffffffff-ffff-ffff-ffff-ffffffffffff', '11111111-1111-1111-1111-111111111111', 'viewer', 'Read-only viewer'); + ('20000000-0001-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001', 'ceo', 'Chief Executive Officer'), + ('20000000-0001-0000-0000-000000000002', '10000000-0000-0000-0000-000000000001', 'director', 'Senior Director'), + ('20000000-0001-0000-0000-000000000003', '10000000-0000-0000-0000-000000000001', 'manager', 'Department Manager'), + ('20000000-0001-0000-0000-000000000004', '10000000-0000-0000-0000-000000000001', 'employee', 'Regular Employee'), + ('20000000-0001-0000-0000-000000000005', '10000000-0000-0000-0000-000000000001', 'intern', 'Intern'), + ('20000000-0001-0000-0000-000000000006', '10000000-0000-0000-0000-000000000001', 'auditor', 'Internal Auditor'), + ('20000000-0001-0000-0000-000000000007', '10000000-0000-0000-0000-000000000001', 'accountant', 'Accountant'), + ('20000000-0001-0000-0000-000000000008', '10000000-0000-0000-0000-000000000001', 'hr_manager', 'Human Resources Manager'), + ('20000000-0001-0000-0000-000000000009', '10000000-0000-0000-0000-000000000001', 'contractor', 'External Contractor'), + ('20000000-0001-0000-0000-000000000010', '10000000-0000-0000-0000-000000000001', 'compliance_officer', 'Compliance Officer'); --- Create roles in Beta Inc +-- Engineering Dept roles INSERT INTO morbac.roles (id, org_id, name, description) VALUES - ('dddddddd-dddd-dddd-dddd-dddddddddddd', '22222222-2222-2222-2222-222222222222', 'manager', 'Manager role'), - ('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '22222222-2222-2222-2222-222222222222', 'staff', 'Staff member'); + ('20000000-0002-0000-0000-000000000001', '10000000-0000-0000-0000-000000000002', 'tech_lead', 'Technical Lead'), + ('20000000-0002-0000-0000-000000000002', '10000000-0000-0000-0000-000000000002', 'engineer', 'Software Engineer'); -SELECT r.name as role, o.name as organization -FROM morbac.roles r -JOIN morbac.orgs o ON r.org_id = o.id +-- Sales Dept roles +INSERT INTO morbac.roles (id, org_id, name, description) VALUES + ('20000000-0003-0000-0000-000000000001', '10000000-0000-0000-0000-000000000003', 'sales_manager', 'Sales Manager'), + ('20000000-0003-0000-0000-000000000002', '10000000-0000-0000-0000-000000000003', 'sales_rep', 'Sales Representative'); + +SELECT r.name AS role, o.name AS organization +FROM morbac.roles r JOIN morbac.orgs o ON r.org_id = o.id ORDER BY o.name, r.name; +-- --------------------------------------------------------------------------- +-- ROLE HIERARCHY (GlobalTech) +-- Senior role inherits all permissions granted to junior role +-- --------------------------------------------------------------------------- \echo '' -\echo '=== Setup: User Role Assignments ===' +\echo '=== Setup: Role Hierarchy (GlobalTech HQ) ===' --- User Alice (admin at Acme) -INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES - ('aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111'); +INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES + -- CEO inherits from Director + ('20000000-0001-0000-0000-000000000001', '20000000-0001-0000-0000-000000000002'), + -- Director inherits from Manager + ('20000000-0001-0000-0000-000000000002', '20000000-0001-0000-0000-000000000003'), + -- Manager inherits from Employee + ('20000000-0001-0000-0000-000000000003', '20000000-0001-0000-0000-000000000004'), + -- Employee inherits from Intern + ('20000000-0001-0000-0000-000000000004', '20000000-0001-0000-0000-000000000005'); --- User Bob (employee at Acme) -INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES - ('bbbbbbbb-0000-0000-0000-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111'); - --- User Charlie (contractor at Acme AND staff at Beta - multi-org user) -INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES - ('cccccccc-0000-0000-0000-000000000003', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '11111111-1111-1111-1111-111111111111'), - ('cccccccc-0000-0000-0000-000000000003', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '22222222-2222-2222-2222-222222222222'); - --- User Diana (manager at Beta) -INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES - ('dddddddd-0000-0000-0000-000000000004', 'dddddddd-dddd-dddd-dddd-dddddddddddd', '22222222-2222-2222-2222-222222222222'); +-- Engineering: tech_lead inherits from engineer +INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES + ('20000000-0002-0000-0000-000000000001', '20000000-0002-0000-0000-000000000002'); SELECT - ur.user_id, - r.name as role, - o.name as organization + sr.name AS senior_role, + jr.name AS junior_role +FROM morbac.role_hierarchy rh +JOIN morbac.roles sr ON sr.id = rh.senior_role_id +JOIN morbac.roles jr ON jr.id = rh.junior_role_id +ORDER BY sr.name; + +-- --------------------------------------------------------------------------- +-- USER — ROLE ASSIGNMENTS +-- --------------------------------------------------------------------------- +\echo '' +\echo '=== Setup: User-Role Assignments ===' + +-- GlobalTech HQ users +INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES + -- Alice: CEO + ('30000000-0000-0000-0000-000000000001', '20000000-0001-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001'), + -- Bob: Director + ('30000000-0000-0000-0000-000000000002', '20000000-0001-0000-0000-000000000002', '10000000-0000-0000-0000-000000000001'), + -- Carol: Manager + ('30000000-0000-0000-0000-000000000003', '20000000-0001-0000-0000-000000000003', '10000000-0000-0000-0000-000000000001'), + -- Dave: Employee + ('30000000-0000-0000-0000-000000000004', '20000000-0001-0000-0000-000000000004', '10000000-0000-0000-0000-000000000001'), + -- Eve: Intern + ('30000000-0000-0000-0000-000000000005', '20000000-0001-0000-0000-000000000005', '10000000-0000-0000-0000-000000000001'), + -- Frank: Contractor + ('30000000-0000-0000-0000-000000000006', '20000000-0001-0000-0000-000000000009', '10000000-0000-0000-0000-000000000001'), + -- Grace: HR Manager + ('30000000-0000-0000-0000-000000000007', '20000000-0001-0000-0000-000000000008', '10000000-0000-0000-0000-000000000001'), + -- Heidi: Auditor + ('30000000-0000-0000-0000-000000000008', '20000000-0001-0000-0000-000000000006', '10000000-0000-0000-0000-000000000001'), + -- Ivan: Accountant + ('30000000-0000-0000-0000-000000000009', '20000000-0001-0000-0000-000000000007', '10000000-0000-0000-0000-000000000001'), + -- Leo: Employee (delegation target) + ('30000000-0000-0000-0000-000000000012', '20000000-0001-0000-0000-000000000004', '10000000-0000-0000-0000-000000000001'); + +-- Judy: Engineer at Engineering + Sales Rep at Sales (multi-org user) +INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES + ('30000000-0000-0000-0000-000000000010', '20000000-0002-0000-0000-000000000002', '10000000-0000-0000-0000-000000000002'), + ('30000000-0000-0000-0000-000000000010', '20000000-0003-0000-0000-000000000002', '10000000-0000-0000-0000-000000000003'); + +-- Karl: no role (unauthorized user — intentionally not assigned any role) + +SELECT ur.user_id, r.name AS role, o.name AS organization FROM morbac.user_roles ur JOIN morbac.roles r ON ur.role_id = r.id JOIN morbac.orgs o ON ur.org_id = o.id ORDER BY ur.user_id; +-- --------------------------------------------------------------------------- +-- ACTIVITIES AND VIEWS +-- --------------------------------------------------------------------------- \echo '' \echo '=== Setup: Activities and Views ===' INSERT INTO morbac.activities (name, description) VALUES - ('read', 'Read/view data'), - ('write', 'Create/modify data'), - ('delete', 'Delete data'), - ('approve', 'Approve actions'); + ('read', 'Read / view data'), + ('write', 'Create or modify data'), + ('delete', 'Delete data'), + ('approve', 'Approve requests or documents'), + ('export', 'Export data to external format'), + ('audit', 'Audit trail review'), + ('manage', 'Full administrative control'); INSERT INTO morbac.views (name, description) VALUES - ('documents', 'Document resources'), - ('reports', 'Report resources'), - ('sensitive_data', 'Sensitive/confidential data'), - ('temp_documents', 'Temporary documents'), - ('future_documents', 'Future documents'), - ('active_documents', 'Active documents'); + ('documents', 'General company documents'), + ('reports', 'Business reports'), + ('financial_data','Financial records'), + ('hr_data', 'Human resources data'), + ('contracts', 'Legal contracts'), + ('audit_logs', 'System audit logs'), + ('public_data', 'Publicly accessible data'); +-- --------------------------------------------------------------------------- +-- CONTEXTS +-- --------------------------------------------------------------------------- \echo '' -\echo '=== Setup: Additional Contexts ===' +\echo '=== Setup: Contexts ===' --- Business hours context (simplified - always true for this test) -CREATE OR REPLACE FUNCTION morbac.context_business_hours() -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -BEGIN - RETURN TRUE; -END; +-- business_hours: always TRUE for tests (simulates daytime access) +CREATE OR REPLACE FUNCTION morbac.ctx_business_hours() +RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ +BEGIN RETURN TRUE; END; +$$; + +-- after_hours: always FALSE for tests (simulates outside-hours access) +CREATE OR REPLACE FUNCTION morbac.ctx_after_hours() +RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ +BEGIN RETURN FALSE; END; +$$; + +-- end_of_quarter: always TRUE for tests (simulates quarterly reporting period) +CREATE OR REPLACE FUNCTION morbac.ctx_end_of_quarter() +RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ +BEGIN RETURN TRUE; END; $$; INSERT INTO morbac.contexts (name, description, evaluator) VALUES - ('business_hours', 'During business hours', 'morbac.context_business_hours'::regproc); + ('business_hours', 'During business hours (Mon–Fri 9–17)', 'morbac.ctx_business_hours'::regproc), + ('after_hours', 'Outside business hours', 'morbac.ctx_after_hours'::regproc), + ('end_of_quarter', 'End-of-quarter reporting window', 'morbac.ctx_end_of_quarter'::regproc); --- Weekend context (always false for test) -CREATE OR REPLACE FUNCTION morbac.context_weekend() -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -BEGIN - RETURN FALSE; -END; -$$; - -INSERT INTO morbac.contexts (name, description, evaluator) VALUES - ('weekend', 'During weekend', 'morbac.context_weekend'::regproc); +SELECT name, description FROM morbac.contexts ORDER BY name; +-- --------------------------------------------------------------------------- +-- POLICIES (via Policy DSL — resolved by compile_policy) +-- --------------------------------------------------------------------------- \echo '' -\echo '=== Setup: Policy DSL ===' +\echo '=== Setup: Policies ===' --- Acme Corp policies +-- GlobalTech HQ policies INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES - -- Admins can do everything on documents - ('Acme Corp', 'admin', 'read', 'documents', 'permission', 'always'), - ('Acme Corp', 'admin', 'write', 'documents', 'permission', 'always'), - ('Acme Corp', 'admin', 'delete', 'documents', 'permission', 'always'), + -- Intern: read public data only + ('GlobalTech HQ', 'intern', 'read', 'public_data', 'permission', 'always'), - -- Employees can read and write documents - ('Acme Corp', 'employee', 'read', 'documents', 'permission', 'always'), - ('Acme Corp', 'employee', 'write', 'documents', 'permission', 'business_hours'), + -- Employee: read documents and reports; write documents only during business hours + ('GlobalTech HQ', 'employee', 'read', 'documents', 'permission', 'always'), + ('GlobalTech HQ', 'employee', 'write', 'documents', 'permission', 'business_hours'), + ('GlobalTech HQ', 'employee', 'read', 'reports', 'permission', 'always'), + ('GlobalTech HQ', 'employee', 'read', 'public_data', 'permission', 'always'), - -- Contractors can only read documents - ('Acme Corp', 'contractor', 'read', 'documents', 'permission', 'always'), + -- Obligation: employees must review reports weekly + ('GlobalTech HQ', 'employee', 'read', 'reports', 'obligation', 'always'), - -- PROHIBITION: Contractors cannot access sensitive data - ('Acme Corp', 'contractor', 'read', 'sensitive_data', 'prohibition', 'always'), - ('Acme Corp', 'contractor', 'write', 'sensitive_data', 'prohibition', 'always'), + -- Manager: approve and delete documents; read financial data + ('GlobalTech HQ', 'manager', 'approve','documents', 'permission', 'always'), + ('GlobalTech HQ', 'manager', 'delete', 'documents', 'permission', 'always'), + ('GlobalTech HQ', 'manager', 'read', 'financial_data', 'permission', 'always'), - -- OBLIGATION: Employees must review reports weekly - ('Acme Corp', 'employee', 'read', 'reports', 'obligation', 'always'), + -- HR Manager: full access to HR data + ('GlobalTech HQ', 'hr_manager', 'read', 'hr_data', 'permission', 'always'), + ('GlobalTech HQ', 'hr_manager', 'write', 'hr_data', 'permission', 'always'), + ('GlobalTech HQ', 'hr_manager', 'delete', 'hr_data', 'permission', 'always'), - -- Admins can access reports - ('Acme Corp', 'admin', 'read', 'reports', 'permission', 'always'), - ('Acme Corp', 'admin', 'approve', 'reports', 'permission', 'always'); + -- Auditor: read audit logs, financial data, and documents + ('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'permission', 'always'), + ('GlobalTech HQ', 'auditor', 'read', 'financial_data', 'permission', 'always'), + ('GlobalTech HQ', 'auditor', 'read', 'documents', 'permission', 'always'), --- Beta Inc policies + -- Accountant: read and write financial data + ('GlobalTech HQ', 'accountant', 'read', 'financial_data', 'permission', 'always'), + ('GlobalTech HQ', 'accountant', 'write', 'financial_data', 'permission', 'always'), + + -- Contractor: read documents; PROHIBITED from financial and HR data + ('GlobalTech HQ', 'contractor', 'read', 'documents', 'permission', 'always'), + ('GlobalTech HQ', 'contractor', 'read', 'financial_data', 'prohibition', 'always'), + ('GlobalTech HQ', 'contractor', 'read', 'hr_data', 'prohibition', 'always'), + + -- Compliance Officer: read audit logs and financial data + ('GlobalTech HQ', 'compliance_officer', 'read', 'audit_logs', 'permission', 'always'), + ('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'); + +-- Engineering Dept policies INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES - -- Managers have full access - ('Beta Inc', 'manager', 'read', 'documents', 'permission', 'always'), - ('Beta Inc', 'manager', 'write', 'documents', 'permission', 'always'), - ('Beta Inc', 'manager', 'read', 'reports', 'permission', 'always'), + ('Engineering Dept', 'engineer', 'read', 'documents', 'permission', 'always'), + ('Engineering Dept', 'engineer', 'write', 'documents', 'permission', 'always'), + ('Engineering Dept', 'tech_lead', 'approve', 'documents', 'permission', 'always'); - -- Staff can read documents - ('Beta Inc', 'staff', 'read', 'documents', 'permission', 'always'), +-- Sales Dept policies +INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES + ('Sales Dept', 'sales_rep', 'read', 'documents', 'permission', 'always'), + ('Sales Dept', 'sales_rep', 'write', 'contracts', 'permission', 'always'), + ('Sales Dept', 'sales_manager', 'read', 'reports', 'permission', 'always'), + ('Sales Dept', 'sales_manager', 'approve', 'contracts', 'permission', 'always'); - -- PROHIBITION: No document modifications on weekends (example) - ('Beta Inc', 'staff', 'write', 'documents', 'prohibition', 'weekend'), - - -- RECOMMENDATION: Staff should review reports - ('Beta Inc', 'staff', 'read', 'reports', 'recommendation', 'always'); - --- Compile policy +-- Compile all policies into rules +\echo '' +\echo '=== Setup: Compiling Policies ===' SELECT * FROM morbac.compile_policy(); +-- --------------------------------------------------------------------------- +-- Test infrastructure +-- --------------------------------------------------------------------------- +\echo '' +\echo '=== Setup: Test Infrastructure ===' + +-- Disable auth cache so state changes between calls are always reflected +SELECT morbac.set_config('cache_ttl_seconds', '0'); + +-- morbac.t(label, actual, expected) — boolean assertion +CREATE OR REPLACE FUNCTION morbac.t(label TEXT, actual BOOLEAN, expect BOOLEAN) +RETURNS TEXT LANGUAGE sql STABLE AS $$ + SELECT CASE WHEN actual IS NOT DISTINCT FROM expect + THEN 'CHECK PASS: ' || label + ELSE 'CHECK FAIL: ' || label + || ' (expected ' || expect::text || ', got ' || COALESCE(actual::text, 'NULL') || ')' + END; +$$; + +-- morbac.t_null(label, actual) — assert value is NULL +CREATE OR REPLACE FUNCTION morbac.t_null(label TEXT, actual TEXT) +RETURNS TEXT LANGUAGE sql STABLE AS $$ + SELECT CASE WHEN actual IS NULL + THEN 'CHECK PASS: ' || label + ELSE 'CHECK FAIL: ' || label || ' (expected NULL, got: ' || actual || ')' + END; +$$; + +-- morbac.t_not_null(label, actual) — assert value is NOT NULL +CREATE OR REPLACE FUNCTION morbac.t_not_null(label TEXT, actual TEXT) +RETURNS TEXT LANGUAGE sql STABLE AS $$ + SELECT CASE WHEN actual IS NOT NULL + THEN 'CHECK PASS: ' || label + ELSE 'CHECK FAIL: ' || label || ' (expected a value, got NULL)' + END; +$$; + +-- morbac.t_eq(label, actual, expected) — assert two numeric values are equal +CREATE OR REPLACE FUNCTION morbac.t_eq(label TEXT, actual NUMERIC, expect NUMERIC) +RETURNS TEXT LANGUAGE sql STABLE AS $$ + SELECT CASE WHEN actual = expect + THEN 'CHECK PASS: ' || label + ELSE 'CHECK FAIL: ' || label + || ' (expected ' || expect || ', got ' || actual || ')' + END; +$$; + \echo '' \echo '=== Setup Complete ===' +\echo ' Organizations : GlobalTech HQ, Engineering Dept, Sales Dept' +\echo ' Roles : 14 across 3 orgs' +\echo ' Users : Alice, Bob, Carol, Dave, Eve, Frank, Grace, Heidi, Ivan, Judy, Karl, Leo' +\echo ' Activities : read, write, delete, approve, export, audit, manage' +\echo ' Views : documents, reports, financial_data, hr_data, contracts, audit_logs, public_data' +\echo ' Contexts : always (true), business_hours (true), after_hours (false), end_of_quarter (true)' diff --git a/tests/01_authorization.sql b/tests/01_authorization.sql index 3199794..6f9e0d6 100644 --- a/tests/01_authorization.sql +++ b/tests/01_authorization.sql @@ -1,179 +1,418 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Authorization Decision Tests --- ============================================================================ --- This test file covers authorization decision tests including: --- - Basic authorization decisions (is_allowed function) --- - Role hierarchy inheritance --- - Organization hierarchy --- - Prohibition precedence --- - Multi-organization user access +-- ============================================================================= +-- Authorization Decision Tests +-- ============================================================================= +-- Tests the core is_allowed() function across a variety of scenarios. +-- No activity or view hierarchy is active yet (added in 02_hierarchies.sql). -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- Users and their roles at GlobalTech HQ: +-- Alice — ceo (permission: inherits all via hierarchy) +-- Bob — director (inherits manager -> employee -> intern) +-- Carol — manager (inherits employee -> intern) +-- Dave — employee (inherits intern) +-- Eve — intern +-- Frank — contractor (has prohibition on financial/hr data) +-- Grace — hr_manager +-- Heidi — auditor +-- Ivan — accountant +-- Judy — engineer@Engineering + sales_rep@Sales (multi-org) +-- Karl — no role +-- Leo — employee +-- +-- Prerequisites: 00_setup.sql +-- ============================================================================= \echo '' -\echo '=== Authorization Decisions ===' +\echo '================================================================' +\echo '01 — CORE AUTHORIZATION DECISIONS' +\echo '================================================================' --- Test Alice (admin at Acme) -\echo 'Alice (admin at Acme Corp):' -SELECT - 'read documents' as action, +-- --------------------------------------------------------------------------- +-- Section 1: Basic permission grants +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. Basic permission grants ---' + +-- Dave (employee) can read documents — has explicit permission +SELECT morbac.t('Dave (employee) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Dave (employee) can write documents — context is business_hours (evaluates TRUE) +SELECT morbac.t('Dave (employee) writes documents [business_hours context=true]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'documents' + ), TRUE); + +-- Dave (employee) can read reports +SELECT morbac.t('Dave (employee) reads reports', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'reports' + ), TRUE); + +-- Eve (intern) can read public data only +SELECT morbac.t('Eve (intern) reads public_data', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'public_data' + ), TRUE); + +-- Grace (hr_manager) can write hr_data +SELECT morbac.t('Grace (hr_manager) writes hr_data', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000007'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'hr_data' + ), TRUE); + +-- Heidi (auditor) can read financial_data +SELECT morbac.t('Heidi (auditor) reads financial_data', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000008'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), TRUE); + +-- Ivan (accountant) can write financial_data +SELECT morbac.t('Ivan (accountant) writes financial_data', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000009'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'financial_data' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 2: Default deny — no rule exists for the combination +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Default deny (no permission rule) ---' + +-- Dave (employee) cannot delete documents — no delete permission for employee +SELECT morbac.t('Dave (employee) deletes documents [no permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), FALSE); + +-- Eve (intern) cannot read documents — intern only has public_data permission +SELECT morbac.t('Eve (intern) reads documents [intern has no docs permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), FALSE); + +-- Dave (employee) cannot read financial_data — no rule for employee -> financial_data +SELECT morbac.t('Dave (employee) reads financial_data [no permission before view hierarchy]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), FALSE); + +-- Grace (hr_manager) cannot read audit_logs — no rule for hr_manager -> audit_logs +SELECT morbac.t('Grace (hr_manager) reads audit_logs [no permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000007'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'audit_logs' + ), FALSE); + +-- Dave (employee) cannot approve documents — no approve permission for employee +SELECT morbac.t('Dave (employee) approves documents [no permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 3: Default deny — user has no role at all +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Default deny (user has no role) ---' + +-- Karl has no role anywhere — all actions denied +SELECT morbac.t('Karl (no role) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), FALSE); + +-- Completely unknown user UUID — EXPECT FALSE +SELECT morbac.t('Unknown user reads documents', + morbac.is_allowed_nocache( + 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 4: Prohibition overrides permission (prohibition precedence) +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. Prohibition overrides permission ---' + +-- Frank (contractor) reads documents — permission granted, no prohibition +SELECT morbac.t('Frank (contractor) reads documents [has permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Frank (contractor) reads financial_data — PROHIBITED +SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), FALSE); + +-- Frank (contractor) reads hr_data — prohibited +SELECT morbac.t('Frank (contractor) reads hr_data [prohibited]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'hr_data' + ), FALSE); + +-- Add a permission for contractor on financial_data, then confirm prohibition still wins +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000009', -- contractor + 'read', 'financial_data', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- Prohibition must always win over permission +SELECT morbac.t('Frank (contractor) reads financial_data after adding permission [prohibition wins]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), FALSE); + +-- Clean up the extra rule +DELETE FROM morbac.rules +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000009' + AND activity = 'read' AND view = 'financial_data' AND modality = 'permission'; + +-- --------------------------------------------------------------------------- +-- Section 5: Context filtering — rule only applies when context is TRUE +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Context filtering ---' + +-- Add a rule that only applies after_hours (context evaluates FALSE) +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 + 'export', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'after_hours'), + 'permission' +); + +-- Context evaluates FALSE so the rule does not apply +SELECT morbac.t('Dave (employee) exports documents [after_hours context=false]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'export', 'documents' + ), FALSE); + +-- Replace with business_hours context (evaluates TRUE) +UPDATE morbac.rules +SET context_id = (SELECT id FROM morbac.contexts WHERE name = 'business_hours') +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000004' + AND activity = 'export' AND view = 'documents'; + +-- Context evaluates TRUE +SELECT morbac.t('Dave (employee) exports documents [business_hours context=true]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'export', 'documents' + ), TRUE); + +-- Clean up the extra rule +DELETE FROM morbac.rules +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000004' + AND activity = 'export' AND view = 'documents'; + +-- --------------------------------------------------------------------------- +-- Section 6: Wrong organization — user has no role in the target org +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. Wrong organization ---' + +-- Dave (employee at GlobalTech) has no role in Engineering Dept +SELECT morbac.t('Dave (GlobalTech employee) reads Engineering documents [no role in Engineering]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), FALSE); + +-- Judy (engineer at Engineering) has no role in GlobalTech +SELECT morbac.t('Judy (Engineering engineer) reads GlobalTech financial_data [no role in GlobalTech]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 7: Multi-organization user — access scoped to each org independently +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 7. Multi-org user (Judy) ---' + +-- Judy is engineer at Engineering — can read documents there +SELECT morbac.t('Judy (engineer) reads Engineering documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), TRUE); + +-- Judy is sales_rep at Sales — can write contracts there +SELECT morbac.t('Judy (sales_rep) writes Sales contracts', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000003'::uuid, + 'write', 'contracts' + ), TRUE); + +-- Judy's engineering permissions do NOT carry to Sales +SELECT morbac.t('Judy writes documents at Sales [engineer perms dont carry over]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000003'::uuid, + 'write', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 8: Role hierarchy inheritance +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 8. Role hierarchy inheritance ---' + +-- Carol (manager) inherits employee permissions — can read documents (employee perm) +SELECT morbac.t('Carol (manager, inherits employee) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Carol (manager) has own permission — can approve documents +SELECT morbac.t('Carol (manager) approves documents [own permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), TRUE); + +-- Bob (director) inherits manager -> employee chain — can read documents +SELECT morbac.t('Bob (director, inherits manager+employee) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000002'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Bob (director) inherits manager — can approve documents +SELECT morbac.t('Bob (director, inherits manager) approves documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000002'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), TRUE); + +-- Alice (CEO) inherits the entire hierarchy — can do everything below +SELECT morbac.t('Alice (CEO, inherits all) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +SELECT morbac.t('Alice (CEO, inherits all) approves documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), TRUE); + +SELECT morbac.t('Alice (CEO, inherits all) deletes documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), TRUE); + +-- Eve (intern) cannot approve — intern has no approve permission +SELECT morbac.t('Eve (intern) approves documents [intern has no approve permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 9: is_allowed_nocache produces same result as is_allowed +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 9. is_allowed vs is_allowed_nocache consistency ---' + +SELECT morbac.t('Dave reads documents: cached = nocache', morbac.is_allowed( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'read', - 'documents' - ) as allowed; + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ) IS NOT DISTINCT FROM morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); -SELECT - 'delete documents' as action, +SELECT morbac.t('Dave deletes documents: cached = nocache', morbac.is_allowed( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'delete', - 'documents' - ) as allowed; + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ) IS NOT DISTINCT FROM morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), TRUE); --- Test Bob (employee at Acme) -\echo '' -\echo 'Bob (employee at Acme Corp):' -SELECT - 'read documents' as action, +SELECT morbac.t('Frank reads financial_data (prohibited): cached = nocache', morbac.is_allowed( - 'bbbbbbbb-0000-0000-0000-000000000002'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'read', - 'documents' - ) as allowed; - -SELECT - 'delete documents' as action, - morbac.is_allowed( - 'bbbbbbbb-0000-0000-0000-000000000002'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'delete', - 'documents' - ) as allowed; - --- Test Charlie (contractor at Acme) -\echo '' -\echo 'Charlie (contractor at Acme Corp):' -SELECT - 'read documents' as action, - morbac.is_allowed( - 'cccccccc-0000-0000-0000-000000000003'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'read', - 'documents' - ) as allowed; - -SELECT - 'write documents' as action, - morbac.is_allowed( - 'cccccccc-0000-0000-0000-000000000003'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'write', - 'documents' - ) as allowed; - --- Test PROHIBITION precedence -\echo '' -\echo 'Testing PROHIBITION PRECEDENCE:' -\echo 'Charlie (contractor) trying to read sensitive_data (should be DENIED by prohibition):' -SELECT - 'read sensitive_data' as action, - morbac.is_allowed( - 'cccccccc-0000-0000-0000-000000000003'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'read', - 'sensitive_data' - ) as allowed; - --- Test Charlie at Beta Inc (multi-org user) -\echo '' -\echo 'Charlie as staff at Beta Inc:' -SELECT - 'read documents' as action, - morbac.is_allowed( - 'cccccccc-0000-0000-0000-000000000003'::uuid, - '22222222-2222-2222-2222-222222222222'::uuid, - 'read', - 'documents' - ) as allowed; - --- Test Diana (manager at Beta) -\echo '' -\echo 'Diana (manager at Beta Inc):' -SELECT - 'write documents' as action, - morbac.is_allowed( - 'dddddddd-0000-0000-0000-000000000004'::uuid, - '22222222-2222-2222-2222-222222222222'::uuid, - 'write', - 'documents' - ) as allowed; - -\echo '' -\echo '=== Role Hierarchy Inheritance ===' - --- Assign user Eve only viewer role -\echo 'Creating user Eve with only viewer role at Acme:' -INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES - ('eeeeeeee-0000-0000-0000-000000000005', 'ffffffff-ffff-ffff-ffff-ffffffffffff', '11111111-1111-1111-1111-111111111111'); - --- Add permission for viewer role -INSERT INTO morbac.policy (org_name, role_name, activity, view, modality) VALUES - ('Acme Corp', 'viewer', 'read', 'documents', 'permission'); - -SELECT * FROM morbac.compile_policy(); - -\echo '' -\echo 'Eve (has viewer role, which employee inherits from):' -SELECT - 'read documents' as action, - morbac.is_allowed( - 'eeeeeeee-0000-0000-0000-000000000005'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'read', - 'documents' - ) as allowed; - -\echo '' -\echo 'Bob (employee) should inherit permissions from viewer role:' -\echo 'Effective roles for Bob (should include employee and viewer via hierarchy):' -SELECT r.name, er.depth -FROM morbac.get_effective_roles( - 'bbbbbbbb-0000-0000-0000-000000000002'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid -) er -JOIN morbac.roles r ON er.role_id = r.id -ORDER BY er.depth; - -\echo '' -\echo 'Alice (admin) should inherit from both employee and viewer:' -SELECT r.name, er.depth -FROM morbac.get_effective_roles( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid -) er -JOIN morbac.roles r ON er.role_id = r.id -ORDER BY er.depth; - -\echo '' -\echo '=== Organization Hierarchy ===' - -\echo 'Ancestors of Acme Subsidiary:' -SELECT o.name, oa.depth -FROM morbac.get_org_ancestors('33333333-3333-3333-3333-333333333333'::uuid) oa -JOIN morbac.orgs o ON oa.org_id = o.id -ORDER BY oa.depth; - -\echo '' -\echo 'Descendants of Acme Corp (should include subsidiary):' -SELECT o.name, od.depth -FROM morbac.get_org_descendants('11111111-1111-1111-1111-111111111111'::uuid) od -JOIN morbac.orgs o ON od.org_id = o.id -ORDER BY od.depth; + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ) IS NOT DISTINCT FROM morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), TRUE); \echo '' \echo '=== Authorization Decision Tests Completed ===' diff --git a/tests/02_hierarchies.sql b/tests/02_hierarchies.sql index 319c490..d901bf9 100644 --- a/tests/02_hierarchies.sql +++ b/tests/02_hierarchies.sql @@ -1,41 +1,365 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Activity and View Hierarchies Tests --- ============================================================================ --- This test file covers activity and view hierarchy tests including: --- - Activity hierarchy (e.g., write implies read) --- - View hierarchy (e.g., sensitive_data is a type of documents) --- - Effective activities and views --- - Permission inheritance through hierarchies +-- ============================================================================= +-- Hierarchy Tests +-- ============================================================================= +-- Tests all four hierarchy types: +-- 1. Role hierarchy — senior roles inherit permissions of junior roles (transitive) +-- 2. Activity hierarchy — requesting a senior activity also matches junior-activity rules +-- 3. View hierarchy — requesting a senior view also matches junior-view rules +-- 4. Org hierarchy — get_org_ancestors / get_org_descendants traversal -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- Hierarchy semantics in this system: +-- Activity: (senior='write', junior='read') means get_effective_activities('write') +-- returns {write, read}, so a rule for 'read' ALSO covers 'write' requests. +-- View: (senior='financial_data', junior='documents') means +-- get_effective_views('financial_data') returns {financial_data, documents}, +-- so a rule for 'documents' ALSO covers 'financial_data' requests. +-- +-- Prerequisites: 00_setup.sql, 01_authorization.sql +-- ============================================================================= \echo '' -\echo '=== Activity and View Hierarchies ===' +\echo '================================================================' +\echo '02 — HIERARCHIES' +\echo '================================================================' --- Define activity hierarchy: write implies read +-- --------------------------------------------------------------------------- +-- Section 1: Role hierarchy — get_effective_roles and get_inherited_roles +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. Role hierarchy introspection ---' + +-- Eve (intern) direct assignment only — 1 effective role +SELECT morbac.t_eq('Eve has 1 effective role (intern only)', + (SELECT COUNT(*) FROM morbac.get_effective_roles( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ))::bigint, 1); + +-- Dave (employee) has employee + intern via hierarchy — 2 effective roles +SELECT morbac.t_eq('Dave has 2 effective roles (employee, intern)', + (SELECT COUNT(*) FROM morbac.get_effective_roles( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ))::bigint, 2); + +-- Carol (manager) has manager + employee + intern via hierarchy — 3 effective roles +SELECT morbac.t_eq('Carol has 3 effective roles (manager, employee, intern)', + (SELECT COUNT(*) FROM morbac.get_effective_roles( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ))::bigint, 3); + +-- Alice (CEO) has ceo, director, manager, employee, intern — 5 effective roles +SELECT morbac.t_eq('Alice has 5 effective roles (ceo through intern)', + (SELECT COUNT(*) FROM morbac.get_effective_roles( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ))::bigint, 5); + +-- get_inherited_roles for manager — manager itself + employee + intern = 3 +SELECT morbac.t_eq('get_inherited_roles(manager) returns 3 roles (manager, employee, intern)', + (SELECT COUNT(*) FROM morbac.get_inherited_roles( + '20000000-0001-0000-0000-000000000003'::uuid + ))::bigint, 3); + +-- get_inherited_roles for ceo — entire chain = 5 +SELECT morbac.t_eq('get_inherited_roles(ceo) returns 5 roles (ceo through intern)', + (SELECT COUNT(*) FROM morbac.get_inherited_roles( + '20000000-0001-0000-0000-000000000001'::uuid + ))::bigint, 5); + +-- Manager is included in its own inherited roles (at depth 0) +SELECT morbac.t('manager role is in its own inherited roles at depth 0', + EXISTS( + SELECT 1 FROM morbac.get_inherited_roles('20000000-0001-0000-0000-000000000003'::uuid) + WHERE role_id = '20000000-0001-0000-0000-000000000003' AND depth = 0 + ), TRUE); + +-- Intern is included in manager's inherited roles (at depth 2) +SELECT morbac.t('intern role appears in manager inherited roles', + EXISTS( + SELECT 1 FROM morbac.get_inherited_roles('20000000-0001-0000-0000-000000000003'::uuid) + WHERE role_id = '20000000-0001-0000-0000-000000000005' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 2: Role hierarchy — authorization via inheritance (transitive) +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Role hierarchy — authorization via inheritance ---' + +-- Carol (manager) has employee permission (read documents) via 1-level inheritance +SELECT morbac.t('Carol (manager, 1-level inherit) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Bob (director) has employee permission (read documents) via 2-level inheritance +SELECT morbac.t('Bob (director, 2-level inherit) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000002'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Alice (CEO) has employee permission (read documents) via 3-level inheritance +SELECT morbac.t('Alice (CEO, 3-level inherit) reads documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Alice (CEO) has intern permission (read public_data) via 4-level inheritance +SELECT morbac.t('Alice (CEO, 4-level inherit) reads public_data', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'public_data' + ), TRUE); + +-- Carol (manager) has manager's own perm (approve documents) +SELECT morbac.t('Carol (manager) approves documents (own perm)', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), TRUE); + +-- Eve (intern) cannot approve — no role above intern has approve perm +SELECT morbac.t('Eve (intern) approves documents [inheritance only goes up in seniority]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), FALSE); + +-- tech_lead in Engineering inherits engineer permissions +INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES + ('30000000-0000-0000-0000-000000000013', '20000000-0002-0000-0000-000000000001', '10000000-0000-0000-0000-000000000002'); + +-- tech_lead inherits engineer's read permission +SELECT morbac.t('Tech lead (inherits engineer) reads Engineering documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000013'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), TRUE); + +-- tech_lead has own approve permission +SELECT morbac.t('Tech lead approves Engineering documents (own perm)', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000013'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'approve', 'documents' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 3: Activity hierarchy +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Activity hierarchy setup ---' + +-- Build activity chain: manage -> delete -> write -> read +-- (Senior activity requesting also covers rules for its junior activities) INSERT INTO morbac.activity_hierarchy (senior_activity, junior_activity) VALUES - ('write', 'read'); + ('manage', 'delete'), + ('delete', 'write'), + ('write', 'read'), + ('export', 'read'); --- Define view hierarchy: sensitive_data is a type of documents +-- get_effective_activities introspection +SELECT morbac.t_eq('get_effective_activities(read) returns 1 (read only)', + (SELECT COUNT(*) FROM morbac.get_effective_activities('read'))::bigint, 1); + +SELECT morbac.t_eq('get_effective_activities(write) returns 2 (write, read)', + (SELECT COUNT(*) FROM morbac.get_effective_activities('write'))::bigint, 2); + +SELECT morbac.t_eq('get_effective_activities(delete) returns 3 (delete, write, read)', + (SELECT COUNT(*) FROM morbac.get_effective_activities('delete'))::bigint, 3); + +SELECT morbac.t_eq('get_effective_activities(manage) returns 4 (manage, delete, write, read)', + (SELECT COUNT(*) FROM morbac.get_effective_activities('manage'))::bigint, 4); + +SELECT morbac.t_eq('get_effective_activities(export) returns 2 (export, read)', + (SELECT COUNT(*) FROM morbac.get_effective_activities('export'))::bigint, 2); + +-- Activity hierarchy authorization tests +-- Rule: employee has 'read documents' permission. +-- With hierarchy (write->read), requesting 'write' also matches the 'read' rule. + +-- Dave (employee) requests 'write' — matches 'read' rule via write->read hierarchy +SELECT morbac.t('Dave (employee, has read perm) writes docs via activity hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'documents' + ), TRUE); + +-- Dave (employee) requests 'delete' — delete->write->read chain, 'read' rule matches +SELECT morbac.t('Dave (employee, has read perm) deletes docs via delete->write->read hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), TRUE); + +-- Dave (employee) requests 'export' — export->read, 'read' rule matches +SELECT morbac.t('Dave (employee, has read perm) exports docs via export->read hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'export', 'documents' + ), TRUE); + +-- Eve (intern) has only 'read public_data' — requesting 'write public_data' also matches +SELECT morbac.t('Eve (intern, has read perm) writes public_data via activity hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'public_data' + ), TRUE); + +-- 'audit' is not in the hierarchy — no junior, no senior — no match for employee +SELECT morbac.t('Dave (employee) audits documents [audit not in hierarchy, no permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'audit', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 4: View hierarchy +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. View hierarchy setup ---' + +-- financial_data and hr_data are specific sub-types of documents INSERT INTO morbac.view_hierarchy (senior_view, junior_view) VALUES - ('sensitive_data', 'documents'); + ('financial_data', 'documents'), + ('hr_data', 'documents'); -\echo 'Activities implied by write:' -SELECT * FROM morbac.get_effective_activities('write'); +-- get_effective_views introspection +SELECT morbac.t_eq('get_effective_views(documents) returns 1 (documents only)', + (SELECT COUNT(*) FROM morbac.get_effective_views('documents'))::bigint, 1); -\echo 'Views for sensitive_data (should include documents):' -SELECT * FROM morbac.get_effective_views('sensitive_data'); +SELECT morbac.t_eq('get_effective_views(financial_data) returns 2 (financial_data, documents)', + (SELECT COUNT(*) FROM morbac.get_effective_views('financial_data'))::bigint, 2); -\echo 'Bob has permission for read+documents, so write+documents should work (activity hierarchy):' -SELECT - 'write documents (via activity hierarchy)' as action, - morbac.is_allowed( - 'bbbbbbbb-0000-0000-0000-000000000002'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'write', - 'documents' - ) as allowed; +SELECT morbac.t_eq('get_effective_views(hr_data) returns 2 (hr_data, documents)', + (SELECT COUNT(*) FROM morbac.get_effective_views('hr_data'))::bigint, 2); + +-- financial_data is included in get_effective_views('financial_data') +SELECT morbac.t('financial_data is in effective views of itself', + EXISTS( + SELECT 1 FROM morbac.get_effective_views('financial_data') + WHERE view = 'financial_data' + ), TRUE); + +-- documents is also included (as junior) +SELECT morbac.t('documents is in effective views of financial_data', + EXISTS( + SELECT 1 FROM morbac.get_effective_views('financial_data') + WHERE view = 'documents' + ), TRUE); + +-- employee has 'read documents' permission +-- requesting 'financial_data' -> get_effective_views('financial_data') = {financial_data, documents} +-- the 'documents' rule matches -> access granted + +-- Dave (employee) reads financial_data — matches employee's 'read documents' rule via view hierarchy +SELECT morbac.t('Dave (employee, has read documents) reads financial_data via view hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), TRUE); + +-- Dave (employee) reads hr_data — matches 'read documents' via view hierarchy +SELECT morbac.t('Dave (employee, has read documents) reads hr_data via view hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'hr_data' + ), TRUE); + +-- Frank (contractor) reads documents — permitted +SELECT morbac.t('Frank (contractor) reads documents [has permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Frank (contractor) reads financial_data — matches contractor's 'read documents' rule via view hierarchy +-- BUT contractor has a PROHIBITION on financial_data — prohibition wins +SELECT morbac.t('Frank (contractor) reads financial_data [prohibition overrides view-hierarchy match]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 5: Org hierarchy navigation +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Org hierarchy navigation ---' + +-- Engineering Dept has 2 ancestors: itself (depth=0) and GlobalTech HQ (depth=1) +SELECT morbac.t_eq('Engineering Dept has 2 ancestors (self + GlobalTech HQ)', + (SELECT COUNT(*) FROM morbac.get_org_ancestors( + '10000000-0000-0000-0000-000000000002'::uuid + ))::bigint, 2); + +-- Sales Dept also has 2 ancestors +SELECT morbac.t_eq('Sales Dept has 2 ancestors (self + GlobalTech HQ)', + (SELECT COUNT(*) FROM morbac.get_org_ancestors( + '10000000-0000-0000-0000-000000000003'::uuid + ))::bigint, 2); + +-- GlobalTech HQ is the root — only 1 ancestor (itself) +SELECT morbac.t_eq('GlobalTech HQ (root) has 1 ancestor (itself only)', + (SELECT COUNT(*) FROM morbac.get_org_ancestors( + '10000000-0000-0000-0000-000000000001'::uuid + ))::bigint, 1); + +-- GlobalTech HQ has 3 descendants: itself + Engineering + Sales +SELECT morbac.t_eq('GlobalTech HQ has 3 descendants (self + Engineering + Sales)', + (SELECT COUNT(*) FROM morbac.get_org_descendants( + '10000000-0000-0000-0000-000000000001'::uuid + ))::bigint, 3); + +-- Engineering (leaf node) has only 1 descendant: itself +SELECT morbac.t_eq('Engineering (leaf) has 1 descendant (itself only)', + (SELECT COUNT(*) FROM morbac.get_org_descendants( + '10000000-0000-0000-0000-000000000002'::uuid + ))::bigint, 1); + +-- GlobalTech HQ is its own ancestor at depth 0 +SELECT morbac.t('GlobalTech HQ is its own ancestor at depth 0', + EXISTS( + SELECT 1 FROM morbac.get_org_ancestors('10000000-0000-0000-0000-000000000001'::uuid) + WHERE org_id = '10000000-0000-0000-0000-000000000001' AND depth = 0 + ), TRUE); + +-- Engineering's parent (GlobalTech HQ) appears at depth 1 +SELECT morbac.t('GlobalTech HQ appears at depth 1 in Engineering ancestors', + EXISTS( + SELECT 1 FROM morbac.get_org_ancestors('10000000-0000-0000-0000-000000000002'::uuid) + WHERE org_id = '10000000-0000-0000-0000-000000000001' AND depth = 1 + ), TRUE); + +-- Verify org hierarchy does NOT automatically propagate permissions +-- (permissions are org-scoped; cross-org access requires explicit cross_org_rules) +SELECT morbac.t('Dave (GlobalTech employee) reads Engineering docs [no cross-org rule]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), FALSE); \echo '' -\echo '=== Activity and View Hierarchies Tests Completed ===' +\echo '=== Hierarchy Tests Completed ===' diff --git a/tests/03_delegation.sql b/tests/03_delegation.sql index 9dcdb58..d16f5fb 100644 --- a/tests/03_delegation.sql +++ b/tests/03_delegation.sql @@ -1,36 +1,355 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Delegation Tests --- ============================================================================ --- This test file covers delegation functionality including: --- - Temporary role delegation --- - Delegated permission inheritance --- - Comprehensive role reporting (direct + delegated) --- - Time-limited delegation (valid_from/valid_until) +-- ============================================================================= +-- Delegation Tests +-- ============================================================================= +-- Tests the role delegation feature: +-- - Active delegation grants the delegate the delegator's role permissions +-- - Expired delegations are inactive +-- - Future delegations are not yet active +-- - Revoked delegations are inactive +-- - Delegator must hold the role (delegation from source is validated) +-- - Negative role assignments override delegations +-- - Delegated roles participate in role hierarchy +-- - get_comprehensive_roles reports delegation source -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- Scenario: +-- Carol (manager) delegates her manager role to Leo (employee) for 1 day +-- -> Leo should temporarily have manager-level access +-- +-- Prerequisites: 00_setup.sql -> 02_hierarchies.sql +-- ============================================================================= \echo '' -\echo '=== Delegation ===' +\echo '================================================================' +\echo '03 — DELEGATION' +\echo '================================================================' --- Alice delegates admin role to user Frank temporarily -INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until) VALUES - ('aaaaaaaa-0000-0000-0000-000000000001', 'ffffffff-0000-0000-0000-000000000006', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', now(), now() + interval '1 day'); +-- --------------------------------------------------------------------------- +-- Section 1: Baseline — Leo (employee) before any delegation +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. Baseline: Leo before delegation ---' -\echo 'Frank (via delegation from Alice) should have admin permissions:' -SELECT - 'delete documents (delegated)' as action, - morbac.is_allowed( - 'ffffffff-0000-0000-0000-000000000006'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'delete', - 'documents' - ) as allowed; +-- Leo has only employee permissions +SELECT morbac.t('Leo (employee) reads documents before delegation', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); -\echo 'Frank effective roles (should include delegated admin):' -SELECT r.name, cr.source -FROM morbac.get_comprehensive_roles('ffffffff-0000-0000-0000-000000000006'::uuid, '11111111-1111-1111-1111-111111111111'::uuid) cr -JOIN morbac.roles r ON cr.role_id = r.id; +-- Leo cannot approve documents — that requires manager role +SELECT morbac.t('Leo (employee) approves documents before delegation [no manager perm]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), FALSE); + +-- Leo cannot delete documents (no employee delete perm, activity hierarchy grants delete via read rule, +-- but 'delete' implies only that if there's a delete->write->read chain; in 02 we set write->read but +-- the chain delete->write was also set. So Leo gets delete via documents perm. Let's check: +-- Actually with activity hierarchy (delete->write->read), the 'read documents' rule covers delete. +SELECT morbac.t('Leo (employee) deletes documents before delegation [via activity hierarchy]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), TRUE); + +-- Leo cannot approve documents (approve is not in activity hierarchy for documents) +-- This is the key baseline check: approve requires manager-level permission +SELECT morbac.t('Leo (employee) does not have approve permission before delegation', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 2: Active delegation — Carol delegates manager role to Leo +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Active delegation: Carol -> Leo (manager role, 1 day) ---' + +INSERT INTO morbac.delegations + (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until) +VALUES ( + 'de000001-0000-0000-0000-000000000001', + '30000000-0000-0000-0000-000000000003', -- Carol (delegator, holds manager) + '30000000-0000-0000-0000-000000000012', -- Leo (delegatee) + '20000000-0001-0000-0000-000000000003', -- manager role + '10000000-0000-0000-0000-000000000001', + now() - interval '1 hour', + now() + interval '1 day' +); + +-- Leo now has delegated manager role — can approve documents +SELECT morbac.t('Leo (delegated manager) approves documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), TRUE); + +-- Leo can delete documents (manager permission) +SELECT morbac.t('Leo (delegated manager) deletes documents', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), TRUE); + +-- Leo (delegated manager) inherits manager's inherited permissions: +-- manager->employee->intern chain; employee has read documents -> view hierarchy covers financial_data +SELECT morbac.t('Leo (delegated manager) reads financial_data via manager+view hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), TRUE); + +-- get_comprehensive_roles should show manager role with delegation source +SELECT morbac.t('Leo has delegated manager role in comprehensive roles', + EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE role_id = '20000000-0001-0000-0000-000000000003' + AND source LIKE 'delegation%' + ), TRUE); + +-- Employee role appears as direct in comprehensive roles +SELECT morbac.t('Leo has employee role as direct in comprehensive roles', + EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE role_id = '20000000-0001-0000-0000-000000000004' + AND source = 'direct' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 3: Expired delegation +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Expired delegation ---' + +INSERT INTO morbac.delegations + (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until) +VALUES ( + 'de000001-0000-0000-0000-000000000002', + '30000000-0000-0000-0000-000000000008', -- Heidi (auditor) + '30000000-0000-0000-0000-000000000004', -- Dave + '20000000-0001-0000-0000-000000000006', -- auditor role + '10000000-0000-0000-0000-000000000001', + now() - interval '7 days', + now() - interval '1 second' -- expired 1 second ago +); + +-- Dave should NOT get auditor permissions from expired delegation +SELECT morbac.t('Dave via expired auditor delegation reads audit_logs [delegation expired]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'audit_logs' + ), FALSE); + +-- The expired delegation should NOT appear in comprehensive roles +SELECT morbac.t('Expired auditor delegation not in Dave comprehensive roles', + 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-000000000006' + AND source LIKE 'delegation%' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 4: Future delegation (not yet active) +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. Future delegation (not yet active) ---' + +INSERT INTO morbac.delegations + (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until) +VALUES ( + 'de000001-0000-0000-0000-000000000003', + '30000000-0000-0000-0000-000000000007', -- Grace (hr_manager) + '30000000-0000-0000-0000-000000000004', -- Dave + '20000000-0001-0000-0000-000000000008', -- hr_manager role + '10000000-0000-0000-0000-000000000001', + now() + interval '1 day', -- starts tomorrow + now() + interval '8 days' +); + +-- Dave should NOT have hr_manager in his comprehensive roles (delegation not yet active) +-- Note: employee already has write hr_data via view hierarchy (hr_data->documents); +-- so we test comprehensive roles rather than is_allowed to verify the delegation is inactive. +SELECT morbac.t('Future hr_manager delegation not yet in Dave comprehensive roles', + 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-000000000008' -- hr_manager + AND source LIKE 'delegation%' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 5: Revoked delegation +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Revoked delegation ---' + +INSERT INTO morbac.delegations + (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until, revoked) +VALUES ( + 'de000001-0000-0000-0000-000000000004', + '30000000-0000-0000-0000-000000000009', -- Ivan (accountant) + '30000000-0000-0000-0000-000000000004', -- Dave + '20000000-0001-0000-0000-000000000007', -- accountant role + '10000000-0000-0000-0000-000000000001', + now() - interval '1 hour', + now() + interval '1 day', + TRUE -- revoked +); + +-- Dave should NOT get accountant from revoked delegation — verify revoked flag is set +-- Note: employee already has write financial_data via view hierarchy; +-- so we verify the delegation is actually revoked in the DB. +SELECT morbac.t('Revoked accountant delegation has revoked=TRUE in DB', + (SELECT revoked FROM morbac.delegations + WHERE id = 'de000001-0000-0000-0000-000000000004'), + TRUE); + +-- The revoked delegation should NOT appear in comprehensive roles +SELECT morbac.t('Revoked accountant delegation not in Dave comprehensive roles', + 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-000000000007' + AND source LIKE 'delegation%' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 6: Delegation blocked by negative role assignment +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. Negative role assignment overrides delegation ---' + +-- Leo has an active delegation for manager role (from section 2) +-- Now add a negative assignment to block that same role +INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason) +VALUES ( + '30000000-0000-0000-0000-000000000012', -- Leo + '20000000-0001-0000-0000-000000000003', -- manager role + '10000000-0000-0000-0000-000000000001', + 'Temporarily suspended from manager responsibilities' +); + +-- negative assignment blocks the delegated manager role +SELECT morbac.t('Leo (delegated manager, but negatively assigned) approves documents [blocked]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), FALSE); + +-- Leo's own employee permissions still work (negative is only for manager) +SELECT morbac.t('Leo (employee, manager negated) reads documents [employee perm unaffected]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Manager delegation no longer appears in comprehensive roles (blocked by negative) +SELECT morbac.t('Negated manager delegation not in Leo comprehensive roles', + NOT EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000012'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE role_id = '20000000-0001-0000-0000-000000000003' + ), TRUE); + +-- Clean up negative assignment so subsequent tests aren't affected +DELETE FROM morbac.negative_role_assignments +WHERE user_id = '30000000-0000-0000-0000-000000000012' + AND role_id = '20000000-0001-0000-0000-000000000003' + AND org_id = '10000000-0000-0000-0000-000000000001'; + +-- --------------------------------------------------------------------------- +-- Section 7: Delegator must hold the role (validation check) +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 7. Delegator-role validation ---' + +-- Create a delegation where the delegator does NOT hold the role +-- Dave (employee) tries to delegate the manager role (which he does not have) +INSERT INTO morbac.delegations + (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until) +VALUES ( + 'de000001-0000-0000-0000-000000000005', + '30000000-0000-0000-0000-000000000004', -- Dave (employee — does NOT hold manager) + '30000000-0000-0000-0000-000000000005', -- Eve + '20000000-0001-0000-0000-000000000003', -- manager role + '10000000-0000-0000-0000-000000000001', + now() - interval '1 hour', + now() + interval '1 day' +); + +-- get_comprehensive_roles requires delegator to hold the role +-- Eve does NOT get manager access from invalid delegation +SELECT morbac.t('Eve via invalid delegation (Dave does not hold manager) denied approve', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'documents' + ), FALSE); + +-- The invalid delegation should NOT appear in Eve's comprehensive roles +SELECT morbac.t('Invalid delegation not in Eve comprehensive roles', + NOT EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE role_id = '20000000-0001-0000-0000-000000000003' + AND source LIKE 'delegation%' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 8: Comprehensive roles source reporting +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 8. get_comprehensive_roles source reporting ---' + +-- Alice (CEO, direct) — source should be 'direct' +SELECT morbac.t('Alice CEO role has source=direct in comprehensive roles', + EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE role_id = '20000000-0001-0000-0000-000000000001' + AND source = 'direct' + ), TRUE); + +-- Alice has no delegation-sourced roles +SELECT morbac.t('Alice has no delegation-sourced roles', + NOT EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE source LIKE 'delegation%' + ), TRUE); \echo '' \echo '=== Delegation Tests Completed ===' diff --git a/tests/04_constraints.sql b/tests/04_constraints.sql index 0677106..e9c00e3 100644 --- a/tests/04_constraints.sql +++ b/tests/04_constraints.sql @@ -1,54 +1,249 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Constraint Tests --- ============================================================================ --- This test file covers various constraints including: --- - Separation of Duty (SoD) conflicts --- - Negative role assignments --- - Role cardinality constraints (min/max users per role) +-- ============================================================================= +-- Constraint Tests +-- ============================================================================= +-- Tests business constraints: +-- 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 -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- Scenario: +-- - auditor and accountant are mutually exclusive (no one can hold both) +-- - Heidi (auditor) cannot be assigned accountant role +-- - Ivan (accountant) cannot be assigned auditor role +-- - Frank (contractor) is explicitly blocked from the employee role +-- - compliance_officer role has a min 1, max 2 cardinality constraint +-- +-- Prerequisites: 00_setup.sql -> 03_delegation.sql +-- ============================================================================= \echo '' -\echo '=== Separation of Duty ===' +\echo '================================================================' +\echo '04 — CONSTRAINTS' +\echo '================================================================' --- Define SoD conflict: auditor and accountant roles are mutually exclusive -INSERT INTO morbac.roles (id, org_id, name, description) VALUES - ('11111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'auditor', 'Auditor role'), - ('22222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111', 'accountant', 'Accountant role'); +-- --------------------------------------------------------------------------- +-- Section 1: Separation of Duty — define conflict +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. Separation of Duty setup ---' -INSERT INTO morbac.sod_conflicts (role_a_id, role_b_id, org_id, description) VALUES - ('11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111', 'Auditors and accountants are mutually exclusive'); +INSERT INTO morbac.sod_conflicts (role_a_id, role_b_id, org_id, description) +VALUES ( + '20000000-0001-0000-0000-000000000006', -- auditor + '20000000-0001-0000-0000-000000000007', -- accountant + '10000000-0000-0000-0000-000000000001', + 'Auditors and accountants are mutually exclusive to ensure financial independence' +); --- Assign user to auditor role +-- Verify the conflict was created +SELECT morbac.t_eq('SoD conflict between auditor and accountant created', + (SELECT COUNT(*) FROM morbac.sod_conflicts + WHERE role_a_id = '20000000-0001-0000-0000-000000000006' + AND role_b_id = '20000000-0001-0000-0000-000000000007')::bigint, + 1); + +-- --------------------------------------------------------------------------- +-- Section 2: SoD violation detection +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. SoD violation detection ---' + +-- Heidi (auditor) — check if assigning accountant role would violate SoD +SELECT morbac.t('Assigning accountant to Heidi (auditor) violates SoD', + morbac.check_sod_violation( + '30000000-0000-0000-0000-000000000008'::uuid, -- Heidi + '20000000-0001-0000-0000-000000000007'::uuid, -- accountant role + '10000000-0000-0000-0000-000000000001'::uuid + ), TRUE); + +-- Ivan (accountant) — check if assigning auditor role would violate SoD +SELECT morbac.t('Assigning auditor to Ivan (accountant) violates SoD [symmetric]', + morbac.check_sod_violation( + '30000000-0000-0000-0000-000000000009'::uuid, -- Ivan + '20000000-0001-0000-0000-000000000006'::uuid, -- auditor role + '10000000-0000-0000-0000-000000000001'::uuid + ), TRUE); + +-- Dave (employee) — check if assigning accountant would violate SoD +-- Dave is not an auditor — no conflict +SELECT morbac.t('Assigning accountant to Dave (not an auditor) does not violate SoD', + morbac.check_sod_violation( + '30000000-0000-0000-0000-000000000004'::uuid, -- Dave + '20000000-0001-0000-0000-000000000007'::uuid, -- accountant role + '10000000-0000-0000-0000-000000000001'::uuid + ), FALSE); + +-- Heidi (auditor) — assigning a non-conflicting role (manager) is fine +SELECT morbac.t('Assigning manager to Heidi (auditor) does not violate SoD', + morbac.check_sod_violation( + '30000000-0000-0000-0000-000000000008'::uuid, -- Heidi + '20000000-0001-0000-0000-000000000003'::uuid, -- manager role + '10000000-0000-0000-0000-000000000001'::uuid + ), FALSE); + +-- User in a different org has no SoD conflict with GlobalTech roles +-- Judy is in Engineering/Sales, not GlobalTech +SELECT morbac.t('Assigning accountant to Judy (no GlobalTech roles) does not violate SoD', + morbac.check_sod_violation( + '30000000-0000-0000-0000-000000000010'::uuid, -- Judy + '20000000-0001-0000-0000-000000000007'::uuid, -- accountant role + '10000000-0000-0000-0000-000000000001'::uuid + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 3: Negative role assignments +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Negative role assignments ---' + +-- Baseline: Frank (contractor) has read documents permission +SELECT morbac.t('Frank (contractor) reads documents [baseline before negative assignment]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Add employee role directly to Frank (unusual but valid) INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES - ('99999999-0000-0000-0000-000000000009', '11111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111'); + ('30000000-0000-0000-0000-000000000006', '20000000-0001-0000-0000-000000000004', '10000000-0000-0000-0000-000000000001'); -\echo 'Check SoD violation if we try to assign accountant role to auditor:' -SELECT morbac.check_sod_violation( - '99999999-0000-0000-0000-000000000009'::uuid, - '22222222-2222-2222-2222-222222222222'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid -) as would_violate_sod; +-- Frank now has both contractor and employee roles; employee grants write documents +SELECT morbac.t('Frank (now also employee) writes documents [employee permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'documents' + ), TRUE); +-- Add negative assignment blocking Frank from the employee role +INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason) +VALUES ( + '30000000-0000-0000-0000-000000000006', -- Frank + '20000000-0001-0000-0000-000000000004', -- employee role + '10000000-0000-0000-0000-000000000001', + 'Frank is a contractor and must not gain employee-level access' +); + +-- Frank's employee role is negated — verify via get_comprehensive_roles +-- employee role must not appear (negated by negative assignment) +SELECT morbac.t('Frank: employee role excluded by negative assignment', + NOT EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE role_id = '20000000-0001-0000-0000-000000000004' + ), TRUE); + +-- Frank's contractor role still appears (only employee is negated) +SELECT morbac.t('Frank: contractor role still present after employee negated', + EXISTS( + SELECT 1 FROM morbac.get_comprehensive_roles( + '30000000-0000-0000-0000-000000000006'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ) + WHERE role_id = '20000000-0001-0000-0000-000000000009' + ), TRUE); + +-- Negative assignment on a role the user never had is harmless +-- Karl has no role — adding negative assignment for manager is a no-op +INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + '20000000-0001-0000-0000-000000000003', -- manager role (Karl never had this) + '10000000-0000-0000-0000-000000000001', + 'Preemptive block' +); + +-- Karl still has no access (negative assignment on non-held role is harmless) +SELECT morbac.t('Karl (no role, preemptive negative assignment) reads documents [still no access]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), FALSE); + +-- Clean up Frank's extra employee assignment and negative assignment for later tests +DELETE FROM morbac.user_roles +WHERE user_id = '30000000-0000-0000-0000-000000000006' + AND role_id = '20000000-0001-0000-0000-000000000004' + AND org_id = '10000000-0000-0000-0000-000000000001'; + +DELETE FROM morbac.negative_role_assignments +WHERE user_id = '30000000-0000-0000-0000-000000000006' + AND role_id = '20000000-0001-0000-0000-000000000004'; + +-- --------------------------------------------------------------------------- +-- Section 4: Role cardinality constraints +-- --------------------------------------------------------------------------- \echo '' -\echo '=== Negative Role Assignment ===' +\echo '--- 4. Role cardinality constraints ---' --- Explicitly prohibit contractor from ever being admin -INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason) VALUES - ('cccccccc-0000-0000-0000-000000000003', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'Contractors cannot be administrators'); +-- Set a cardinality constraint: compliance_officer role — min 1, max 2 +INSERT INTO morbac.role_cardinality (role_id, min_users, max_users, description) +VALUES ( + '20000000-0001-0000-0000-000000000010', -- compliance_officer + 1, 2, + 'Compliance officer role: at least 1, at most 2' +); -\echo 'Charlie cannot have admin role even if explicitly assigned (negative assignment takes precedence).' +-- Currently 0 users have compliance_officer — adding one should be fine (0 < max=2) +SELECT morbac.t_null('Adding first compliance_officer (0 users, max=2) — no violation', + morbac.check_cardinality_violation( + '20000000-0001-0000-0000-000000000010'::uuid, + TRUE -- adding + )); -\echo '' -\echo '=== Cardinality Constraints ===' +-- Assign two users as compliance officer +INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES + ('30000000-0000-0000-0000-000000000004', '20000000-0001-0000-0000-000000000010', '10000000-0000-0000-0000-000000000001'), + ('30000000-0000-0000-0000-000000000005', '20000000-0001-0000-0000-000000000010', '10000000-0000-0000-0000-000000000001'); --- Set max 2 users for admin role -INSERT INTO morbac.role_cardinality (role_id, min_users, max_users, description) VALUES - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 2, 'Admin role limited to 2 users minimum 1'); +-- Now 2 users — at max. Trying to add a 3rd should violate +SELECT morbac.t_not_null('Adding 3rd compliance_officer (2 users, max=2) — violation returned', + morbac.check_cardinality_violation( + '20000000-0001-0000-0000-000000000010'::uuid, + TRUE -- adding + )); -\echo 'Check cardinality when adding user (1 admin exists, max is 2, should be OK):' -SELECT morbac.check_cardinality_violation('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid, TRUE) as violation; +-- Removing one — 2 users, min=1 — removing leaves 1 which is ≥ min=1, should be fine +SELECT morbac.t_null('Removing from 2 compliance_officers (min=1) — no violation (still above min)', + morbac.check_cardinality_violation( + '20000000-0001-0000-0000-000000000010'::uuid, + FALSE -- removing + )); + +-- Remove one user +DELETE FROM morbac.user_roles +WHERE user_id = '30000000-0000-0000-0000-000000000005' + AND role_id = '20000000-0001-0000-0000-000000000010'; + +-- 1 user remaining = min. Removing the last one would violate min=1 +SELECT morbac.t_not_null('Removing last compliance_officer (1 user, min=1) — violation returned', + morbac.check_cardinality_violation( + '20000000-0001-0000-0000-000000000010'::uuid, + FALSE -- removing + )); + +-- Adding again after being at 1 — 1 user, max=2 — ok +SELECT morbac.t_null('Adding when at 1 compliance_officer (max=2) — no violation', + morbac.check_cardinality_violation( + '20000000-0001-0000-0000-000000000010'::uuid, + TRUE -- adding + )); + +-- Role with no cardinality constraint — no violation for any operation +SELECT morbac.t_null('Checking cardinality for employee role (no constraint) — no violation', + morbac.check_cardinality_violation( + '20000000-0001-0000-0000-000000000004'::uuid, -- employee + TRUE + )); + +-- Clean up extra compliance_officer assignments +DELETE FROM morbac.user_roles +WHERE role_id = '20000000-0001-0000-0000-000000000010' + AND user_id = '30000000-0000-0000-0000-000000000004'; \echo '' \echo '=== Constraint Tests Completed ===' diff --git a/tests/05_temporal.sql b/tests/05_temporal.sql index d839de7..9ad7e10 100644 --- a/tests/05_temporal.sql +++ b/tests/05_temporal.sql @@ -1,82 +1,329 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Temporal Constraint Tests --- ============================================================================ --- This test file covers temporal constraints including: --- - Rules with valid_from timestamps (future activation) --- - Rules with valid_until timestamps (expiration) --- - Rules with both valid_from and valid_until (time windows) --- - Authorization decisions respecting temporal bounds +-- ============================================================================= +-- Temporal Constraint Tests +-- ============================================================================= +-- Tests time-bounded rules using valid_from and valid_until on the rules table. -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- Cases: +-- 1. is_rule_valid() helper function tested directly +-- 2. is_active flag set by trigger on insert/update +-- 3. Expired rule (valid_until in the past) -> denied +-- 4. Future rule (valid_from in the future) -> denied +-- 5. Active time window (valid_from past, valid_until future) -> allowed +-- 6. Multiple rules for same combination — only active ones count +-- 7. Expired prohibition: no longer blocks access after it expires +-- 8. Temporal rules interact correctly with role hierarchy +-- +-- Note: Direct rule inserts are used (bypassing Policy DSL) to control timestamps. +-- Uses a dedicated view 'temp_view' for isolation so existing rules aren't disturbed. +-- +-- Prerequisites: 00_setup.sql -> 04_constraints.sql +-- ============================================================================= \echo '' -\echo '=== Temporal Constraints on Rules ===' +\echo '================================================================' +\echo '05 — TEMPORAL CONSTRAINTS' +\echo '================================================================' --- Create a time-limited rule (expires in the past) -INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_until) -VALUES ( - '11111111-1111-1111-1111-111111111111', - 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- employee role - 'write', - 'temp_documents', - (SELECT id FROM morbac.contexts WHERE name = 'always'), - 'permission', - '2020-01-01 00:00:00+00' -- Expired rule -); +-- --------------------------------------------------------------------------- +-- Setup: dedicated view for temporal tests +-- --------------------------------------------------------------------------- +INSERT INTO morbac.views (name, description) VALUES + ('temp_view', 'Scratch view for temporal tests'); --- Create a future rule (not yet active) -INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from) -VALUES ( - '11111111-1111-1111-1111-111111111111', - 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - 'write', - 'future_documents', - (SELECT id FROM morbac.contexts WHERE name = 'always'), - 'permission', - '2030-01-01 00:00:00+00' -- Future rule -); +-- --------------------------------------------------------------------------- +-- Section 1: is_rule_valid() helper function +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. is_rule_valid() helper ---' --- Create an active time-bounded rule +-- No bounds: always valid +SELECT morbac.t('is_rule_valid(NULL, NULL) — always valid', + morbac.is_rule_valid(NULL::timestamptz, NULL::timestamptz), TRUE); + +-- Past valid_from, no valid_until: currently active +SELECT morbac.t('is_rule_valid(past, NULL) — started in past, no end', + morbac.is_rule_valid('2000-01-01'::timestamptz, NULL), TRUE); + +-- Future valid_from: not yet active +SELECT morbac.t('is_rule_valid(future, NULL) — not yet started', + morbac.is_rule_valid('2099-01-01'::timestamptz, NULL), FALSE); + +-- Past valid_until: expired +SELECT morbac.t('is_rule_valid(NULL, past) — already expired', + morbac.is_rule_valid(NULL, '2000-01-01'::timestamptz), FALSE); + +-- Future valid_until, no valid_from: currently active +SELECT morbac.t('is_rule_valid(NULL, future) — no start, future end', + morbac.is_rule_valid(NULL, '2099-01-01'::timestamptz), TRUE); + +-- Active window: past start, future end +SELECT morbac.t('is_rule_valid(past, future) — within active window', + morbac.is_rule_valid('2000-01-01'::timestamptz, '2099-01-01'::timestamptz), TRUE); + +-- Fully past window (both start and end in the past) +SELECT morbac.t('is_rule_valid(past_start, past_end) — entirely expired', + morbac.is_rule_valid('2000-01-01'::timestamptz, '2001-01-01'::timestamptz), FALSE); + +-- Fully future window (both start and end in the future) +SELECT morbac.t('is_rule_valid(future_start, future_end) — entirely in the future', + morbac.is_rule_valid('2090-01-01'::timestamptz, '2099-01-01'::timestamptz), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 2: is_active flag set by trigger on insert +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. is_active trigger on insert ---' + +-- Insert a currently active rule INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from, valid_until) VALUES ( - '11111111-1111-1111-1111-111111111111', - 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - 'write', - 'active_documents', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'audit', 'temp_view', (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission', - '2020-01-01 00:00:00+00', -- Started in the past - '2030-12-31 23:59:59+00' -- Ends in the future + now() - interval '1 day', + now() + interval '1 day' ); -\echo 'Alice (employee) access to temporal-constrained documents:' -SELECT - 'write to temp_documents (expired rule)' as action, - morbac.is_allowed( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'write', - 'temp_documents' - ) as allowed -UNION ALL -SELECT - 'write to future_documents (not yet active)' as action, - morbac.is_allowed( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'write', - 'future_documents' - ) as allowed -UNION ALL -SELECT - 'write to active_documents (currently valid)' as action, - morbac.is_allowed( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'write', - 'active_documents' - ) as allowed; +-- is_active should be TRUE for the active rule +SELECT morbac.t('is_active = TRUE for rule with active time window', + (SELECT is_active FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view'), + TRUE); + +-- Update to expired window, trigger should recalculate is_active +UPDATE morbac.rules +SET valid_until = now() - interval '1 second' +WHERE activity = 'audit' AND view = 'temp_view'; + +-- is_active should be FALSE after expiry update +SELECT morbac.t('is_active = FALSE after valid_until set to past', + (SELECT is_active FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view'), + FALSE); + +-- Clean up +DELETE FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view'; + +-- --------------------------------------------------------------------------- +-- Section 3: Expired rule — valid_until in the past +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Expired rule (valid_until in the past) ---' + +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_until) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'export', 'temp_view', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + '2020-01-01 00:00:00+00' -- expired years ago +); + +-- Rule expired, no valid permission +SELECT morbac.t('Dave (employee) exports temp_view via expired rule [denied]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'export', 'temp_view' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 4: Future rule — valid_from in the future +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. Future rule (valid_from in the future) ---' + +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'manage', 'temp_view', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + '2099-01-01 00:00:00+00' -- won't start for decades +); + +-- Rule not yet active +SELECT morbac.t('Dave (employee) manages temp_view via future rule [not yet active]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'manage', 'temp_view' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 5: Active time window (valid_from past, valid_until future) +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Active time window ---' + +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from, valid_until) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'read', 'temp_view', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + '2020-01-01 00:00:00+00', + '2099-12-31 23:59:59+00' +); + +-- Rule is within its validity window +SELECT morbac.t('Dave (employee) reads temp_view via active window rule', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'temp_view' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 6: Multiple rules for same combination, only active ones count +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. Multiple rules for same combination, only active ones count ---' + +-- At this point, employee has expired export rule (section 3) and active read rule (section 5). +-- Since export->read in activity hierarchy, the active read rule grants export access. +SELECT morbac.t('Dave exports temp_view (expired export + active read via export->read hierarchy)', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'export', 'temp_view' + ), TRUE); + +-- Activate the expired export rule (unique constraint prevents a second rule with same modality) +UPDATE morbac.rules +SET valid_from = now() - interval '1 hour', + valid_until = now() + interval '1 hour' +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000004' + AND activity = 'export' AND view = 'temp_view' AND modality = 'permission'; + +-- Now export rule is active directly (not just via read hierarchy) +SELECT morbac.t('Dave exports temp_view (export rule now active)', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'export', 'temp_view' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 7: Temporal prohibition — expired prohibition no longer blocks +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 7. Expired prohibition no longer blocks ---' + +-- Add an active permission for intern -> read temp_view +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000005', -- intern + 'read', 'temp_view', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- Add an EXPIRED prohibition for intern -> read temp_view +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_until) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000005', -- intern + 'read', 'temp_view', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition', + '2020-01-01 00:00:00+00' -- prohibition expired years ago +); + +-- Prohibition expired, permission stands +SELECT morbac.t('Eve (intern) reads temp_view [active permission, expired prohibition]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'temp_view' + ), TRUE); + +-- Now replace the prohibition with an active one +UPDATE morbac.rules +SET valid_until = now() + interval '1 day' +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000005' + AND activity = 'read' AND view = 'temp_view' AND modality = 'prohibition'; + +-- Active prohibition now blocks +SELECT morbac.t('Eve (intern) reads temp_view [active prohibition blocks]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'temp_view' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 8: Temporal rule with role hierarchy +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 8. Temporal rule with role hierarchy ---' + +-- Expire the intern read temp_view prohibition (activated in section 7) so it does not +-- block Carol (manager) and Alice (CEO) who inherit from intern via role hierarchy. +UPDATE morbac.rules +SET valid_until = now() - interval '1 second' +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000005' + AND activity = 'read' AND view = 'temp_view' AND modality = 'prohibition'; + +-- Add an active 'approve temp_view' permission for manager. +-- 'approve' is NOT in the activity hierarchy (manage->delete->write->read, export->read), +-- so employee's 'read temp_view' rule does NOT cover 'approve temp_view'. +-- This ensures the temporal test cleanly verifies expiry without hierarchy interference. +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from, valid_until) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000003', -- manager + 'approve', 'temp_view', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + now() - interval '1 hour', + now() + interval '1 hour' +); + +-- Carol (manager) can approve temp_view directly +SELECT morbac.t('Carol (manager) approves temp_view [temporal rule, active]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'temp_view' + ), TRUE); + +-- Alice (CEO) inherits from manager — should also get the temporal permission +SELECT morbac.t('Alice (CEO, inherits manager) approves temp_view [temporal rule, active]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'temp_view' + ), TRUE); + +-- Expire the manager approve rule +UPDATE morbac.rules +SET valid_until = now() - interval '1 second' +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000003' + AND activity = 'approve' AND view = 'temp_view'; + +-- Rule expired for both manager and CEO +SELECT morbac.t('Carol (manager) approves temp_view after rule expiry [denied]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'temp_view' + ), FALSE); + +SELECT morbac.t('Alice (CEO, inherits manager) approves temp_view after rule expiry [denied]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'approve', 'temp_view' + ), FALSE); \echo '' \echo '=== Temporal Constraint Tests Completed ===' diff --git a/tests/06_cross_org.sql b/tests/06_cross_org.sql index e8f8077..7bf49a8 100644 --- a/tests/06_cross_org.sql +++ b/tests/06_cross_org.sql @@ -1,30 +1,299 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Cross-Organizational Rules Tests --- ============================================================================ --- This test file covers cross-organizational authorization including: --- - Cross-organization rules (access between different orgs) --- - Inter-organizational collaboration scenarios --- - Cross-org permission validation +-- ============================================================================= +-- Cross-Organization Rules Tests +-- ============================================================================= +-- Tests inter-organizational access via the cross_org_rules table. -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- Scenarios: +-- 1. No cross-org rule: users in org A cannot access org B resources +-- 2. Cross-org permission: specific role in source org can access target org +-- 3. User must hold role in source org (not just any role) +-- 4. Cross-org prohibition: blocks access even if there's a regular permission +-- 5. Cross-org rules combine with activity hierarchy +-- 6. Cross-org rules combine with view hierarchy +-- 7. Temporal cross-org rules (valid_from / valid_until) +-- +-- Company context: +-- Judy (sales_rep at Sales) is allowed to read GlobalTech reports via cross-org rule +-- Nina (eng_auditor at Engineering) has a temporal cross-org rule to read GlobalTech audit_logs +-- Diana (sales_manager at Sales) has a cross-org prohibition on writing Engineering docs +-- +-- Prerequisites: 00_setup.sql -> 05_temporal.sql +-- ============================================================================= \echo '' -\echo '=== Cross-Organization Rules ===' +\echo '================================================================' +\echo '06 — CROSS-ORGANIZATIONAL RULES' +\echo '================================================================' --- Allow Beta managers to read Acme documents (inter-org collaboration) -INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, context_id, modality) VALUES - ('22222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111', 'dddddddd-dddd-dddd-dddd-dddddddddddd', 'read', 'documents', (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); +-- --------------------------------------------------------------------------- +-- Setup: additional roles and users for cross-org scenarios +-- --------------------------------------------------------------------------- -\echo 'Diana (manager at Beta) can read Acme documents via cross-org rule:' -SELECT - 'read Acme documents from Beta' as action, - morbac.is_allowed( - 'dddddddd-0000-0000-0000-000000000004'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'read', - 'documents' - ) as allowed; +-- Nina: eng_auditor role in Engineering Dept +INSERT INTO morbac.roles (id, org_id, name, description) VALUES + ('20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002', 'eng_auditor', 'Engineering Auditor'); + +INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES + ('30000000-0000-0000-0000-000000000014', '20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002'); + +-- Policies for eng_auditor in Engineering +INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) +VALUES ('Engineering Dept', 'eng_auditor', 'read', 'audit_logs', 'permission', 'always'); +SELECT * FROM morbac.compile_policy(); + +-- Verify nina's role was set up correctly +SELECT morbac.t('Nina has eng_auditor role at Engineering', + morbac.user_has_role( + '30000000-0000-0000-0000-000000000014'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'eng_auditor' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 1: No cross-org rule — access between orgs is denied by default +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. No cross-org rule: access denied by default ---' + +-- Dave (employee at GlobalTech) has no role in Engineering and no cross-org rule +SELECT morbac.t('Dave (GlobalTech employee) reads Engineering documents [no cross-org rule]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), FALSE); + +-- Judy (Engineering engineer) accesses GlobalTech financial_data (no cross-org rule yet) +SELECT morbac.t('Judy (Engineering engineer) reads GlobalTech financial_data [no cross-org rule]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 2: Cross-org permission — Sales sales_rep reads GlobalTech reports +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Cross-org permission ---' + +-- Judy is sales_rep at Sales. Add a cross-org rule: +-- Sales sales_rep can read GlobalTech reports +INSERT INTO morbac.cross_org_rules + (source_org_id, target_org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000003', -- source: Sales Dept + '10000000-0000-0000-0000-000000000001', -- target: GlobalTech HQ + '20000000-0003-0000-0000-000000000002', -- sales_rep role + 'read', 'reports', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- Cross-org rule grants access +SELECT morbac.t('Judy (Sales sales_rep) reads GlobalTech reports via cross-org rule', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'reports' + ), TRUE); + +-- Judy at Sales can still read Sales docs normally (local rule, no cross-org needed) +SELECT morbac.t('Judy (Sales sales_rep) reads Sales documents [local rule]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000003'::uuid, + 'read', 'documents' + ), TRUE); + +-- Judy does NOT have permission to read GlobalTech documents (cross-org rule only covers reports) +SELECT morbac.t('Judy (Sales sales_rep) reads GlobalTech documents [no rule for documents]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 3: User must hold role in source org +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Role must be held in source org ---' + +-- Karl has no role anywhere — cannot use the Sales->GlobalTech cross-org rule +SELECT morbac.t('Karl (no role) reads GlobalTech reports via cross-org rule [no role in source]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'reports' + ), FALSE); + +-- Karl also has no access to GlobalTech documents +SELECT morbac.t('Karl (no role) reads GlobalTech documents [no access anywhere]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 4: Cross-org prohibition — blocks access even with regular permission +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. Cross-org prohibition ---' + +-- Engineering engineer can normally read Engineering documents (local perm) +SELECT morbac.t('Judy (Engineering engineer) reads Engineering documents [local perm]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), TRUE); + +-- Add Diana (sales_manager at Sales) for cross-org prohibition test +INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES + ('30000000-0000-0000-0000-000000000015', '20000000-0003-0000-0000-000000000001', '10000000-0000-0000-0000-000000000003'); + +-- Add cross-org prohibition: Sales sales_manager prohibited from writing Engineering documents +INSERT INTO morbac.cross_org_rules + (source_org_id, target_org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000003', -- source: Sales Dept + '10000000-0000-0000-0000-000000000002', -- target: Engineering Dept + '20000000-0003-0000-0000-000000000001', -- sales_manager role + 'write', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +-- Also give a cross-org permission that would otherwise allow the write +INSERT INTO morbac.cross_org_rules + (source_org_id, target_org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000003', -- source: Sales Dept + '10000000-0000-0000-0000-000000000002', -- target: Engineering Dept + '20000000-0003-0000-0000-000000000001', -- sales_manager role + 'write', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- Cross-org prohibition takes precedence over permission +SELECT morbac.t('Diana (Sales sales_manager) writes Engineering documents [cross-org prohibition wins]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000015'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'write', 'documents' + ), FALSE); + +-- Diana can read Engineering documents (no prohibition for read) +INSERT INTO morbac.cross_org_rules + (source_org_id, target_org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000003', -- source: Sales Dept + '10000000-0000-0000-0000-000000000002', -- target: Engineering Dept + '20000000-0003-0000-0000-000000000001', -- sales_manager role + 'read', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- No prohibition for read, permission applies +SELECT morbac.t('Diana (Sales sales_manager) reads Engineering documents [cross-org permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000015'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 5: Cross-org + activity hierarchy +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Cross-org with activity hierarchy ---' + +-- The sales_rep cross-org rule grants 'read reports' in GlobalTech. +-- With activity hierarchy (write->read), requesting 'write reports' also matches +-- the 'read' rule (get_effective_activities('write') = {write, read}). +SELECT morbac.t('Judy (Sales sales_rep) writes GlobalTech reports via cross-org + activity hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'reports' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 6: Cross-org + view hierarchy +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. Cross-org with view hierarchy ---' + +-- Add a cross-org rule for 'read documents' (not financial_data directly) +-- With view hierarchy (financial_data->documents), 'read documents' cross-org rule +-- also covers 'read financial_data' requests +INSERT INTO morbac.cross_org_rules + (source_org_id, target_org_id, role_id, activity, view, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000003', -- source: Sales Dept + '10000000-0000-0000-0000-000000000001', -- target: GlobalTech HQ + '20000000-0003-0000-0000-000000000002', -- sales_rep role + 'read', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- get_effective_views('financial_data') = {financial_data, documents} +-- so 'read documents' cross-org rule covers 'read financial_data' request +SELECT morbac.t('Judy (Sales sales_rep) reads GlobalTech financial_data via cross-org + view hierarchy', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000010'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 7: Temporal cross-org rule +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 7. Temporal cross-org rule ---' + +-- Add a time-limited cross-org rule for eng_auditor to read GlobalTech audit_logs +INSERT INTO morbac.cross_org_rules + (source_org_id, target_org_id, role_id, activity, view, context_id, modality, valid_from, valid_until) +VALUES ( + '10000000-0000-0000-0000-000000000002', -- source: Engineering + '10000000-0000-0000-0000-000000000001', -- target: GlobalTech HQ + '20000000-0002-0000-0000-000000000003', -- eng_auditor role + 'read', 'audit_logs', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + now() - interval '1 hour', + now() + interval '1 day' +); + +-- Nina (Engineering eng_auditor) reads GlobalTech audit_logs within window +SELECT morbac.t('Nina (Engineering eng_auditor) reads GlobalTech audit_logs [temporal cross-org, active]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000014'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'audit_logs' + ), TRUE); + +-- Expire the cross-org rule +UPDATE morbac.cross_org_rules +SET valid_until = now() - interval '1 second' +WHERE source_org_id = '10000000-0000-0000-0000-000000000002' + AND target_org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0002-0000-0000-000000000003' + AND activity = 'read' AND view = 'audit_logs'; + +-- Temporal rule expired +SELECT morbac.t('Nina (Engineering eng_auditor) reads GlobalTech audit_logs [temporal cross-org, expired]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000014'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'audit_logs' + ), FALSE); \echo '' \echo '=== Cross-Organization Rules Tests Completed ===' diff --git a/tests/07_audit.sql b/tests/07_audit.sql index 85945a9..f73d075 100644 --- a/tests/07_audit.sql +++ b/tests/07_audit.sql @@ -1,54 +1,280 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Audit Logging Tests --- ============================================================================ --- This test file covers audit logging functionality including: --- - Enabling audit on tables --- - Auditing INSERT operations --- - Auditing UPDATE operations --- - Auditing DELETE operations --- - Audit log queries and reporting --- - Disabling audit +-- ============================================================================= +-- Audit Logging Tests +-- ============================================================================= +-- Tests the audit system: enable_audit / disable_audit and the resulting +-- audit_log entries for INSERT, UPDATE, and DELETE operations. -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- Cases: +-- 1. Enable audit creates trigger on specified table +-- 2. INSERT to audited table creates an audit log entry +-- 3. DELETE from audited table creates an audit log entry +-- 4. UPDATE to audited table creates an audit log entry with changed_fields +-- 5. All operations for a record_id are queryable +-- 6. Multiple tables can be audited independently +-- 7. Disable audit removes the trigger +-- 8. After disabling, changes are no longer logged +-- +-- Prerequisites: 00_setup.sql -> 06_cross_org.sql +-- ============================================================================= \echo '' -\echo '=== Audit Logging ===' +\echo '================================================================' +\echo '07 — AUDIT LOGGING' +\echo '================================================================' + +-- Clear any existing audit log entries to start fresh +TRUNCATE morbac.audit_log; + +-- --------------------------------------------------------------------------- +-- Section 1: Enable audit on user_roles +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. Enable audit on user_roles ---' --- Enable audit on user_roles table SELECT morbac.enable_audit('user_roles'); -\echo 'Audit trigger created for user_roles table' +-- Trigger must exist on user_roles after enable +SELECT morbac.t('Trigger exists on user_roles after enable_audit', + (SELECT COUNT(*) > 0 FROM information_schema.triggers + WHERE event_object_schema = 'morbac' + AND event_object_table = 'user_roles'), + TRUE); --- Perform some auditable operations +-- No entries before any change +SELECT morbac.t_eq('Audit log count before operations = 0', + (SELECT COUNT(*) FROM morbac.audit_log WHERE table_name = 'user_roles')::bigint, + 0); + +-- --------------------------------------------------------------------------- +-- Section 2: INSERT is logged +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. INSERT is logged ---' + +-- Assign a new role to Karl (user who has no role) INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES ( - 'ffffffff-0000-0000-0000-000000000001'::uuid, - 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- employee role - '11111111-1111-1111-1111-111111111111' + '30000000-0000-0000-0000-000000000011', -- Karl + '20000000-0001-0000-0000-000000000005', -- intern role + '10000000-0000-0000-0000-000000000001' ); +-- Exactly 1 INSERT entry for user_roles +SELECT morbac.t_eq('Audit log has 1 INSERT entry after INSERT into user_roles', + (SELECT COUNT(*) FROM morbac.audit_log + WHERE table_name = 'user_roles' AND operation = 'INSERT')::bigint, + 1); +-- new_data must contain the inserted record +SELECT morbac.t('INSERT audit entry contains user_id in new_data', + (SELECT new_data->>'user_id' FROM morbac.audit_log + WHERE table_name = 'user_roles' AND operation = 'INSERT' + LIMIT 1) = '30000000-0000-0000-0000-000000000011', + TRUE); + +-- --------------------------------------------------------------------------- +-- Section 3: DELETE is logged +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. DELETE is logged ---' --- Delete a user role DELETE FROM morbac.user_roles -WHERE user_id = 'ffffffff-0000-0000-0000-000000000001'::uuid - AND role_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND role_id = '20000000-0001-0000-0000-000000000005' + AND org_id = '10000000-0000-0000-0000-000000000001'; -\echo 'Audit log entries for user_roles operations:' -SELECT - operation, - record_id, - 'N/A' as old_active, - 'N/A' as new_active, - session_user, - timestamp -FROM morbac.audit_log -WHERE table_name = 'user_roles' -ORDER BY timestamp; +-- Now 2 entries: INSERT + DELETE +SELECT morbac.t_eq('Audit log has 2 entries after DELETE (INSERT + DELETE)', + (SELECT COUNT(*) FROM morbac.audit_log WHERE table_name = 'user_roles')::bigint, + 2); + +-- DELETE entry must have old_data populated +SELECT morbac.t('DELETE audit entry has old_data populated', + (SELECT old_data IS NOT NULL FROM morbac.audit_log + WHERE table_name = 'user_roles' AND operation = 'DELETE' + LIMIT 1), + TRUE); + +-- --------------------------------------------------------------------------- +-- Section 4: Enable audit on rules and test UPDATE logging +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. UPDATE on rules table is logged (with changed_fields) ---' + +SELECT morbac.enable_audit('rules'); + +-- Insert a rule to audit (using explicit id for record_id tracking) +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'a0000000-0000-0000-0000-000000000001', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000009', -- contractor + 'read', 'public_data', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- 1 INSERT entry for rules +SELECT morbac.t_eq('Audit log has 1 INSERT entry for rules', + (SELECT COUNT(*) FROM morbac.audit_log + WHERE table_name = 'rules' AND operation = 'INSERT')::bigint, + 1); + +-- Update the modality (simulate a policy change) +UPDATE morbac.rules +SET modality = 'prohibition' +WHERE id = 'a0000000-0000-0000-0000-000000000001'; + +-- 1 UPDATE entry with changed_fields +SELECT morbac.t_eq('Audit log has 1 UPDATE entry for rules', + (SELECT COUNT(*) FROM morbac.audit_log + WHERE table_name = 'rules' AND operation = 'UPDATE')::bigint, + 1); + +-- changed_fields must be non-null for UPDATE +SELECT morbac.t('UPDATE audit entry has changed_fields populated', + (SELECT changed_fields IS NOT NULL FROM morbac.audit_log + WHERE table_name = 'rules' AND operation = 'UPDATE' + LIMIT 1), + TRUE); + +-- old_data and new_data both populated for UPDATE +SELECT morbac.t('UPDATE audit entry: old modality = permission', + (SELECT old_data->>'modality' FROM morbac.audit_log + WHERE table_name = 'rules' AND operation = 'UPDATE' + LIMIT 1) = 'permission', + TRUE); + +SELECT morbac.t('UPDATE audit entry: new modality = prohibition', + (SELECT new_data->>'modality' FROM morbac.audit_log + WHERE table_name = 'rules' AND operation = 'UPDATE' + LIMIT 1) = 'prohibition', + TRUE); + +-- Clean up the audited rule +DELETE FROM morbac.rules WHERE id = 'a0000000-0000-0000-0000-000000000001'; + +-- --------------------------------------------------------------------------- +-- Section 5: All operations queryable by record_id +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Query audit log by record_id ---' + +-- The test rule had INSERT, UPDATE, DELETE — should be 3 entries +SELECT morbac.t_eq('Audit log has 3 entries for test rule record (INSERT + UPDATE + DELETE)', + (SELECT COUNT(*) FROM morbac.audit_log + WHERE table_name = 'rules' + AND record_id = 'a0000000-0000-0000-0000-000000000001')::bigint, + 3); + +-- All three operations present +SELECT morbac.t('INSERT, UPDATE, DELETE all present for test rule record', + (SELECT COUNT(DISTINCT operation) FROM morbac.audit_log + WHERE table_name = 'rules' + AND record_id = 'a0000000-0000-0000-0000-000000000001')::bigint = 3, + TRUE); + +-- --------------------------------------------------------------------------- +-- Section 6: Multiple tables audited independently +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. Multiple tables audited independently ---' + +SELECT morbac.enable_audit('delegations'); + +-- Insert a delegation to trigger audit on the delegations table +INSERT INTO morbac.delegations + (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until) +VALUES ( + 'de000099-0000-0000-0000-000000000099', + '30000000-0000-0000-0000-000000000001', -- Alice (CEO) + '30000000-0000-0000-0000-000000000011', -- Karl + '20000000-0001-0000-0000-000000000001', -- ceo role + '10000000-0000-0000-0000-000000000001', + now(), + now() + interval '1 hour' +); + +-- delegations table now has audit entries +SELECT morbac.t('Audit log has at least 1 entry for delegations table', + (SELECT COUNT(*) FROM morbac.audit_log WHERE table_name = 'delegations') >= 1, + TRUE); + +-- All three tables have audit entries +SELECT morbac.t('user_roles, rules, and delegations all have audit entries', + (SELECT COUNT(DISTINCT table_name) FROM morbac.audit_log + WHERE table_name IN ('user_roles', 'rules', 'delegations'))::bigint = 3, + TRUE); + +-- Clean up the test delegation +DELETE FROM morbac.delegations WHERE id = 'de000099-0000-0000-0000-000000000099'; + +-- --------------------------------------------------------------------------- +-- Section 7: Disable audit removes trigger +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 7. Disable audit removes trigger ---' -\echo 'Disable audit on user_roles:' SELECT morbac.disable_audit('user_roles'); +-- Audit trigger must be removed from user_roles (other system triggers may remain) +SELECT morbac.t('No audit trigger on user_roles after disable_audit', + (SELECT COUNT(*) = 0 FROM information_schema.triggers + WHERE event_object_schema = 'morbac' + AND event_object_table = 'user_roles' + AND trigger_name LIKE '%audit%'), + TRUE); + +-- --------------------------------------------------------------------------- +-- Section 8: After disabling, changes are no longer logged +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 8. Disable audit stops logging ---' + +-- Record current count for user_roles before post-disable operations +-- (2 entries from sections 2+3) +DO $$ +DECLARE + count_before BIGINT; +BEGIN + SELECT COUNT(*) INTO count_before FROM morbac.audit_log WHERE table_name = 'user_roles'; + -- Store in a temp table for later assertion + CREATE TEMP TABLE IF NOT EXISTS audit_count_check (cnt BIGINT); + DELETE FROM audit_count_check; + INSERT INTO audit_count_check VALUES (count_before); +END; +$$; + +-- Perform another user_roles change — should NOT be logged +INSERT INTO morbac.user_roles (user_id, role_id, org_id) +VALUES ( + '30000000-0000-0000-0000-000000000011', + '20000000-0001-0000-0000-000000000005', -- intern + '10000000-0000-0000-0000-000000000001' +); + +DELETE FROM morbac.user_roles +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND role_id = '20000000-0001-0000-0000-000000000005'; + +-- Count after operations must equal count before (no new entries) +SELECT morbac.t('user_roles audit count unchanged after post-disable operations', + (SELECT COUNT(*) FROM morbac.audit_log WHERE table_name = 'user_roles')::bigint + = (SELECT cnt FROM audit_count_check), + TRUE); + +DROP TABLE IF EXISTS audit_count_check; + +-- Disable the remaining audit triggers +SELECT morbac.disable_audit('rules'); +SELECT morbac.disable_audit('delegations'); + +-- All audit triggers removed +SELECT morbac.t('No audit triggers remain after all disable_audit calls', + (SELECT COUNT(*) = 0 FROM information_schema.triggers + WHERE event_object_schema = 'morbac' + AND trigger_name LIKE '%audit%'), + TRUE); + \echo '' \echo '=== Audit Logging Tests Completed ===' diff --git a/tests/08_admin.sql b/tests/08_admin.sql index 15fcc50..4595b3a 100644 --- a/tests/08_admin.sql +++ b/tests/08_admin.sql @@ -1,37 +1,342 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Administration Rules Tests --- ============================================================================ --- This test file covers administration rules including: --- - Administrative activities (create_rule, etc.) --- - Administrative permission checks (is_admin_allowed) --- - Role-based administrative access control --- - Administrative authorization decisions +-- ============================================================================= +-- Administration Rules Tests +-- ============================================================================= +-- Tests the admin meta-policy system: who can manage policies, roles, and users. -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- is_admin_allowed(user_id, org_id, admin_activity, admin_target) is the main API. +-- Helper functions: can_manage_roles(), can_manage_policies(), can_manage_user_role(). +-- +-- Scenarios: +-- 1. Default deny: no admin rule = no admin access +-- 2. Grant admin permission to a role, verify the role holder can manage +-- 3. Admin prohibition blocks management even with permission +-- 4. Role hierarchy applies: senior role inherits admin permissions +-- 5. Admin helper functions +-- 6. can_manage_user_role() per-role management permission +-- 7. Admin rules are org-scoped +-- +-- Company context: +-- Grace (hr_manager) can manage users (assign_role) for employee-level roles +-- Carol (manager) can create rules +-- Alice (CEO) inherits Carol's admin perms via hierarchy +-- +-- Prerequisites: 00_setup.sql -> 07_audit.sql +-- ============================================================================= \echo '' -\echo '=== Administration Rules ===' +\echo '================================================================' +\echo '08 — ADMINISTRATION RULES' +\echo '================================================================' --- Only admin role can create rules -INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) VALUES - ('11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'create_rule', 'rules', (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); +-- --------------------------------------------------------------------------- +-- Section 1: Default deny — no admin rule means no admin access +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. Default deny: no admin rule ---' -\echo 'Alice (admin) can create rules:' -SELECT morbac.is_admin_allowed( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'create_rule', - 'rules' -) as can_create_rules; +-- Nobody has admin rules yet — all should be denied +SELECT morbac.t('Alice (CEO, no admin rule yet) creates rules [default deny]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'create_rule', 'rules' + ), FALSE); -\echo 'Bob (employee) cannot create rules:' -SELECT morbac.is_admin_allowed( - 'bbbbbbbb-0000-0000-0000-000000000002'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'create_rule', - 'rules' -) as can_create_rules; +SELECT morbac.t('Grace (hr_manager, no admin rule) assigns roles [default deny]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000007'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'assign_role', 'employee' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 2: Grant admin permissions +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Grant admin permissions ---' + +-- Grant manager role: can create and delete rules +INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) +VALUES + ('10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000003', -- manager + 'create_rule', 'rules', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission'), + ('10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000003', -- manager + 'delete_rule', 'rules', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission'); + +-- Grant hr_manager role: can assign employee and intern roles +INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) +VALUES + ('10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000008', -- hr_manager + 'assign_role', 'employee', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission'), + ('10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000008', -- hr_manager + 'assign_role', 'intern', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission'); + +-- Grant director role: can manage roles and policies +INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) +VALUES + ('10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000002', -- director + 'manage', 'roles', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission'), + ('10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000002', -- director + 'manage', 'policies', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission'); + +-- --------------------------------------------------------------------------- +-- Section 3: Verify admin permissions +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Verify admin permissions ---' + +-- Carol (manager) can create rules +SELECT morbac.t('Carol (manager) creates rules', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'create_rule', 'rules' + ), TRUE); + +-- Carol (manager) can delete rules +SELECT morbac.t('Carol (manager) deletes rules', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete_rule', 'rules' + ), TRUE); + +-- Grace (hr_manager) can assign employee role +SELECT morbac.t('Grace (hr_manager) assigns employee role', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000007'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'assign_role', 'employee' + ), TRUE); + +-- Grace (hr_manager) can assign intern role +SELECT morbac.t('Grace (hr_manager) assigns intern role', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000007'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'assign_role', 'intern' + ), TRUE); + +-- Grace (hr_manager) cannot assign manager role (no rule for that) +SELECT morbac.t('Grace (hr_manager) assigns manager role [no admin rule for manager]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000007'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'assign_role', 'manager' + ), FALSE); + +-- Dave (employee) has no admin permissions +SELECT morbac.t('Dave (employee) creates rules [no admin rule for employee]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'create_rule', 'rules' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 4: Role hierarchy applies to admin permissions +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. Role hierarchy applies to admin permissions ---' + +-- Bob (director) has own admin perm (manage roles/policies) and +-- inherits manager's admin perms (create_rule, delete_rule) via director->manager hierarchy + +-- Bob (director) creates rules — inherited from manager via director->manager hierarchy +SELECT morbac.t('Bob (director, inherits manager admin) creates rules', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000002'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'create_rule', 'rules' + ), TRUE); + +-- Bob (director) manages roles — own admin perm +SELECT morbac.t('Bob (director) manages roles [own admin perm]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000002'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'manage', 'roles' + ), TRUE); + +-- Alice (CEO) inherits everything through CEO->director->manager chain +SELECT morbac.t('Alice (CEO, inherits director+manager) manages roles', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'manage', 'roles' + ), TRUE); + +SELECT morbac.t('Alice (CEO) manages policies', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'manage', 'policies' + ), TRUE); + +-- Eve (intern) inherits nothing useful for admin +SELECT morbac.t('Eve (intern) creates rules [no admin permission in hierarchy]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'create_rule', 'rules' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 5: Admin prohibition overrides permission +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Admin prohibition overrides permission ---' + +-- Carol (manager) currently can create rules. Add a prohibition. +INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) +VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000003', -- manager + 'create_rule', 'rules', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +-- Prohibition overrides permission +SELECT morbac.t('Carol (manager, now prohibited) creates rules [prohibition wins]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'create_rule', 'rules' + ), FALSE); + +-- Carol can still delete rules (prohibition only applied to create_rule) +SELECT morbac.t('Carol (manager, prohibition only on create) deletes rules [still allowed]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete_rule', 'rules' + ), TRUE); + +-- Remove the prohibition for subsequent tests +DELETE FROM morbac.admin_rules +WHERE org_id = '10000000-0000-0000-0000-000000000001' + AND role_id = '20000000-0001-0000-0000-000000000003' + AND admin_activity = 'create_rule' AND admin_target = 'rules' + AND modality = 'prohibition'; + +-- Carol can again create rules after prohibition removed +SELECT morbac.t('Carol (manager) creates rules after prohibition removed', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'create_rule', 'rules' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 6: Admin helper functions +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. Admin helper functions ---' + +-- can_manage_roles: wraps is_admin_allowed('manage', 'roles') +SELECT morbac.t('Bob (director) can_manage_roles', + morbac.can_manage_roles( + '30000000-0000-0000-0000-000000000002'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ), TRUE); + +SELECT morbac.t('Dave (employee) can_manage_roles [denied]', + morbac.can_manage_roles( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ), FALSE); + +-- can_manage_policies: wraps is_admin_allowed('manage', 'policies') +SELECT morbac.t('Bob (director) can_manage_policies', + morbac.can_manage_policies( + '30000000-0000-0000-0000-000000000002'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ), TRUE); + +SELECT morbac.t('Carol (manager) can_manage_policies [no manage policies rule for manager]', + morbac.can_manage_policies( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 7: can_manage_user_role — per-role management check +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 7. can_manage_user_role ---' + +-- Grace (hr_manager) has assign_role perm for 'employee' +-- can_manage_user_role checks is_admin_allowed('assign_role', role_name) + +SELECT morbac.t('Grace (hr_manager) can manage employee role assignments', + morbac.can_manage_user_role( + '30000000-0000-0000-0000-000000000007'::uuid, -- Grace + '10000000-0000-0000-0000-000000000001'::uuid, + '20000000-0001-0000-0000-000000000004'::uuid -- employee role + ), TRUE); + +SELECT morbac.t('Grace (hr_manager) can manage intern role assignments', + morbac.can_manage_user_role( + '30000000-0000-0000-0000-000000000007'::uuid, -- Grace + '10000000-0000-0000-0000-000000000001'::uuid, + '20000000-0001-0000-0000-000000000005'::uuid -- intern role + ), TRUE); + +SELECT morbac.t('Grace (hr_manager) cannot manage manager role assignments [no rule]', + morbac.can_manage_user_role( + '30000000-0000-0000-0000-000000000007'::uuid, -- Grace + '10000000-0000-0000-0000-000000000001'::uuid, + '20000000-0001-0000-0000-000000000003'::uuid -- manager role + ), FALSE); + +SELECT morbac.t('Dave (employee) cannot manage any role assignments', + morbac.can_manage_user_role( + '30000000-0000-0000-0000-000000000004'::uuid, -- Dave + '10000000-0000-0000-0000-000000000001'::uuid, + '20000000-0001-0000-0000-000000000005'::uuid -- intern role + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 8: Admin rules are org-scoped +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 8. Admin rules are org-scoped ---' + +-- Carol (manager at GlobalTech) cannot manage Engineering rules +-- (her admin rule is for GlobalTech org only) +SELECT morbac.t('Carol (GlobalTech manager) creates Engineering rules [wrong org]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000003'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, -- Engineering org + 'create_rule', 'rules' + ), FALSE); + +-- Alice (CEO at GlobalTech) cannot manage Engineering rules via admin (same org scoping) +SELECT morbac.t('Alice (GlobalTech CEO) creates Engineering rules [org-scoped]', + morbac.is_admin_allowed( + '30000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, -- Engineering org + 'manage', 'roles' + ), FALSE); \echo '' \echo '=== Administration Rules Tests Completed ===' diff --git a/tests/09_utilities.sql b/tests/09_utilities.sql index b10d2da..b3ad519 100644 --- a/tests/09_utilities.sql +++ b/tests/09_utilities.sql @@ -1,91 +1,471 @@ --- ============================================================================ --- MORBAC PostgreSQL Extension - Utility Functions and Context Tests --- ============================================================================ --- This test file covers utility functions and context evaluation including: --- - Obligations (pending_obligations) --- - Recommendations (pending_recommendations) --- - Context evaluation (eval_context) --- - User role queries (user_roles_in_org, user_has_role) --- - Policy compilation and idempotency --- - Adding new policy entries +-- ============================================================================= +-- Utilities, Derived Roles, RLS, and Policy DSL Tests +-- ============================================================================= +-- Tests miscellaneous utility functions and advanced features: -- --- Prerequisites: Assumes 00_setup.sql has been run to set up base data --- ============================================================================ +-- 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 '=== Obligations and Recommendations ===' +\echo '================================================================' +\echo '09 — UTILITIES, DERIVED ROLES, RLS, POLICY DSL' +\echo '================================================================' -\echo 'Obligations for Bob (employee at Acme):' -SELECT * FROM morbac.pending_obligations( - 'bbbbbbbb-0000-0000-0000-000000000002'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid +-- --------------------------------------------------------------------------- +-- 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' ); -\echo '' -\echo 'Recommendations for Charlie (staff at Beta):' -SELECT * FROM morbac.pending_recommendations( - 'cccccccc-0000-0000-0000-000000000003'::uuid, - '22222222-2222-2222-2222-222222222222'::uuid +-- 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' ); -\echo '' -\echo '=== Utility Functions ===' +-- 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); -\echo 'Roles for Charlie at Acme Corp:' -SELECT * FROM morbac.user_roles_in_org( - 'cccccccc-0000-0000-0000-000000000003'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid +-- --------------------------------------------------------------------------- +-- 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' ); -\echo '' -\echo 'Roles for Charlie at Beta Inc:' -SELECT * FROM morbac.user_roles_in_org( - 'cccccccc-0000-0000-0000-000000000003'::uuid, - '22222222-2222-2222-2222-222222222222'::uuid +-- 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 'Does Alice have admin role at Acme Corp?' -SELECT morbac.user_has_role( - 'aaaaaaaa-0000-0000-0000-000000000001'::uuid, - '11111111-1111-1111-1111-111111111111'::uuid, - 'admin' -) as has_admin_role; +\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 '=== Context Evaluation ===' - -\echo 'Evaluating always context:' -SELECT morbac.eval_context( - (SELECT id FROM morbac.contexts WHERE name = 'always') -) as result; - -\echo '' -\echo 'Evaluating business_hours context:' -SELECT morbac.eval_context( - (SELECT id FROM morbac.contexts WHERE name = 'business_hours') -) as result; - -\echo '' -\echo 'Evaluating weekend context:' -SELECT morbac.eval_context( - (SELECT id FROM morbac.contexts WHERE name = 'weekend') -) as result; - -\echo '' -\echo '=== Policy Re-compilation (Idempotency Test) ===' - -\echo 'Compiling policy again (should show 0 new rules, idempotent):' -SELECT * FROM morbac.compile_policy(); - -\echo '' -\echo '=== Adding New Policy Entry ===' - -INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES - ('Acme Corp', 'admin', 'approve', 'documents', 'permission', 'always'); - -\echo 'Compiling new policy entry:' -SELECT * FROM morbac.compile_policy(); - -\echo '' -\echo '=== Utility Functions and Context Tests Completed ===' +\echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ===' diff --git a/tools/test.sh b/tools/test.sh index 343d2d8..889dbd5 100755 --- a/tools/test.sh +++ b/tools/test.sh @@ -2,9 +2,15 @@ # test.sh - Run tests against the PostgreSQL extension using psql # Usage: ./test.sh [database_name] [postgres_user] -# Note: This script expects the extension to be already installed via 'make install' +# +# Assertions in SQL test files must output rows whose value starts with: +# CHECK PASS: — assertion passed +# CHECK FAIL: — assertion failed +# +# Any SQL ERROR also counts as a failure. +# The script exits with code 1 if any file has failures or SQL errors. -set -e # Exit immediately if a command exits with a non-zero status +set -e DB="${1:-morbac_test}" PG_USER="${2:-$USER}" @@ -18,43 +24,64 @@ if [ ! -d "$TEST_DIR" ]; then exit 1 fi -if ! psql -U "$PG_USER" -d "$DB" -c '\q'; then +if ! psql -U "$PG_USER" -d "$DB" -c '\q' 2>/dev/null; then echo "Error: cannot connect to database: $DB" >&2 echo "Ensure the database exists and is accessible" >&2 exit 1 fi echo "Running tests on database $DB" +echo "" -total=0 -passed=0 -failed=0 +total_files=0 +passed_files=0 +failed_files=0 +total_pass=0 +total_fail=0 -set +e # Temporarily disable exit on error to capture test failures +set +e # Capture failures without aborting for test_file in "$TEST_DIR"/[0-9][0-9]_*.sql; do - if [ -f "$test_file" ]; then - test_name=$(basename "$test_file" .sql) - echo " - $test_name" - ((total++)) + [ -f "$test_file" ] || continue - output=$(psql -U "$PG_USER" -d "$DB" -f "$test_file" 2>&1) - exit_code=$? - echo "$output" + test_name=$(basename "$test_file" .sql) + ((total_files++)) - if [ $exit_code -eq 0 ] && ! echo "$output" | grep -qE "ERROR|FAILED"; then - ((passed++)) - else - ((failed++)) - echo " FAILED" + output=$(psql -U "$PG_USER" -d "$DB" -f "$test_file" 2>&1) + exit_code=$? + + # Count assertions + file_pass=$(echo "$output" | grep -c "CHECK PASS:" || true) + file_fail=$(echo "$output" | grep -c "CHECK FAIL:" || true) + file_errors=$(echo "$output" | grep -c "^psql:.*ERROR:" || true) + + total_pass=$((total_pass + file_pass)) + total_fail=$((total_fail + file_fail)) + + if [ $exit_code -ne 0 ] || [ "$file_fail" -gt 0 ] || [ "$file_errors" -gt 0 ]; then + ((failed_files++)) + echo " [FAIL] $test_name — ${file_pass} passed, ${file_fail} failed, ${file_errors} SQL error(s)" + # Print only failing lines for a focused view + if [ "$file_errors" -gt 0 ]; then + echo "$output" | grep "^psql:.*ERROR:" | sed 's/^/ /' fi + if [ "$file_fail" -gt 0 ]; then + echo "$output" | grep "CHECK FAIL:" | sed 's/^/ /' + fi + else + ((passed_files++)) + echo " [PASS] $test_name — ${file_pass} assertion(s) passed" fi done -set -e # Re-enable exit on error +set -e -echo "Tests: $total total, $passed passed, $failed failed" +echo "" +echo "========================================" +echo "Files : $total_files total | $passed_files passed | $failed_files failed" +echo "Checks : $((total_pass + total_fail)) total | $total_pass passed | $total_fail failed" +echo "========================================" -if [ $failed -gt 0 ]; then +if [ $failed_files -gt 0 ]; then exit 1 fi