feat(tests): add more tests and make them pass

This commit is contained in:
2026-03-22 23:25:48 +01:00
parent 90b96079a0
commit c22d18063b
13 changed files with 3332 additions and 643 deletions
+8 -8
View File
@@ -67,10 +67,10 @@ BEGIN
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id = p_org_id AND ur.org_id = p_org_id
UNION UNION
-- Recursive case: senior roles (roles that inherit from assigned roles) -- Recursive case: junior roles (inherited by the user's assigned roles)
SELECT rh.senior_role_id, er.depth + 1 SELECT rh.junior_role_id, er.depth + 1
FROM effective_roles er 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 SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.depth
FROM effective_roles FROM effective_roles
@@ -207,10 +207,10 @@ BEGIN
UNION UNION
-- Role hierarchy (senior roles) -- Role hierarchy (junior roles inherited by user's roles)
SELECT rh.senior_role_id, er.source || '_inherited', er.depth + 1 SELECT rh.junior_role_id, er.source || '_inherited', er.depth + 1
FROM effective_roles er 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 SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.source, effective_roles.depth
FROM effective_roles FROM effective_roles
@@ -277,9 +277,9 @@ BEGIN
FROM inherited_roles ir FROM inherited_roles ir
INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ir.role_id 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 FROM inherited_roles
ORDER BY role_id, depth; ORDER BY inherited_roles.role_id, inherited_roles.depth;
END; END;
$$; $$;
+8 -8
View File
@@ -4,7 +4,7 @@
-- Helper functions for Row-Level Security policies -- Helper functions for Row-Level Security policies
-- Compatible with PostgREST -- 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() CREATE OR REPLACE FUNCTION morbac.current_user_id()
RETURNS UUID RETURNS UUID
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -13,8 +13,8 @@ AS $$
DECLARE DECLARE
v_user_id TEXT; v_user_id TEXT;
BEGIN BEGIN
-- Read from PostgREST request.header.x-user-id setting -- Read from morbac.user_id GUC (set via SET morbac.user_id = '...' or PostgREST header mapping)
v_user_id := current_setting('request.header.x-user-id', TRUE); v_user_id := current_setting('morbac.user_id', TRUE);
IF v_user_id IS NULL OR v_user_id = '' THEN IF v_user_id IS NULL OR v_user_id = '' THEN
RETURN NULL; RETURN NULL;
@@ -28,9 +28,9 @@ END;
$$; $$;
COMMENT ON FUNCTION morbac.current_user_id() IS 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() CREATE OR REPLACE FUNCTION morbac.current_org_id()
RETURNS UUID RETURNS UUID
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -39,8 +39,8 @@ AS $$
DECLARE DECLARE
v_org_id TEXT; v_org_id TEXT;
BEGIN BEGIN
-- Read from PostgREST request.header.x-org-id setting -- Read from morbac.org_id GUC (set via SET morbac.org_id = '...' or PostgREST header mapping)
v_org_id := current_setting('request.header.x-org-id', TRUE); v_org_id := current_setting('morbac.org_id', TRUE);
IF v_org_id IS NULL OR v_org_id = '' THEN IF v_org_id IS NULL OR v_org_id = '' THEN
RETURN NULL; RETURN NULL;
@@ -54,7 +54,7 @@ END;
$$; $$;
COMMENT ON FUNCTION morbac.current_org_id() IS 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 -- RLS check function
CREATE OR REPLACE FUNCTION morbac.rls_check( CREATE OR REPLACE FUNCTION morbac.rls_check(
+276 -118
View File
@@ -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 -- This file establishes the full company structure used across all test files:
-- that are used across multiple 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 EXTENSION IF EXISTS pgmorbac CASCADE;
DROP SCHEMA IF EXISTS morbac CASCADE; DROP SCHEMA IF EXISTS morbac CASCADE;
-- Install the extension
CREATE EXTENSION pgmorbac; CREATE EXTENSION pgmorbac;
-- Verify schema and tables exist \echo '=== Setup: Verify schema created ==='
\echo '=== Schema and Tables Created ===' SELECT tablename FROM pg_tables WHERE schemaname = 'morbac' ORDER BY tablename;
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname = 'morbac'
ORDER BY tablename;
-- ---------------------------------------------------------------------------
-- ORGANIZATIONS
-- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: Organizations ===' \echo '=== Setup: Organizations ==='
-- Create two organizations
INSERT INTO morbac.orgs (id, name) VALUES INSERT INTO morbac.orgs (id, name) VALUES
('11111111-1111-1111-1111-111111111111', 'Acme Corp'), ('10000000-0000-0000-0000-000000000001', 'GlobalTech HQ'),
('22222222-2222-2222-2222-222222222222', 'Beta Inc'); ('10000000-0000-0000-0000-000000000002', 'Engineering Dept'),
('10000000-0000-0000-0000-000000000003', 'Sales Dept');
-- Create hierarchical organization (Acme Subsidiary under Acme Corp) -- Engineering and Sales are children of GlobalTech
INSERT INTO morbac.orgs (id, name, parent_id) VALUES UPDATE morbac.orgs SET parent_id = '10000000-0000-0000-0000-000000000001'
('33333333-3333-3333-3333-333333333333', 'Acme Subsidiary', '11111111-1111-1111-1111-111111111111'); WHERE id IN (
'10000000-0000-0000-0000-000000000002',
'10000000-0000-0000-0000-000000000003'
);
SELECT SELECT o.name, COALESCE(p.name, '') AS parent
o.name, FROM morbac.orgs o LEFT JOIN morbac.orgs p ON o.parent_id = p.id
COALESCE(p.name, 'None') as parent_organization
FROM morbac.orgs o
LEFT JOIN morbac.orgs p ON o.parent_id = p.id
ORDER BY o.parent_id NULLS FIRST, o.name; ORDER BY o.parent_id NULLS FIRST, o.name;
-- ---------------------------------------------------------------------------
-- ROLES
-- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: Roles ===' \echo '=== Setup: Roles ==='
-- Create roles in Acme Corp -- GlobalTech HQ roles
INSERT INTO morbac.roles (id, org_id, name, description) VALUES INSERT INTO morbac.roles (id, org_id, name, description) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'admin', 'Administrator role'), ('20000000-0001-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001', 'ceo', 'Chief Executive Officer'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'employee', 'Regular employee'), ('20000000-0001-0000-0000-000000000002', '10000000-0000-0000-0000-000000000001', 'director', 'Senior Director'),
('cccccccc-cccc-cccc-cccc-cccccccccccc', '11111111-1111-1111-1111-111111111111', 'contractor', 'External contractor'), ('20000000-0001-0000-0000-000000000003', '10000000-0000-0000-0000-000000000001', 'manager', 'Department Manager'),
('ffffffff-ffff-ffff-ffff-ffffffffffff', '11111111-1111-1111-1111-111111111111', 'viewer', 'Read-only viewer'); ('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 INSERT INTO morbac.roles (id, org_id, name, description) VALUES
('dddddddd-dddd-dddd-dddd-dddddddddddd', '22222222-2222-2222-2222-222222222222', 'manager', 'Manager role'), ('20000000-0002-0000-0000-000000000001', '10000000-0000-0000-0000-000000000002', 'tech_lead', 'Technical Lead'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '22222222-2222-2222-2222-222222222222', 'staff', 'Staff member'); ('20000000-0002-0000-0000-000000000002', '10000000-0000-0000-0000-000000000002', 'engineer', 'Software Engineer');
SELECT r.name as role, o.name as organization -- Sales Dept roles
FROM morbac.roles r INSERT INTO morbac.roles (id, org_id, name, description) VALUES
JOIN morbac.orgs o ON r.org_id = o.id ('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; ORDER BY o.name, r.name;
-- ---------------------------------------------------------------------------
-- ROLE HIERARCHY (GlobalTech)
-- Senior role inherits all permissions granted to junior role
-- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: User Role Assignments ===' \echo '=== Setup: Role Hierarchy (GlobalTech HQ) ==='
-- User Alice (admin at Acme) INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES -- CEO inherits from Director
('aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111'); ('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) -- Engineering: tech_lead inherits from engineer
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES
('bbbbbbbb-0000-0000-0000-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111'); ('20000000-0002-0000-0000-000000000001', '20000000-0002-0000-0000-000000000002');
-- 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');
SELECT SELECT
ur.user_id, sr.name AS senior_role,
r.name as role, jr.name AS junior_role
o.name as organization 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 FROM morbac.user_roles ur
JOIN morbac.roles r ON ur.role_id = r.id JOIN morbac.roles r ON ur.role_id = r.id
JOIN morbac.orgs o ON ur.org_id = o.id JOIN morbac.orgs o ON ur.org_id = o.id
ORDER BY ur.user_id; ORDER BY ur.user_id;
-- ---------------------------------------------------------------------------
-- ACTIVITIES AND VIEWS
-- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: Activities and Views ===' \echo '=== Setup: Activities and Views ==='
INSERT INTO morbac.activities (name, description) VALUES INSERT INTO morbac.activities (name, description) VALUES
('read', 'Read / view data'), ('read', 'Read / view data'),
('write', 'Create/modify data'), ('write', 'Create or modify data'),
('delete', 'Delete data'), ('delete', 'Delete data'),
('approve', 'Approve actions'); ('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 INSERT INTO morbac.views (name, description) VALUES
('documents', 'Document resources'), ('documents', 'General company documents'),
('reports', 'Report resources'), ('reports', 'Business reports'),
('sensitive_data', 'Sensitive/confidential data'), ('financial_data','Financial records'),
('temp_documents', 'Temporary documents'), ('hr_data', 'Human resources data'),
('future_documents', 'Future documents'), ('contracts', 'Legal contracts'),
('active_documents', 'Active documents'); ('audit_logs', 'System audit logs'),
('public_data', 'Publicly accessible data');
-- ---------------------------------------------------------------------------
-- CONTEXTS
-- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: Additional Contexts ===' \echo '=== Setup: Contexts ==='
-- Business hours context (simplified - always true for this test) -- business_hours: always TRUE for tests (simulates daytime access)
CREATE OR REPLACE FUNCTION morbac.context_business_hours() CREATE OR REPLACE FUNCTION morbac.ctx_business_hours()
RETURNS BOOLEAN RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$
LANGUAGE plpgsql BEGIN RETURN TRUE; END;
STABLE $$;
AS $$
BEGIN -- after_hours: always FALSE for tests (simulates outside-hours access)
RETURN TRUE; CREATE OR REPLACE FUNCTION morbac.ctx_after_hours()
END; 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 INSERT INTO morbac.contexts (name, description, evaluator) VALUES
('business_hours', 'During business hours', 'morbac.context_business_hours'::regproc); ('business_hours', 'During business hours (MonFri 917)', '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) SELECT name, description FROM morbac.contexts ORDER BY name;
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);
-- ---------------------------------------------------------------------------
-- POLICIES (via Policy DSL — resolved by compile_policy)
-- ---------------------------------------------------------------------------
\echo '' \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 INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES
-- Admins can do everything on documents -- Intern: read public data only
('Acme Corp', 'admin', 'read', 'documents', 'permission', 'always'), ('GlobalTech HQ', 'intern', 'read', 'public_data', 'permission', 'always'),
('Acme Corp', 'admin', 'write', 'documents', 'permission', 'always'),
('Acme Corp', 'admin', 'delete', 'documents', 'permission', 'always'),
-- Employees can read and write documents -- Employee: read documents and reports; write documents only during business hours
('Acme Corp', 'employee', 'read', 'documents', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'documents', 'permission', 'always'),
('Acme Corp', 'employee', 'write', 'documents', 'permission', 'business_hours'), ('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 -- Obligation: employees must review reports weekly
('Acme Corp', 'contractor', 'read', 'documents', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'reports', 'obligation', 'always'),
-- PROHIBITION: Contractors cannot access sensitive data -- Manager: approve and delete documents; read financial data
('Acme Corp', 'contractor', 'read', 'sensitive_data', 'prohibition', 'always'), ('GlobalTech HQ', 'manager', 'approve','documents', 'permission', 'always'),
('Acme Corp', 'contractor', 'write', 'sensitive_data', 'prohibition', 'always'), ('GlobalTech HQ', 'manager', 'delete', 'documents', 'permission', 'always'),
('GlobalTech HQ', 'manager', 'read', 'financial_data', 'permission', 'always'),
-- OBLIGATION: Employees must review reports weekly -- HR Manager: full access to HR data
('Acme Corp', 'employee', 'read', 'reports', 'obligation', 'always'), ('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 -- Auditor: read audit logs, financial data, and documents
('Acme Corp', 'admin', 'read', 'reports', 'permission', 'always'), ('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'permission', 'always'),
('Acme Corp', 'admin', 'approve', 'reports', '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 INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES
-- Managers have full access ('Engineering Dept', 'engineer', 'read', 'documents', 'permission', 'always'),
('Beta Inc', 'manager', 'read', 'documents', 'permission', 'always'), ('Engineering Dept', 'engineer', 'write', 'documents', 'permission', 'always'),
('Beta Inc', 'manager', 'write', 'documents', 'permission', 'always'), ('Engineering Dept', 'tech_lead', 'approve', 'documents', 'permission', 'always');
('Beta Inc', 'manager', 'read', 'reports', 'permission', 'always'),
-- Staff can read documents -- Sales Dept policies
('Beta Inc', 'staff', 'read', 'documents', 'permission', 'always'), 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) -- Compile all policies into rules
('Beta Inc', 'staff', 'write', 'documents', 'prohibition', 'weekend'), \echo ''
\echo '=== Setup: Compiling Policies ==='
-- RECOMMENDATION: Staff should review reports
('Beta Inc', 'staff', 'read', 'reports', 'recommendation', 'always');
-- Compile policy
SELECT * FROM morbac.compile_policy(); 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 ''
\echo '=== Setup Complete ===' \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)'
+406 -167
View File
@@ -1,179 +1,418 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Authorization Decision Tests -- Authorization Decision Tests
-- ============================================================================ -- =============================================================================
-- This test file covers authorization decision tests including: -- Tests the core is_allowed() function across a variety of scenarios.
-- - Basic authorization decisions (is_allowed function) -- No activity or view hierarchy is active yet (added in 02_hierarchies.sql).
-- - Role hierarchy inheritance
-- - Organization hierarchy
-- - Prohibition precedence
-- - Multi-organization user access
-- --
-- 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 ''
\echo '=== Authorization Decisions ===' \echo '================================================================'
\echo '01 — CORE AUTHORIZATION DECISIONS'
\echo '================================================================'
-- Test Alice (admin at Acme) -- ---------------------------------------------------------------------------
\echo 'Alice (admin at Acme Corp):' -- Section 1: Basic permission grants
SELECT -- ---------------------------------------------------------------------------
'read documents' as action, \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( morbac.is_allowed(
'aaaaaaaa-0000-0000-0000-000000000001'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid, '10000000-0000-0000-0000-000000000001'::uuid,
'read', 'read', 'documents'
'documents' ) IS NOT DISTINCT FROM morbac.is_allowed_nocache(
) as allowed; '30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'documents'
), TRUE);
SELECT SELECT morbac.t('Dave deletes documents: cached = nocache',
'delete documents' as action,
morbac.is_allowed( morbac.is_allowed(
'aaaaaaaa-0000-0000-0000-000000000001'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid, '10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'delete', 'documents'
'documents' ) IS NOT DISTINCT FROM morbac.is_allowed_nocache(
) as allowed; '30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'documents'
), TRUE);
-- Test Bob (employee at Acme) SELECT morbac.t('Frank reads financial_data (prohibited): cached = nocache',
\echo ''
\echo 'Bob (employee at Acme Corp):'
SELECT
'read documents' as action,
morbac.is_allowed( morbac.is_allowed(
'bbbbbbbb-0000-0000-0000-000000000002'::uuid, '30000000-0000-0000-0000-000000000006'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid, '10000000-0000-0000-0000-000000000001'::uuid,
'read', 'read', 'financial_data'
'documents' ) IS NOT DISTINCT FROM morbac.is_allowed_nocache(
) as allowed; '30000000-0000-0000-0000-000000000006'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
SELECT 'read', 'financial_data'
'delete documents' as action, ), TRUE);
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;
\echo '' \echo ''
\echo '=== Authorization Decision Tests Completed ===' \echo '=== Authorization Decision Tests Completed ==='
+353 -29
View File
@@ -1,41 +1,365 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Activity and View Hierarchies Tests -- Hierarchy Tests
-- ============================================================================ -- =============================================================================
-- This test file covers activity and view hierarchy tests including: -- Tests all four hierarchy types:
-- - Activity hierarchy (e.g., write implies read) -- 1. Role hierarchy — senior roles inherit permissions of junior roles (transitive)
-- - View hierarchy (e.g., sensitive_data is a type of documents) -- 2. Activity hierarchy — requesting a senior activity also matches junior-activity rules
-- - Effective activities and views -- 3. View hierarchy — requesting a senior view also matches junior-view rules
-- - Permission inheritance through hierarchies -- 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 ''
\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 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 INSERT INTO morbac.view_hierarchy (senior_view, junior_view) VALUES
('sensitive_data', 'documents'); ('financial_data', 'documents'),
('hr_data', 'documents');
\echo 'Activities implied by write:' -- get_effective_views introspection
SELECT * FROM morbac.get_effective_activities('write'); 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 morbac.t_eq('get_effective_views(financial_data) returns 2 (financial_data, documents)',
SELECT * FROM morbac.get_effective_views('sensitive_data'); (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 morbac.t_eq('get_effective_views(hr_data) returns 2 (hr_data, documents)',
SELECT (SELECT COUNT(*) FROM morbac.get_effective_views('hr_data'))::bigint, 2);
'write documents (via activity hierarchy)' as action,
morbac.is_allowed( -- financial_data is included in get_effective_views('financial_data')
'bbbbbbbb-0000-0000-0000-000000000002'::uuid, SELECT morbac.t('financial_data is in effective views of itself',
'11111111-1111-1111-1111-111111111111'::uuid, EXISTS(
'write', SELECT 1 FROM morbac.get_effective_views('financial_data')
'documents' WHERE view = 'financial_data'
) as allowed; ), 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 ''
\echo '=== Activity and View Hierarchies Tests Completed ===' \echo '=== Hierarchy Tests Completed ==='
+346 -27
View File
@@ -1,36 +1,355 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Delegation Tests -- Delegation Tests
-- ============================================================================ -- =============================================================================
-- This test file covers delegation functionality including: -- Tests the role delegation feature:
-- - Temporary role delegation -- - Active delegation grants the delegate the delegator's role permissions
-- - Delegated permission inheritance -- - Expired delegations are inactive
-- - Comprehensive role reporting (direct + delegated) -- - Future delegations are not yet active
-- - Time-limited delegation (valid_from/valid_until) -- - 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 ''
\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 -- Section 1: Baseline — Leo (employee) before any delegation
('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'); -- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Baseline: Leo before delegation ---'
\echo 'Frank (via delegation from Alice) should have admin permissions:' -- Leo has only employee permissions
SELECT SELECT morbac.t('Leo (employee) reads documents before delegation',
'delete documents (delegated)' as action, morbac.is_allowed_nocache(
morbac.is_allowed( '30000000-0000-0000-0000-000000000012'::uuid,
'ffffffff-0000-0000-0000-000000000006'::uuid, '10000000-0000-0000-0000-000000000001'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid, 'read', 'documents'
'delete', ), TRUE);
'documents'
) as allowed;
\echo 'Frank effective roles (should include delegated admin):' -- Leo cannot approve documents — that requires manager role
SELECT r.name, cr.source SELECT morbac.t('Leo (employee) approves documents before delegation [no manager perm]',
FROM morbac.get_comprehensive_roles('ffffffff-0000-0000-0000-000000000006'::uuid, '11111111-1111-1111-1111-111111111111'::uuid) cr morbac.is_allowed_nocache(
JOIN morbac.roles r ON cr.role_id = r.id; '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 ''
\echo '=== Delegation Tests Completed ===' \echo '=== Delegation Tests Completed ==='
+231 -36
View File
@@ -1,54 +1,249 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Constraint Tests -- Constraint Tests
-- ============================================================================ -- =============================================================================
-- This test file covers various constraints including: -- Tests business constraints:
-- - Separation of Duty (SoD) conflicts -- 1. Separation of Duty (SoD) — mutually exclusive roles
-- - Negative role assignments -- 2. Negative role assignments — explicit blocking of a role
-- - Role cardinality constraints (min/max users per 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 ''
\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 -- Section 1: Separation of Duty — define conflict
('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'); \echo ''
\echo '--- 1. Separation of Duty setup ---'
INSERT INTO morbac.sod_conflicts (role_a_id, role_b_id, org_id, description) VALUES INSERT INTO morbac.sod_conflicts (role_a_id, role_b_id, org_id, description)
('11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111', 'Auditors and accountants are mutually exclusive'); 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 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:' -- Frank now has both contractor and employee roles; employee grants write documents
SELECT morbac.check_sod_violation( SELECT morbac.t('Frank (now also employee) writes documents [employee permission]',
'99999999-0000-0000-0000-000000000009'::uuid, morbac.is_allowed_nocache(
'22222222-2222-2222-2222-222222222222'::uuid, '30000000-0000-0000-0000-000000000006'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid '10000000-0000-0000-0000-000000000001'::uuid,
) as would_violate_sod; '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 ''
\echo '=== Negative Role Assignment ===' \echo '--- 4. Role cardinality constraints ---'
-- Explicitly prohibit contractor from ever being admin -- Set a cardinality constraint: compliance_officer role — min 1, max 2
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason) VALUES INSERT INTO morbac.role_cardinality (role_id, min_users, max_users, description)
('cccccccc-0000-0000-0000-000000000003', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'Contractors cannot be administrators'); 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 '' -- Assign two users as compliance officer
\echo '=== Cardinality Constraints ===' 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 -- Now 2 users — at max. Trying to add a 3rd should violate
INSERT INTO morbac.role_cardinality (role_id, min_users, max_users, description) VALUES SELECT morbac.t_not_null('Adding 3rd compliance_officer (2 users, max=2) — violation returned',
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 2, 'Admin role limited to 2 users minimum 1'); 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):' -- Removing one — 2 users, min=1 — removing leaves 1 which is ≥ min=1, should be fine
SELECT morbac.check_cardinality_violation('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::uuid, TRUE) as violation; 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 ''
\echo '=== Constraint Tests Completed ===' \echo '=== Constraint Tests Completed ==='
+314 -67
View File
@@ -1,82 +1,329 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Temporal Constraint Tests -- Temporal Constraint Tests
-- ============================================================================ -- =============================================================================
-- This test file covers temporal constraints including: -- Tests time-bounded rules using valid_from and valid_until on the rules table.
-- - 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
-- --
-- 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 ''
\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) -- Setup: dedicated view for temporal tests
VALUES ( -- ---------------------------------------------------------------------------
'11111111-1111-1111-1111-111111111111', INSERT INTO morbac.views (name, description) VALUES
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- employee role ('temp_view', 'Scratch view for temporal tests');
'write',
'temp_documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
'2020-01-01 00:00:00+00' -- Expired rule
);
-- Create a future rule (not yet active) -- ---------------------------------------------------------------------------
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from) -- Section 1: is_rule_valid() helper function
VALUES ( -- ---------------------------------------------------------------------------
'11111111-1111-1111-1111-111111111111', \echo ''
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', \echo '--- 1. is_rule_valid() helper ---'
'write',
'future_documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
'2030-01-01 00:00:00+00' -- Future rule
);
-- 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) INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from, valid_until)
VALUES ( VALUES (
'11111111-1111-1111-1111-111111111111', '10000000-0000-0000-0000-000000000001',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '20000000-0001-0000-0000-000000000004', -- employee
'write', 'audit', 'temp_view',
'active_documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'), (SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'permission',
'2020-01-01 00:00:00+00', -- Started in the past now() - interval '1 day',
'2030-12-31 23:59:59+00' -- Ends in the future now() + interval '1 day'
); );
\echo 'Alice (employee) access to temporal-constrained documents:' -- is_active should be TRUE for the active rule
SELECT SELECT morbac.t('is_active = TRUE for rule with active time window',
'write to temp_documents (expired rule)' as action, (SELECT is_active FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view'),
morbac.is_allowed( TRUE);
'aaaaaaaa-0000-0000-0000-000000000001'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid, -- Update to expired window, trigger should recalculate is_active
'write', UPDATE morbac.rules
'temp_documents' SET valid_until = now() - interval '1 second'
) as allowed WHERE activity = 'audit' AND view = 'temp_view';
UNION ALL
SELECT -- is_active should be FALSE after expiry update
'write to future_documents (not yet active)' as action, SELECT morbac.t('is_active = FALSE after valid_until set to past',
morbac.is_allowed( (SELECT is_active FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view'),
'aaaaaaaa-0000-0000-0000-000000000001'::uuid, FALSE);
'11111111-1111-1111-1111-111111111111'::uuid,
'write', -- Clean up
'future_documents' DELETE FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view';
) as allowed
UNION ALL -- ---------------------------------------------------------------------------
SELECT -- Section 3: Expired rule — valid_until in the past
'write to active_documents (currently valid)' as action, -- ---------------------------------------------------------------------------
morbac.is_allowed( \echo ''
'aaaaaaaa-0000-0000-0000-000000000001'::uuid, \echo '--- 3. Expired rule (valid_until in the past) ---'
'11111111-1111-1111-1111-111111111111'::uuid,
'write', INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_until)
'active_documents' VALUES (
) as allowed; '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 ''
\echo '=== Temporal Constraint Tests Completed ===' \echo '=== Temporal Constraint Tests Completed ==='
+291 -22
View File
@@ -1,30 +1,299 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Cross-Organizational Rules Tests -- Cross-Organization Rules Tests
-- ============================================================================ -- =============================================================================
-- This test file covers cross-organizational authorization including: -- Tests inter-organizational access via the cross_org_rules table.
-- - Cross-organization rules (access between different orgs)
-- - Inter-organizational collaboration scenarios
-- - Cross-org permission validation
-- --
-- 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 ''
\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 -- Setup: additional roles and users for cross-org scenarios
('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'); -- ---------------------------------------------------------------------------
\echo 'Diana (manager at Beta) can read Acme documents via cross-org rule:' -- Nina: eng_auditor role in Engineering Dept
SELECT INSERT INTO morbac.roles (id, org_id, name, description) VALUES
'read Acme documents from Beta' as action, ('20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002', 'eng_auditor', 'Engineering Auditor');
morbac.is_allowed(
'dddddddd-0000-0000-0000-000000000004'::uuid, INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
'11111111-1111-1111-1111-111111111111'::uuid, ('30000000-0000-0000-0000-000000000014', '20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002');
'read',
'documents' -- Policies for eng_auditor in Engineering
) as allowed; 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 ''
\echo '=== Cross-Organization Rules Tests Completed ===' \echo '=== Cross-Organization Rules Tests Completed ==='
+260 -34
View File
@@ -1,54 +1,280 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Audit Logging Tests -- Audit Logging Tests
-- ============================================================================ -- =============================================================================
-- This test file covers audit logging functionality including: -- Tests the audit system: enable_audit / disable_audit and the resulting
-- - Enabling audit on tables -- audit_log entries for INSERT, UPDATE, and DELETE operations.
-- - Auditing INSERT operations
-- - Auditing UPDATE operations
-- - Auditing DELETE operations
-- - Audit log queries and reporting
-- - Disabling audit
-- --
-- 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 ''
\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'); 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) INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES ( VALUES (
'ffffffff-0000-0000-0000-000000000001'::uuid, '30000000-0000-0000-0000-000000000011', -- Karl
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- employee role '20000000-0001-0000-0000-000000000005', -- intern role
'11111111-1111-1111-1111-111111111111' '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 DELETE FROM morbac.user_roles
WHERE user_id = 'ffffffff-0000-0000-0000-000000000001'::uuid WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; AND role_id = '20000000-0001-0000-0000-000000000005'
AND org_id = '10000000-0000-0000-0000-000000000001';
\echo 'Audit log entries for user_roles operations:' -- Now 2 entries: INSERT + DELETE
SELECT SELECT morbac.t_eq('Audit log has 2 entries after DELETE (INSERT + DELETE)',
operation, (SELECT COUNT(*) FROM morbac.audit_log WHERE table_name = 'user_roles')::bigint,
record_id, 2);
'N/A' as old_active,
'N/A' as new_active, -- DELETE entry must have old_data populated
session_user, SELECT morbac.t('DELETE audit entry has old_data populated',
timestamp (SELECT old_data IS NOT NULL FROM morbac.audit_log
FROM morbac.audit_log WHERE table_name = 'user_roles' AND operation = 'DELETE'
WHERE table_name = 'user_roles' LIMIT 1),
ORDER BY timestamp; 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'); 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 ''
\echo '=== Audit Logging Tests Completed ===' \echo '=== Audit Logging Tests Completed ==='
+333 -28
View File
@@ -1,37 +1,342 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Administration Rules Tests -- Administration Rules Tests
-- ============================================================================ -- =============================================================================
-- This test file covers administration rules including: -- Tests the admin meta-policy system: who can manage policies, roles, and users.
-- - Administrative activities (create_rule, etc.)
-- - Administrative permission checks (is_admin_allowed)
-- - Role-based administrative access control
-- - Administrative authorization decisions
-- --
-- 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 ''
\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 -- Section 1: Default deny — no admin rule means no admin access
('11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'create_rule', 'rules', (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); -- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Default deny: no admin rule ---'
\echo 'Alice (admin) can create rules:' -- Nobody has admin rules yet — all should be denied
SELECT morbac.is_admin_allowed( SELECT morbac.t('Alice (CEO, no admin rule yet) creates rules [default deny]',
'aaaaaaaa-0000-0000-0000-000000000001'::uuid, morbac.is_admin_allowed(
'11111111-1111-1111-1111-111111111111'::uuid, '30000000-0000-0000-0000-000000000001'::uuid,
'create_rule', '10000000-0000-0000-0000-000000000001'::uuid,
'rules' 'create_rule', 'rules'
) as can_create_rules; ), FALSE);
\echo 'Bob (employee) cannot create rules:' SELECT morbac.t('Grace (hr_manager, no admin rule) assigns roles [default deny]',
SELECT morbac.is_admin_allowed( morbac.is_admin_allowed(
'bbbbbbbb-0000-0000-0000-000000000002'::uuid, '30000000-0000-0000-0000-000000000007'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid, '10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'assign_role', 'employee'
'rules' ), FALSE);
) as can_create_rules;
-- ---------------------------------------------------------------------------
-- 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 ''
\echo '=== Administration Rules Tests Completed ===' \echo '=== Administration Rules Tests Completed ==='
+455 -75
View File
@@ -1,91 +1,471 @@
-- ============================================================================ -- =============================================================================
-- MORBAC PostgreSQL Extension - Utility Functions and Context Tests -- Utilities, Derived Roles, RLS, and Policy DSL Tests
-- ============================================================================ -- =============================================================================
-- This test file covers utility functions and context evaluation including: -- Tests miscellaneous utility functions and advanced features:
-- - 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
-- --
-- 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 ''
\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( -- Section 1: pending_obligations
'bbbbbbbb-0000-0000-0000-000000000002'::uuid, -- ---------------------------------------------------------------------------
'11111111-1111-1111-1111-111111111111'::uuid \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 '' -- Obligation alone doesn't grant access
\echo 'Recommendations for Charlie (staff at Beta):' SELECT morbac.t('Dave has obligation for read contracts but no permission [denied]',
SELECT * FROM morbac.pending_recommendations( morbac.is_allowed_nocache(
'cccccccc-0000-0000-0000-000000000003'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
'22222222-2222-2222-2222-222222222222'::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 '' -- Recommendation alone doesn't grant access
\echo '=== Utility Functions ===' 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( -- Section 4: user_has_role
'cccccccc-0000-0000-0000-000000000003'::uuid, -- ---------------------------------------------------------------------------
'11111111-1111-1111-1111-111111111111'::uuid \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 '' -- Evaluator function: returns TRUE only for Dave at GlobalTech
\echo 'Roles for Charlie at Beta Inc:' CREATE OR REPLACE FUNCTION morbac.eval_senior_employee(p_user_id UUID, p_org_id UUID)
SELECT * FROM morbac.user_roles_in_org( RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$
'cccccccc-0000-0000-0000-000000000003'::uuid, BEGIN
'22222222-2222-2222-2222-222222222222'::uuid -- 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 ''
\echo 'Does Alice have admin role at Acme Corp?' \echo '--- 10. RLS helper: current_user_id, current_org_id, rls_check ---'
SELECT morbac.user_has_role(
'aaaaaaaa-0000-0000-0000-000000000001'::uuid, -- Without session variables set, current_user_id and current_org_id return NULL
'11111111-1111-1111-1111-111111111111'::uuid, SELECT morbac.t_null('current_user_id() returns NULL without session var',
'admin' morbac.current_user_id()::text);
) as has_admin_role;
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 ''
\echo '=== Context Evaluation ===' \echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ==='
\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 ==='
+46 -19
View File
@@ -2,9 +2,15 @@
# test.sh - Run tests against the PostgreSQL extension using psql # test.sh - Run tests against the PostgreSQL extension using psql
# Usage: ./test.sh [database_name] [postgres_user] # 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: <description> — assertion passed
# CHECK FAIL: <description> — 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}" DB="${1:-morbac_test}"
PG_USER="${2:-$USER}" PG_USER="${2:-$USER}"
@@ -18,43 +24,64 @@ if [ ! -d "$TEST_DIR" ]; then
exit 1 exit 1
fi 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 "Error: cannot connect to database: $DB" >&2
echo "Ensure the database exists and is accessible" >&2 echo "Ensure the database exists and is accessible" >&2
exit 1 exit 1
fi fi
echo "Running tests on database $DB" echo "Running tests on database $DB"
echo ""
total=0 total_files=0
passed=0 passed_files=0
failed=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 for test_file in "$TEST_DIR"/[0-9][0-9]_*.sql; do
if [ -f "$test_file" ]; then [ -f "$test_file" ] || continue
test_name=$(basename "$test_file" .sql) test_name=$(basename "$test_file" .sql)
echo " - $test_name" ((total_files++))
((total++))
output=$(psql -U "$PG_USER" -d "$DB" -f "$test_file" 2>&1) output=$(psql -U "$PG_USER" -d "$DB" -f "$test_file" 2>&1)
exit_code=$? exit_code=$?
echo "$output"
if [ $exit_code -eq 0 ] && ! echo "$output" | grep -qE "ERROR|FAILED"; then # Count assertions
((passed++)) file_pass=$(echo "$output" | grep -c "CHECK PASS:" || true)
else file_fail=$(echo "$output" | grep -c "CHECK FAIL:" || true)
((failed++)) file_errors=$(echo "$output" | grep -c "^psql:.*ERROR:" || true)
echo " FAILED"
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 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 fi
done 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 exit 1
fi fi