diff --git a/src/authorization.sql b/src/authorization.sql index a3d2d35..0d1a15f 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -245,6 +245,12 @@ BEGIN v_computed_result := morbac.is_allowed_nocache(p_user_id, p_org_id, p_activity, p_view); + -- NULL org_id (global-rules-only path) cannot be stored in auth_cache (org_id NOT NULL PK). + -- Global rule changes flush the entire cache anyway, so skipping is safe. + IF p_org_id IS NULL THEN + RETURN v_computed_result; + END IF; + BEGIN INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at) VALUES ( diff --git a/src/rls.sql b/src/rls.sql index 0fc0ab3..3ba3eb0 100644 --- a/src/rls.sql +++ b/src/rls.sql @@ -162,17 +162,15 @@ BEGIN v_org_ids := morbac.current_org_ids(); IF v_org_ids IS NOT NULL THEN - IF p_row_org_id IS NULL OR NOT (p_row_org_id = ANY(v_org_ids)) THEN + IF p_row_org_id IS NOT NULL AND NOT (p_row_org_id = ANY(v_org_ids)) THEN RETURN FALSE; END IF; + -- p_row_org_id NULL: global row — is_allowed(NULL) checks global_rules only RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); END IF; - IF p_row_org_id IS NOT NULL THEN - RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); - END IF; - - RETURN FALSE; + -- No org context: use row's org (or NULL for global rows — global_rules only) + RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); END; $$; diff --git a/tests/13_global_rules.sql b/tests/13_global_rules.sql index 242e547..008ce23 100644 --- a/tests/13_global_rules.sql +++ b/tests/13_global_rules.sql @@ -380,5 +380,9 @@ SELECT morbac.t('Karl reads GlobalTech contracts [temporal global rule, expired] 'read', 'contracts' ), FALSE); +DELETE FROM morbac.global_rules +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND activity = 'read' AND view = 'contracts'; + \echo '' \echo '=== Global Rules Tests Completed ===' diff --git a/tests/15_rls_check.sql b/tests/15_rls_check.sql new file mode 100644 index 0000000..9491ddc --- /dev/null +++ b/tests/15_rls_check.sql @@ -0,0 +1,215 @@ +-- ============================================================================= +-- rls_check Tests +-- ============================================================================= +-- Tests morbac.rls_check() with all session-org combinations, focusing on +-- the two cases fixed to support global rows (p_row_org_id IS NULL): +-- +-- 1. No user_id set: always FALSE +-- 2. Single org context +-- a. org-scoped row, matching org +-- b. org-scoped row, different org (blocked) +-- c. global row (NULL org_id): uses session org +-- 3. org_ids filter +-- a. org-scoped row in list +-- b. org-scoped row not in list (blocked) +-- c. global row + global permission [was FALSE, now TRUE] +-- d. global row + global prohibition [was FALSE, now correctly FALSE] +-- e. global row + no rule [was FALSE, still FALSE] +-- 4. No org context +-- a. org-scoped row: uses row's org +-- b. global row + global permission [was FALSE, now TRUE] +-- c. global row + global prohibition [was FALSE, now correctly FALSE] +-- d. global row + no rule [was FALSE, still FALSE] +-- +-- User state carried from previous tests: +-- Karl (30000000-0000-0000-0000-000000000011): +-- - user_rules: read documents, read reports in GlobalTech HQ (no expiry) +-- - no role assignments, no org memberships +-- +-- Prerequisites: 00_setup.sql -> 14_system_principals.sql +-- ============================================================================= + +\echo '' +\echo '================================================================' +\echo '15 -- RLS_CHECK' +\echo '================================================================' + +-- --------------------------------------------------------------------------- +-- Section 1: No user_id set — always FALSE +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. No user_id: always FALSE ---' + +RESET morbac.user_id; +RESET morbac.org_id; +RESET morbac.org_ids; + +SELECT morbac.t('rls_check without user_id, org row', + morbac.rls_check('read', 'documents', + '10000000-0000-0000-0000-000000000001'::uuid), + FALSE); + +SELECT morbac.t('rls_check without user_id, global row', + morbac.rls_check('read', 'contracts', NULL), + FALSE); + +-- --------------------------------------------------------------------------- +-- Section 2: Single org context +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Single org context ---' + +SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl +SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ + +-- 2a: org-scoped row, matching org — Karl has user_rule for read documents +SELECT morbac.t('rls_check single org, org row matches session org (Karl/documents)', + morbac.rls_check('read', 'documents', + '10000000-0000-0000-0000-000000000001'::uuid), + TRUE); + +-- 2b: org-scoped row, different org — blocked before is_allowed +SELECT morbac.t('rls_check single org, org row from different org (blocked)', + morbac.rls_check('read', 'documents', + '10000000-0000-0000-0000-000000000002'::uuid), + FALSE); + +-- 2c: global row — uses session org, so Karl's user_rule on GlobalTech applies +SELECT morbac.t('rls_check single org, global row (NULL org_id): uses session org', + morbac.rls_check('read', 'documents', NULL), + TRUE); + +-- 2c (no permission): Karl has no rule for contracts in GlobalTech +SELECT morbac.t('rls_check single org, global row (NULL org_id): no permission for contracts', + morbac.rls_check('read', 'contracts', NULL), + FALSE); + +RESET morbac.user_id; +RESET morbac.org_id; + +-- --------------------------------------------------------------------------- +-- Section 3: org_ids filter +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. org_ids filter ---' + +SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl +SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001"]'; -- [GlobalTech HQ] + +-- 3a: org-scoped row in the list — Karl has user_rule for read documents in GlobalTech +SELECT morbac.t('rls_check org_ids, org row in list (Karl/documents/GlobalTech)', + morbac.rls_check('read', 'documents', + '10000000-0000-0000-0000-000000000001'::uuid), + TRUE); + +-- 3b: org-scoped row not in the list — blocked +SELECT morbac.t('rls_check org_ids, org row not in list (blocked)', + morbac.rls_check('read', 'documents', + '10000000-0000-0000-0000-000000000002'::uuid), + FALSE); + +-- 3c: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only +INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('rls_check org_ids, global row + global permission [new: was FALSE]', + morbac.rls_check('read', 'contracts', NULL), + TRUE); + +DELETE FROM morbac.global_rules +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND activity = 'read' AND view = 'contracts'; + +-- 3d: global row + global prohibition +INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +SELECT morbac.t('rls_check org_ids, global row + global prohibition', + morbac.rls_check('read', 'contracts', NULL), + FALSE); + +DELETE FROM morbac.global_rules +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND activity = 'read' AND view = 'contracts'; + +-- 3e: global row + no rule +SELECT morbac.t('rls_check org_ids, global row + no rule', + morbac.rls_check('read', 'contracts', NULL), + FALSE); + +RESET morbac.user_id; +RESET morbac.org_ids; + +-- --------------------------------------------------------------------------- +-- Section 4: No org context +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. No org context ---' + +SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl + +-- 4a: org-scoped row — uses row's org_id (Karl has user_rule in GlobalTech) +SELECT morbac.t('rls_check no org context, org row: uses row org (Karl/documents/GlobalTech)', + morbac.rls_check('read', 'documents', + '10000000-0000-0000-0000-000000000001'::uuid), + TRUE); + +-- 4a (no permission): Karl has no rule in Engineering +SELECT morbac.t('rls_check no org context, org row: no permission in row org (Engineering)', + morbac.rls_check('read', 'documents', + '10000000-0000-0000-0000-000000000002'::uuid), + FALSE); + +-- 4b: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only +INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('rls_check no org context, global row + global permission [new: was FALSE]', + morbac.rls_check('read', 'contracts', NULL), + TRUE); + +DELETE FROM morbac.global_rules +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND activity = 'read' AND view = 'contracts'; + +-- 4c: global row + global prohibition +INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +SELECT morbac.t('rls_check no org context, global row + global prohibition', + morbac.rls_check('read', 'contracts', NULL), + FALSE); + +DELETE FROM morbac.global_rules +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND activity = 'read' AND view = 'contracts'; + +-- 4d: global row + no rule +SELECT morbac.t('rls_check no org context, global row + no rule', + morbac.rls_check('read', 'contracts', NULL), + FALSE); + +RESET morbac.user_id; + +\echo '' +\echo '=== rls_check Tests Completed ==='