diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8c537..b59d16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.2] - 2026-11-06 + +### Fixed +- `is_allowed()` with a NULL user id failed with a NOT NULL violation on + `auth_cache` instead of answering. `is_allowed()` and `is_allowed_nocache()` + now return FALSE for a NULL user, the answer `rls_check()` already gave. + Decisions for real users are unchanged. + ## [1.0.1] - 2026-10-14 ### Fixed diff --git a/META.json b/META.json index 93157b1..9d84ce4 100644 --- a/META.json +++ b/META.json @@ -2,7 +2,7 @@ "name": "pgmorbac", "abstract": "Multi-OrBAC: Organization-Based Access Control with multi-organization support", "description": "A PostgreSQL extension implementing the Multi-OrBAC access control model \u2014 enabling organization-centric, context-aware, and hierarchical access control with delegation, separation of duty, and cross-organizational policies.", - "version": "1.0.1", + "version": "1.0.2", "maintainer": [ "Marc VILLAIN " ], @@ -10,8 +10,8 @@ "provides": { "pgmorbac": { "abstract": "Multi-OrBAC access control extension", - "file": "pgmorbac--1.0.1.sql", - "version": "1.0.1" + "file": "pgmorbac--1.0.2.sql", + "version": "1.0.2" } }, "prereqs": { diff --git a/README.md b/README.md index 60f14ba..bfde38b 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ sudo ./tools/install.sh ```bash # Build versioned file from source -make build # Concatenates src/ files into pgmorbac--1.0.1.sql +make build # Concatenates src/ files into pgmorbac--1.0.2.sql # Copy files to PostgreSQL extension directory sudo cp pgmorbac.control $(pg_config --sharedir)/extension/ -sudo cp pgmorbac--1.0.1.sql $(pg_config --sharedir)/extension/ +sudo cp pgmorbac--1.0.2.sql $(pg_config --sharedir)/extension/ # Enable in PostgreSQL psql -d mydb -c "CREATE EXTENSION pgmorbac;" diff --git a/pgmorbac.control b/pgmorbac.control index ea8a555..4fed39b 100644 --- a/pgmorbac.control +++ b/pgmorbac.control @@ -1,6 +1,6 @@ # pgmorbac extension # Multi-OrBAC access control model for PostgreSQL comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support' -default_version = '1.0.1' +default_version = '1.0.2' relocatable = false schema = morbac diff --git a/src/authorization.sql b/src/authorization.sql index e73b17f..d02d43b 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -57,6 +57,11 @@ DECLARE v_cross_context UUID[]; v_cross_prio INTEGER[]; BEGIN + -- No principal, no decision: the same answer rls_check() gives. + IF p_user_id IS NULL THEN + RETURN FALSE; + END IF; + v_is_system_principal := EXISTS ( SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id ); @@ -310,6 +315,10 @@ DECLARE v_computed_result BOOLEAN; v_expires_at TIMESTAMPTZ; BEGIN + IF p_user_id IS NULL THEN + RETURN FALSE; + END IF; + SELECT allowed, expires_at INTO v_cached_result, v_expires_at FROM morbac.auth_cache WHERE user_id = p_user_id diff --git a/src/upgrades/pgmorbac--1.0.1--1.0.2.sql b/src/upgrades/pgmorbac--1.0.1--1.0.2.sql new file mode 100644 index 0000000..4c8a06d --- /dev/null +++ b/src/upgrades/pgmorbac--1.0.1--1.0.2.sql @@ -0,0 +1,339 @@ +-- pgmorbac 1.0.1 -> 1.0.2 +-- +-- is_allowed() with a NULL user id failed with a NOT NULL violation on +-- auth_cache instead of answering. Both is_allowed() and is_allowed_nocache() +-- now return FALSE for a NULL user, as rls_check() already did. No decision +-- for a real user changes. + +CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( + p_user_id UUID, + p_org_id UUID, + p_activity TEXT, + p_view TEXT +) +RETURNS BOOLEAN +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_rule RECORD; + v_max_prohibition_priority INTEGER := NULL; + v_max_permission_priority INTEGER := NULL; + v_is_system_principal BOOLEAN; + v_activities TEXT[]; + v_views TEXT[]; + v_held_org UUID[]; + v_held_role UUID[]; + v_local_modality morbac.modality[]; + v_local_context UUID[]; + v_local_prio INTEGER[]; + v_cross_modality morbac.modality[]; + v_cross_context UUID[]; + v_cross_prio INTEGER[]; +BEGIN + -- No principal, no decision: the same answer rls_check() gives. + IF p_user_id IS NULL THEN + RETURN FALSE; + END IF; + + v_is_system_principal := EXISTS ( + SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id + ); + + v_activities := ARRAY(SELECT activity FROM morbac.get_effective_activities(p_activity)); + v_views := ARRAY(SELECT view FROM morbac.get_effective_views(p_view)); + + -- Roles the user holds, per org. get_comprehensive_roles() returns nothing + -- in an org without a direct assignment, delegation or derived role for the + -- user, and its result only matters where a rule matches the request. + WITH orgs AS MATERIALIZED ( + SELECT o.org_id + FROM ( + SELECT ur.org_id FROM morbac.user_roles ur WHERE ur.user_id = p_user_id + UNION + SELECT d.org_id FROM morbac.delegations d WHERE d.delegatee_id = p_user_id + UNION + SELECT ro.org_id FROM morbac.derived_roles dr + INNER JOIN morbac.roles ro ON ro.id = dr.role_id + ) o + WHERE EXISTS ( + SELECT 1 FROM morbac.rules r + WHERE r.org_id = o.org_id + AND r.activity = ANY (v_activities) + AND r.view = ANY (v_views) + ) + OR EXISTS ( + SELECT 1 FROM morbac.cross_org_rules cr + WHERE cr.source_org_id = o.org_id + AND cr.target_org_id = p_org_id + AND cr.activity = ANY (v_activities) + AND cr.view = ANY (v_views) + ) + ), + held AS ( + SELECT DISTINCT c.org_id, g.role_id + FROM orgs c + CROSS JOIN LATERAL morbac.get_comprehensive_roles(p_user_id, c.org_id) g + ) + SELECT array_agg(h.org_id ORDER BY h.org_id, h.role_id), + array_agg(h.role_id ORDER BY h.org_id, h.role_id) + INTO v_held_org, v_held_role + FROM held h; + + -- Local rules applicable at p_org_id (prohibitions are not collected for + -- system principals, which skip them) + WITH matched AS MATERIALIZED ( + SELECT r.org_id, r.scope, r.modality, r.context_id, COALESCE(r.priority, 0) AS prio + FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id) + INNER JOIN morbac.rules r ON r.org_id = h.org_id AND r.role_id = h.role_id + WHERE r.activity = ANY (v_activities) + AND r.view = ANY (v_views) + AND (r.modality = 'permission' OR (r.modality = 'prohibition' AND NOT v_is_system_principal)) + AND morbac.is_rule_valid(r.valid_from, r.valid_until) + ), + in_scope AS MATERIALIZED ( + SELECT s.org_id, s.scope + FROM (SELECT DISTINCT m.org_id, m.scope FROM matched m) s + WHERE morbac.org_in_scope(p_org_id, s.org_id, s.scope) + ) + SELECT array_agg(m.modality ORDER BY m.prio DESC, m.modality, m.context_id), + array_agg(m.context_id ORDER BY m.prio DESC, m.modality, m.context_id), + array_agg(m.prio ORDER BY m.prio DESC, m.modality, m.context_id) + INTO v_local_modality, v_local_context, v_local_prio + FROM matched m + INNER JOIN in_scope s ON s.org_id = m.org_id AND s.scope = m.scope; + + -- Cross-org rules into p_org_id whose role the user holds in the source org + SELECT array_agg(cr.modality ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id), + array_agg(cr.context_id ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id), + array_agg(COALESCE(cr.priority, 0) ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id) + INTO v_cross_modality, v_cross_context, v_cross_prio + FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id) + INNER JOIN morbac.cross_org_rules cr ON cr.source_org_id = h.org_id AND cr.role_id = h.role_id + WHERE cr.target_org_id = p_org_id + AND cr.activity = ANY (v_activities) + AND cr.view = ANY (v_views) + AND (cr.modality = 'permission' OR (cr.modality = 'prohibition' AND NOT v_is_system_principal)) + AND morbac.is_rule_valid(cr.valid_from, cr.valid_until); + + -- STEPS 1-3.5: Prohibitions - skipped entirely for system principals + IF NOT v_is_system_principal THEN + + -- STEP 1: Local prohibitions - find the highest-priority applicable one + FOR v_rule IN + SELECT u.context_id, u.prio + FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio) + WHERE u.modality = 'prohibition' + ORDER BY u.prio DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + v_max_prohibition_priority := v_rule.prio; + EXIT; + END IF; + END LOOP; + + -- STEP 2: Cross-org prohibitions - update max if a higher priority is found + FOR v_rule IN + SELECT u.context_id, u.prio + FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio) + WHERE u.modality = 'prohibition' + ORDER BY u.prio DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN + v_max_prohibition_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + -- STEP 3: User-level prohibitions - direct user rules, update max if higher + FOR v_rule IN + SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio + FROM morbac.user_rules ur + WHERE ur.user_id = p_user_id + AND ur.org_id IS NOT DISTINCT FROM p_org_id + AND ur.modality = 'prohibition' + AND ur.activity = ANY (v_activities) + AND ur.view = ANY (v_views) + AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) + ORDER BY COALESCE(ur.priority, 0) DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN + v_max_prohibition_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + -- STEP 3.5: Global prohibitions + FOR v_rule IN + SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio + FROM morbac.global_rules gr + WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) + AND gr.modality = 'prohibition' + AND (gr.activity IS NULL OR gr.activity = ANY (v_activities)) + AND (gr.view IS NULL OR gr.view = ANY (v_views)) + AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) + ORDER BY COALESCE(gr.priority, 0) DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN + v_max_prohibition_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + END IF; -- v_is_system_principal + + -- STEP 4: Local permissions - find the highest-priority applicable one + FOR v_rule IN + SELECT u.context_id, u.prio + FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio) + WHERE u.modality = 'permission' + ORDER BY u.prio DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + v_max_permission_priority := v_rule.prio; + EXIT; + END IF; + END LOOP; + + -- STEP 5: Cross-org permissions - update max if higher found + FOR v_rule IN + SELECT u.context_id, u.prio + FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio) + WHERE u.modality = 'permission' + ORDER BY u.prio DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN + v_max_permission_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + -- STEP 6: User-level permissions - direct user rules, update max if higher + FOR v_rule IN + SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio + FROM morbac.user_rules ur + WHERE ur.user_id = p_user_id + AND ur.org_id IS NOT DISTINCT FROM p_org_id + AND ur.modality = 'permission' + AND ur.activity = ANY (v_activities) + AND ur.view = ANY (v_views) + AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) + ORDER BY COALESCE(ur.priority, 0) DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN + v_max_permission_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + -- STEP 6.5: Global permissions + FOR v_rule IN + SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio + FROM morbac.global_rules gr + WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) + AND gr.modality = 'permission' + AND (gr.activity IS NULL OR gr.activity = ANY (v_activities)) + AND (gr.view IS NULL OR gr.view = ANY (v_views)) + AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) + ORDER BY COALESCE(gr.priority, 0) DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN + v_max_permission_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + -- STEP 7: Priority resolution + IF v_max_prohibition_priority IS NULL THEN + RETURN v_max_permission_priority IS NOT NULL; + END IF; + + IF v_max_permission_priority IS NOT NULL + AND v_max_permission_priority > v_max_prohibition_priority THEN + RETURN TRUE; + END IF; + + RETURN FALSE; +END; +$$; + +COMMENT ON FUNCTION morbac.is_allowed_nocache(UUID, UUID, TEXT, TEXT) IS +'Authorization without cache - use for debugging or when cache must be bypassed'; + +CREATE OR REPLACE FUNCTION morbac.is_allowed( + p_user_id UUID, + p_org_id UUID, + p_activity TEXT, + p_view TEXT +) +RETURNS BOOLEAN +LANGUAGE plpgsql +VOLATILE +SECURITY DEFINER +AS $$ +DECLARE + v_cached_result BOOLEAN; + v_computed_result BOOLEAN; + v_expires_at TIMESTAMPTZ; +BEGIN + IF p_user_id IS NULL THEN + RETURN FALSE; + END IF; + + SELECT allowed, expires_at INTO v_cached_result, v_expires_at + FROM morbac.auth_cache + WHERE user_id = p_user_id + AND org_id = p_org_id + AND activity = p_activity + AND view = p_view + AND expires_at > CURRENT_TIMESTAMP; + + IF FOUND THEN + RETURN v_cached_result; + END IF; + + 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 ( + 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; +$$; + +COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS +'Complete OrBAC authorization with caching (default) - use is_allowed_nocache() for debugging'; diff --git a/tests/18_null_user.sql b/tests/18_null_user.sql new file mode 100644 index 0000000..9d8e0e2 --- /dev/null +++ b/tests/18_null_user.sql @@ -0,0 +1,29 @@ +-- ============================================================================= +-- NULL user Tests +-- ============================================================================= +-- is_allowed() and is_allowed_nocache() answer FALSE for a NULL user id, as +-- rls_check() does, instead of failing on the auth_cache NOT NULL constraint. +-- +-- Prerequisites: 00_setup.sql -> 17_is_allowed_equivalence.sql +-- ============================================================================= + +\echo '' +\echo '================================================================' +\echo '18 -- NULL USER' +\echo '================================================================' + +SELECT morbac.t('is_allowed NULL user, org', + morbac.is_allowed(NULL, '10000000-0000-0000-0000-000000000001'::uuid, 'read', 'documents'), + FALSE); + +SELECT morbac.t('is_allowed NULL user, no org', + morbac.is_allowed(NULL, NULL, 'read', 'documents'), + FALSE); + +SELECT morbac.t('is_allowed_nocache NULL user, org', + morbac.is_allowed_nocache(NULL, '10000000-0000-0000-0000-000000000001'::uuid, 'read', 'documents'), + FALSE); + +SELECT morbac.t('is_allowed NULL user writes nothing to auth_cache', + NOT EXISTS (SELECT 1 FROM morbac.auth_cache WHERE user_id IS NULL), + TRUE);