fix: is_allowed_nocache resolves roles once per org, not per rule
release / release (push) Canceled after 0s

Steps 1, 2, 4 and 5 called get_comprehensive_roles() for every row of
morbac.rules (and org_in_scope() per row), so a decision cost grew with
every tenant's rules: ~240 ms with 2376 rules across 27 orgs. Roles are now
resolved once per org where the user can hold one and that has a rule for
the request, rules are read through those (org, role) pairs, and
org_in_scope() runs once per (org, scope) pair. Decisions are unchanged.

Release 1.0.1 with the 1.0.0 -> 1.0.1 upgrade script. Test 17 compares
every decision with the 1.0.0 body on a seeded random fixture and checks
the cost no longer follows unrelated tenants' rules. bump_version.sh no
longer rewrites the PGXN meta-spec version.
This commit is contained in:
2026-10-14 23:41:00 +02:00
parent 3e8380d4f7
commit 1cf23c37ce
9 changed files with 931 additions and 51 deletions
+276
View File
@@ -0,0 +1,276 @@
-- pgmorbac 1.0.0 -> 1.0.1
--
-- is_allowed_nocache() resolved the user's roles once per row of the whole
-- rules table, so its cost grew with every tenant's rules. It now resolves
-- them once per org where the user can hold a role, before reading rules.
-- Decisions are unchanged.
-- Role-bound rules (rules, cross_org_rules) apply only in orgs where the user
-- holds a role. Those roles are resolved once per org that has a direct
-- assignment, delegation or derived role for the user and a rule matching the
-- request, then the matching rules are read through that (org, role) set.
-- Resolving roles per rule row instead made the cost grow with every rule of
-- every tenant.
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
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';