diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index ce30435..a2828ee 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -851,7 +851,7 @@ WHERE table_name = 'rules' ### Authorization Functions -**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny. +**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny. Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests). ```sql SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents'); diff --git a/src/authorization.sql b/src/authorization.sql index c5d24bd..a3d2d35 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -32,10 +32,11 @@ DECLARE v_rule RECORD; v_max_prohibition_priority INTEGER := NULL; v_max_permission_priority INTEGER := NULL; - v_is_system_principal BOOLEAN := FALSE; + v_is_system_principal BOOLEAN; BEGIN - SELECT TRUE INTO v_is_system_principal - FROM morbac.system_principals WHERE user_id = p_user_id; + v_is_system_principal := EXISTS ( + SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id + ); -- STEPS 1-3.5: Prohibitions — skipped entirely for system principals IF NOT v_is_system_principal THEN @@ -244,19 +245,23 @@ BEGIN v_computed_result := morbac.is_allowed_nocache(p_user_id, p_org_id, p_activity, p_view); - INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at) - VALUES ( - p_user_id, - p_org_id, - p_activity, - p_view, - v_computed_result, - CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer) - ) - ON CONFLICT (user_id, org_id, activity, view) DO UPDATE - SET allowed = v_computed_result, - computed_at = CURRENT_TIMESTAMP, - expires_at = CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer); + BEGIN + INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at) + VALUES ( + p_user_id, + p_org_id, + p_activity, + p_view, + v_computed_result, + CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer) + ) + ON CONFLICT (user_id, org_id, activity, view) DO UPDATE + SET allowed = v_computed_result, + computed_at = CURRENT_TIMESTAMP, + expires_at = CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer); + EXCEPTION WHEN read_only_sql_transaction THEN + NULL; + END; RETURN v_computed_result; END; diff --git a/tests/14_system_principals.sql b/tests/14_system_principals.sql index 4d92ff5..88bfe52 100644 --- a/tests/14_system_principals.sql +++ b/tests/14_system_principals.sql @@ -14,6 +14,8 @@ -- 8. Cannot create delegations involving a system principal -- 9. Cannot add a targeted global_rules prohibition for a system principal -- 10. Cannot modify or delete existing global_rules for a system principal +-- 11. Prohibitions apply to regular users (system principal bypass is not global) +-- 12. is_allowed() works correctly in read-only transactions -- -- Prerequisites: 00_setup.sql -> 13_global_rules.sql -- ============================================================================= @@ -289,5 +291,54 @@ EXCEPTION END; $$; +-- --------------------------------------------------------------------------- +-- Section 11: Prohibitions apply to regular users, not just system principals +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 11. Prohibitions apply to regular users ---' + +INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority) +VALUES ( + NULL, + 'delete', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition', 100 +); + +SELECT morbac.t('Dave deletes GlobalTech documents [blanket prohibition must apply to non-system users]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), FALSE); + +SELECT morbac.t('System principal deletes GlobalTech documents [prohibition still bypassed]', + morbac.is_allowed_nocache( + '40000000-0000-0000-0000-000000000001'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'delete', 'documents' + ), TRUE); + +DELETE FROM morbac.global_rules +WHERE user_id IS NULL AND activity = 'delete' AND view = 'documents'; + +-- --------------------------------------------------------------------------- +-- Section 12: is_allowed() works in read-only transactions +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 12. is_allowed() in read-only transactions ---' + +BEGIN; +SET TRANSACTION READ ONLY; + +SELECT morbac.t('is_allowed() returns correct result in read-only transaction', + morbac.is_allowed( + '30000000-0000-0000-0000-000000000004'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'public_data' + ), TRUE); + +ROLLBACK; + \echo '' \echo '=== System Principals Tests Completed ==='