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
+3
View File
@@ -20,3 +20,6 @@ Thumbs.db
node_modules/ node_modules/
dist/ dist/
package-lock.json package-lock.json
# Upgrade scripts are sources, not build output
!src/upgrades/pgmorbac--*.sql
+17
View File
@@ -5,6 +5,22 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.1] - 2026-10-14
### Fixed
- `is_allowed_nocache()` (and so every uncached `is_allowed()`) resolved the
user's roles once per row of the whole `rules` table, calling
`get_comprehensive_roles()` and `org_in_scope()` for every rule of every
tenant: about 240 ms per decision 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 requested activity and view, and matching rules are read through those
(org, role) pairs; `org_in_scope()` runs once per (org, scope) pair. Cost no
longer grows with other tenants' rules. Decisions are unchanged, checked
against the 1.0.0 implementation on a randomized fixture
(`tests/17_is_allowed_equivalence.sql`).
- `tools/bump_version.sh` no longer rewrites the PGXN `meta-spec` version in
`META.json`.
## [1.0.0] - 2026-07-25 ## [1.0.0] - 2026-07-25
### Added ### Added
@@ -55,4 +71,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`rls_check(activity, view, row_org_id[, row_user_id])` for row-scoped tables `rls_check(activity, view, row_org_id[, row_user_id])` for row-scoped tables
where a NULL `row_org_id` means the record is unattributed where a NULL `row_org_id` means the record is unattributed
[1.0.1]: https://github.com/crudylabs/pgmorbac/releases/tag/v1.0.1
[1.0.0]: https://github.com/crudylabs/pgmorbac/releases/tag/v1.0.0 [1.0.0]: https://github.com/crudylabs/pgmorbac/releases/tag/v1.0.0
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "pgmorbac", "name": "pgmorbac",
"abstract": "Multi-OrBAC: Organization-Based Access Control with multi-organization support", "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.", "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.0", "version": "1.0.1",
"maintainer": [ "maintainer": [
"Marc VILLAIN <marc.villain@epita.fr>" "Marc VILLAIN <marc.villain@epita.fr>"
], ],
@@ -10,8 +10,8 @@
"provides": { "provides": {
"pgmorbac": { "pgmorbac": {
"abstract": "Multi-OrBAC access control extension", "abstract": "Multi-OrBAC access control extension",
"file": "pgmorbac--1.0.0.sql", "file": "pgmorbac--1.0.1.sql",
"version": "1.0.0" "version": "1.0.1"
} }
}, },
"prereqs": { "prereqs": {
+2 -2
View File
@@ -67,11 +67,11 @@ sudo ./tools/install.sh
```bash ```bash
# Build versioned file from source # Build versioned file from source
make build # Concatenates src/ files into pgmorbac--1.0.0.sql make build # Concatenates src/ files into pgmorbac--1.0.1.sql
# Copy files to PostgreSQL extension directory # Copy files to PostgreSQL extension directory
sudo cp pgmorbac.control $(pg_config --sharedir)/extension/ sudo cp pgmorbac.control $(pg_config --sharedir)/extension/
sudo cp pgmorbac--1.0.0.sql $(pg_config --sharedir)/extension/ sudo cp pgmorbac--1.0.1.sql $(pg_config --sharedir)/extension/
# Enable in PostgreSQL # Enable in PostgreSQL
psql -d mydb -c "CREATE EXTENSION pgmorbac;" psql -d mydb -c "CREATE EXTENSION pgmorbac;"
+1 -1
View File
@@ -1,6 +1,6 @@
# pgmorbac extension # pgmorbac extension
# Multi-OrBAC access control model for PostgreSQL # Multi-OrBAC access control model for PostgreSQL
comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support' comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support'
default_version = '1.0.0' default_version = '1.0.1'
relocatable = false relocatable = false
schema = morbac schema = morbac
+117 -44
View File
@@ -23,6 +23,13 @@
-- is a specific org, or NULL for unattributed objects. -- is a specific org, or NULL for unattributed objects.
-- - global_rules apply to every org, unattributed included. -- - global_rules apply to every org, unattributed included.
-- 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( CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
p_user_id UUID, p_user_id UUID,
p_org_id UUID, p_org_id UUID,
@@ -39,25 +46,106 @@ DECLARE
v_max_prohibition_priority INTEGER := NULL; v_max_prohibition_priority INTEGER := NULL;
v_max_permission_priority INTEGER := NULL; v_max_permission_priority INTEGER := NULL;
v_is_system_principal BOOLEAN; 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 BEGIN
v_is_system_principal := EXISTS ( v_is_system_principal := EXISTS (
SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id 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 -- STEPS 1-3.5: Prohibitions - skipped entirely for system principals
IF NOT v_is_system_principal THEN IF NOT v_is_system_principal THEN
-- STEP 1: Local prohibitions - find the highest-priority applicable one -- STEP 1: Local prohibitions - find the highest-priority applicable one
FOR v_rule IN FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.rules r FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio)
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope) WHERE u.modality = 'prohibition'
AND r.modality = 'prohibition' ORDER BY u.prio DESC
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
v_max_prohibition_priority := v_rule.prio; v_max_prohibition_priority := v_rule.prio;
@@ -67,15 +155,10 @@ BEGIN
-- STEP 2: Cross-org prohibitions - update max if a higher priority is found -- STEP 2: Cross-org prohibitions - update max if a higher priority is found
FOR v_rule IN FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.cross_org_rules cr FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio)
WHERE cr.target_org_id = p_org_id WHERE u.modality = 'prohibition'
AND cr.modality = 'prohibition' ORDER BY u.prio DESC
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN 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 IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
@@ -92,8 +175,8 @@ BEGIN
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition' AND ur.modality = 'prohibition'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND ur.activity = ANY (v_activities)
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND ur.view = ANY (v_views)
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC ORDER BY COALESCE(ur.priority, 0) DESC
LOOP LOOP
@@ -111,8 +194,8 @@ BEGIN
FROM morbac.global_rules gr FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'prohibition' AND gr.modality = 'prohibition'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))) AND (gr.activity IS NULL OR gr.activity = ANY (v_activities))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view))) AND (gr.view IS NULL OR gr.view = ANY (v_views))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC ORDER BY COALESCE(gr.priority, 0) DESC
LOOP LOOP
@@ -128,15 +211,10 @@ BEGIN
-- STEP 4: Local permissions - find the highest-priority applicable one -- STEP 4: Local permissions - find the highest-priority applicable one
FOR v_rule IN FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.rules r FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio)
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope) WHERE u.modality = 'permission'
AND r.modality = 'permission' ORDER BY u.prio DESC
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
v_max_permission_priority := v_rule.prio; v_max_permission_priority := v_rule.prio;
@@ -146,15 +224,10 @@ BEGIN
-- STEP 5: Cross-org permissions - update max if higher found -- STEP 5: Cross-org permissions - update max if higher found
FOR v_rule IN FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio SELECT u.context_id, u.prio
FROM morbac.cross_org_rules cr FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio)
WHERE cr.target_org_id = p_org_id WHERE u.modality = 'permission'
AND cr.modality = 'permission' ORDER BY u.prio DESC
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN 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 IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
@@ -171,8 +244,8 @@ BEGIN
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission' AND ur.modality = 'permission'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND ur.activity = ANY (v_activities)
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND ur.view = ANY (v_views)
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC ORDER BY COALESCE(ur.priority, 0) DESC
LOOP LOOP
@@ -190,8 +263,8 @@ BEGIN
FROM morbac.global_rules gr FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'permission' AND gr.modality = 'permission'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))) AND (gr.activity IS NULL OR gr.activity = ANY (v_activities))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view))) AND (gr.view IS NULL OR gr.view = ANY (v_views))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC ORDER BY COALESCE(gr.priority, 0) DESC
LOOP LOOP
+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';
+510
View File
@@ -0,0 +1,510 @@
-- =============================================================================
-- is_allowed_nocache() equivalence with the 1.0.0 implementation
-- =============================================================================
-- 1.0.1 resolves roles once per org instead of once per rule row. This file
-- keeps the 1.0.0 function as a reference and compares both on a seeded random
-- fixture: org forest, role hierarchy, negative assignments, delegations,
-- derived roles, activity/view hierarchies, every rule scope, priorities,
-- prohibitions, contexts that are false or NULL, validity windows, cross-org,
-- user and global rules, and a system principal holding roles.
--
-- It then adds rules for many unrelated tenants and checks that the cost of a
-- decision no longer follows the total rule count.
--
-- Prerequisites: 00_setup.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '17 - is_allowed_nocache EQUIVALENCE AND SCALING'
\echo '================================================================'
SET client_min_messages = error;
-- Reference: the 1.0.0 body, unchanged except for its name
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache_1_0_0(
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;
BEGIN
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
-- STEP 1: Local prohibitions - find the highest-priority applicable one
FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
FROM morbac.rules r
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope)
AND r.modality = 'prohibition'
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) 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 cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr
WHERE cr.target_org_id = p_org_id
AND cr.modality = 'prohibition'
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.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: 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 IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
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 IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
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 r.context_id, COALESCE(r.priority, 0) AS prio
FROM morbac.rules r
WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope)
AND r.modality = 'permission'
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) 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 cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr
WHERE cr.target_org_id = p_org_id
AND cr.modality = 'permission'
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.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: 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 IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
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 IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
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;
$$;
CREATE OR REPLACE FUNCTION morbac.eq_context_never() RETURNS BOOLEAN LANGUAGE sql STABLE AS 'SELECT FALSE';
CREATE OR REPLACE FUNCTION morbac.eq_context_null() RETURNS BOOLEAN LANGUAGE sql STABLE AS 'SELECT NULL::BOOLEAN';
CREATE OR REPLACE FUNCTION morbac.eq_derived(p_user_id UUID, p_org_id UUID) RETURNS BOOLEAN
LANGUAGE sql STABLE AS $$
SELECT get_byte(decode(md5(p_user_id::text || p_org_id::text), 'hex'), 0) % 3 = 0
$$;
INSERT INTO morbac.contexts (name, evaluator) VALUES
('eq_never', 'morbac.eq_context_never'::regproc),
('eq_null', 'morbac.eq_context_null'::regproc);
INSERT INTO morbac.activities (name) VALUES ('eq_read'), ('eq_write'), ('eq_admin');
INSERT INTO morbac.activity_hierarchy (senior_activity, junior_activity) VALUES
('eq_admin', 'eq_write'), ('eq_write', 'eq_read');
INSERT INTO morbac.views (name) VALUES ('eq_doc'), ('eq_secret'), ('eq_misc');
INSERT INTO morbac.view_hierarchy (senior_view, junior_view) VALUES ('eq_secret', 'eq_doc');
CREATE TABLE morbac.eq_users (n INT PRIMARY KEY, id UUID NOT NULL);
CREATE TABLE morbac.eq_orgs (n INT PRIMARY KEY, id UUID NOT NULL);
CREATE TABLE morbac.eq_roles (n INT PRIMARY KEY, id UUID NOT NULL);
DO $$
DECLARE
v_ctx_always UUID := (SELECT id FROM morbac.contexts WHERE name = 'always');
v_ctx_never UUID := (SELECT id FROM morbac.contexts WHERE name = 'eq_never');
v_ctx_null UUID := (SELECT id FROM morbac.contexts WHERE name = 'eq_null');
v_scopes TEXT[] := ARRAY['self','children','descendants','subtree','parent','ancestors','lineage','root','unattributed','all'];
v_acts TEXT[] := ARRAY['eq_read','eq_write','eq_admin'];
v_views TEXT[] := ARRAY['eq_doc','eq_secret','eq_misc'];
v_org UUID;
v_role UUID;
v_x DOUBLE PRECISION;
v_from TIMESTAMPTZ;
v_until TIMESTAMPTZ;
v_rec RECORD;
v_src INT;
v_dst INT;
BEGIN
PERFORM setseed(0.17);
-- Org forest: 12 orgs, each either a new root or the child of an earlier one
FOR i IN 0..11 LOOP
v_org := md5('eq_org_' || i)::uuid;
INSERT INTO morbac.orgs (id, name, parent_id) VALUES (
v_org, 'eq_org_' || i,
CASE WHEN i = 0 OR random() < 0.15 THEN NULL
ELSE md5('eq_org_' || floor(random() * i)::int)::uuid END
);
INSERT INTO morbac.eq_orgs VALUES (i, v_org);
END LOOP;
-- Roles, with an acyclic hierarchy (senior n -> junior m only when n < m)
FOR i IN 0..13 LOOP
v_role := md5('eq_role_' || i)::uuid;
INSERT INTO morbac.roles (id, org_id, name)
VALUES (v_role, md5('eq_org_' || floor(random() * 12)::int)::uuid, 'eq_role_' || i);
INSERT INTO morbac.eq_roles VALUES (i, v_role);
END LOOP;
FOR i IN 0..13 LOOP
FOR j IN (i + 1)..13 LOOP
IF random() < 0.12 THEN
INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id)
SELECT a.id, b.id FROM morbac.eq_roles a, morbac.eq_roles b WHERE a.n = i AND b.n = j;
END IF;
END LOOP;
END LOOP;
INSERT INTO morbac.derived_roles (role_id, condition_evaluator)
SELECT id, 'morbac.eq_derived'::regproc FROM morbac.eq_roles WHERE n IN (3, 11);
-- Users and their assignments: mostly in the role's own org, sometimes elsewhere
FOR i IN 0..10 LOOP
INSERT INTO morbac.eq_users VALUES (i, md5('eq_user_' || i)::uuid);
END LOOP;
FOR i IN 0..10 LOOP
FOR k IN 1..(1 + floor(random() * 3)::int) LOOP
v_role := md5('eq_role_' || floor(random() * 14)::int)::uuid;
v_org := (SELECT r.org_id FROM morbac.roles r WHERE r.id = v_role);
IF random() < 0.25 THEN
v_org := md5('eq_org_' || floor(random() * 12)::int)::uuid;
END IF;
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES ((SELECT id FROM morbac.eq_users WHERE n = i), v_role, v_org)
ON CONFLICT DO NOTHING;
END LOOP;
END LOOP;
-- Negative assignments: some cancel a held role, some target an inherited one
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id)
SELECT ur.user_id, ur.role_id, ur.org_id FROM morbac.user_roles ur
JOIN morbac.eq_users u ON u.id = ur.user_id
ORDER BY md5(ur.user_id::text || ur.role_id::text) LIMIT 3;
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id)
SELECT ur.user_id, rh.junior_role_id, ur.org_id FROM morbac.user_roles ur
JOIN morbac.eq_users u ON u.id = ur.user_id
JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ur.role_id
ORDER BY md5(ur.user_id::text || rh.junior_role_id::text) LIMIT 2
ON CONFLICT DO NOTHING;
-- Delegations: active, expired, future and revoked
FOR v_rec IN
SELECT ur.user_id, ur.role_id, ur.org_id, row_number() OVER (ORDER BY md5(ur.user_id::text || ur.role_id::text)) AS k
FROM morbac.user_roles ur JOIN morbac.eq_users u ON u.id = ur.user_id
LIMIT 8
LOOP
INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until, revoked)
SELECT v_rec.user_id, u.id, v_rec.role_id, v_rec.org_id,
CASE v_rec.k % 4 WHEN 1 THEN now() - interval '2 days' WHEN 2 THEN now() + interval '1 day' ELSE now() - interval '1 day' END,
CASE v_rec.k % 4 WHEN 1 THEN now() - interval '1 day' WHEN 2 THEN now() + interval '2 days' ELSE now() + interval '1 day' END,
v_rec.k % 4 = 3
FROM morbac.eq_users u
WHERE u.id <> v_rec.user_id
ORDER BY md5(u.id::text || v_rec.k::text) LIMIT 1;
END LOOP;
-- Role-bound rules
FOR i IN 1..450 LOOP
v_x := random();
v_from := NULL; v_until := NULL;
IF v_x < 0.08 THEN
v_from := now() - interval '2 days'; v_until := now() - interval '1 day';
ELSIF v_x < 0.16 THEN
v_from := now() + interval '1 day';
ELSIF v_x < 0.22 THEN
v_from := now() - interval '1 day'; v_until := now() + interval '1 day';
END IF;
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope, priority, valid_from, valid_until)
VALUES (
md5('eq_org_' || floor(random() * 12)::int)::uuid,
md5('eq_role_' || floor(random() * 14)::int)::uuid,
v_acts[1 + floor(random() * 3)::int],
v_views[1 + floor(random() * 3)::int],
CASE WHEN random() < 0.7 THEN v_ctx_always WHEN random() < 0.5 THEN v_ctx_never ELSE v_ctx_null END,
(CASE WHEN random() < 0.55 THEN 'permission' WHEN random() < 0.8 THEN 'prohibition' ELSE 'obligation' END)::morbac.modality,
v_scopes[1 + floor(random() * 10)::int],
CASE WHEN random() < 0.4 THEN NULL ELSE floor(random() * 5)::int END,
v_from, v_until
) ON CONFLICT DO NOTHING;
END LOOP;
FOR i IN 1..70 LOOP
v_src := floor(random() * 12)::int;
v_dst := floor(random() * 12)::int;
CONTINUE WHEN v_src = v_dst;
INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, context_id, modality, priority, valid_from)
VALUES (md5('eq_org_' || v_src)::uuid, md5('eq_org_' || v_dst)::uuid,
md5('eq_role_' || floor(random() * 14)::int)::uuid,
v_acts[1 + floor(random() * 3)::int],
v_views[1 + floor(random() * 3)::int],
CASE WHEN random() < 0.8 THEN v_ctx_always ELSE v_ctx_never END,
(CASE WHEN random() < 0.6 THEN 'permission' ELSE 'prohibition' END)::morbac.modality,
CASE WHEN random() < 0.4 THEN NULL ELSE floor(random() * 5)::int END,
CASE WHEN random() < 0.1 THEN now() + interval '1 day' END
) ON CONFLICT DO NOTHING;
END LOOP;
FOR i IN 1..60 LOOP
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, priority)
VALUES (
md5('eq_user_' || floor(random() * 10)::int)::uuid,
CASE WHEN random() < 0.2 THEN NULL ELSE md5('eq_org_' || floor(random() * 12)::int)::uuid END,
v_acts[1 + floor(random() * 3)::int],
v_views[1 + floor(random() * 3)::int],
CASE WHEN random() < 0.8 THEN v_ctx_always ELSE v_ctx_never END,
(CASE WHEN random() < 0.5 THEN 'permission' ELSE 'prohibition' END)::morbac.modality,
CASE WHEN random() < 0.4 THEN NULL ELSE floor(random() * 5)::int END
) ON CONFLICT DO NOTHING;
END LOOP;
FOR i IN 1..10 LOOP
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
CASE WHEN i <= 2 THEN NULL ELSE md5('eq_user_' || floor(random() * 10)::int)::uuid END,
CASE WHEN random() < 0.3 THEN NULL ELSE v_acts[1 + floor(random() * 3)::int] END,
CASE WHEN random() < 0.3 THEN NULL ELSE v_views[1 + floor(random() * 3)::int] END,
v_ctx_always,
(CASE WHEN i % 2 = 0 THEN 'permission' ELSE 'prohibition' END)::morbac.modality,
floor(random() * 5)::int
) ON CONFLICT DO NOTHING;
END LOOP;
-- User 10 holds roles, then becomes a system principal: its prohibitions are skipped
INSERT INTO morbac.system_principals (user_id, description)
SELECT id, 'eq system principal' FROM morbac.eq_users WHERE n = 10;
END;
$$;
CREATE TABLE morbac.eq_cases AS
SELECT u.id AS user_id, o.id AS org_id, a.activity, v.view
FROM morbac.eq_users u
CROSS JOIN (SELECT id FROM morbac.eq_orgs UNION ALL SELECT NULL::UUID) o
CROSS JOIN (VALUES ('eq_read'), ('eq_write'), ('eq_admin')) a(activity)
CROSS JOIN (VALUES ('eq_doc'), ('eq_secret'), ('eq_misc')) v(view);
CREATE TABLE morbac.eq_results AS
SELECT c.*,
morbac.is_allowed_nocache_1_0_0(c.user_id, c.org_id, c.activity, c.view) AS expected,
morbac.is_allowed_nocache(c.user_id, c.org_id, c.activity, c.view) AS actual
FROM morbac.eq_cases c;
SELECT CASE WHEN count(*) FILTER (WHERE expected IS DISTINCT FROM actual) = 0
THEN 'CHECK PASS: ' || count(*) || ' decisions identical to 1.0.0'
ELSE 'CHECK FAIL: ' || count(*) FILTER (WHERE expected IS DISTINCT FROM actual) || ' of ' || count(*) || ' decisions differ from 1.0.0'
END
FROM morbac.eq_results;
SELECT morbac.t('fixture yields both allowed and denied decisions',
(SELECT count(*) FILTER (WHERE expected) >= 50 AND count(*) FILTER (WHERE NOT expected) >= 50 FROM morbac.eq_results), TRUE);
SELECT morbac.t('fixture rules cover every scope',
(SELECT count(DISTINCT r.scope) FROM morbac.rules r JOIN morbac.eq_orgs o ON o.id = r.org_id) = 10, TRUE);
SELECT morbac.t('system principal is allowed somewhere',
(SELECT bool_or(actual) FROM morbac.eq_results r JOIN morbac.eq_users u ON u.id = r.user_id WHERE u.n = 10), TRUE);
-- Scaling: 60 unrelated tenants, each with its own roles and ~40 rules on the
-- same activities and views. The fixture users hold no role there.
DO $$
DECLARE
v_org UUID;
v_role UUID;
v_ctx UUID := (SELECT id FROM morbac.contexts WHERE name = 'always');
BEGIN
FOR t IN 1..60 LOOP
v_org := gen_random_uuid();
v_role := gen_random_uuid();
INSERT INTO morbac.orgs (id, name) VALUES (v_org, 'eq_tenant_' || t);
INSERT INTO morbac.roles (id, org_id, name) VALUES (v_role, v_org, 'tenant_role');
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES (gen_random_uuid(), v_role, v_org);
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT v_org, v_role, a, v, v_ctx, 'permission', s
FROM unnest(ARRAY['eq_read','eq_write','eq_admin']) a,
unnest(ARRAY['eq_doc','eq_secret','eq_misc']) v,
unnest(ARRAY['self','subtree','children','lineage']) s;
END LOOP;
END;
$$;
SELECT CASE WHEN count(*) FILTER (WHERE r.expected IS DISTINCT FROM morbac.is_allowed_nocache(r.user_id, r.org_id, r.activity, r.view)) = 0
THEN 'CHECK PASS: decisions unchanged after adding unrelated tenants'
ELSE 'CHECK FAIL: unrelated tenants changed ' || count(*) FILTER (WHERE r.expected IS DISTINCT FROM morbac.is_allowed_nocache(r.user_id, r.org_id, r.activity, r.view)) || ' decisions'
END
FROM morbac.eq_results r;
-- Old and new on the same sample after the tenants were added. The 1.0.0 cost
-- grows with every rule in the table; the new one must stay well below it.
CREATE TABLE morbac.eq_sample AS
SELECT * FROM morbac.eq_results ORDER BY md5(user_id::text || COALESCE(org_id::text, '') || activity || view) LIMIT 8;
DO $$
DECLARE
v_rec RECORD;
v_t0 TIMESTAMPTZ;
v_old INTERVAL;
v_new INTERVAL;
v_ok BOOLEAN := TRUE;
BEGIN
v_t0 := clock_timestamp();
FOR v_rec IN SELECT * FROM morbac.eq_sample LOOP
IF morbac.is_allowed_nocache_1_0_0(v_rec.user_id, v_rec.org_id, v_rec.activity, v_rec.view) IS DISTINCT FROM v_rec.expected THEN
v_ok := FALSE;
END IF;
END LOOP;
v_old := clock_timestamp() - v_t0;
v_t0 := clock_timestamp();
FOR i IN 1..10 LOOP
FOR v_rec IN SELECT * FROM morbac.eq_sample LOOP
IF morbac.is_allowed_nocache(v_rec.user_id, v_rec.org_id, v_rec.activity, v_rec.view) IS DISTINCT FROM v_rec.expected THEN
v_ok := FALSE;
END IF;
END LOOP;
END LOOP;
v_new := (clock_timestamp() - v_t0) / 10;
CREATE TEMP TABLE eq_timing AS SELECT v_old AS old_cost, v_new AS new_cost, v_ok AS ok;
END;
$$;
SELECT morbac.t('1.0.0 reference still agrees after adding tenants', (SELECT ok FROM eq_timing), TRUE);
SELECT morbac.t('decision cost is at least 10x below 1.0.0 with ' || (SELECT count(*) FROM morbac.rules) || ' rules (1.0.0 '
|| (SELECT round(extract(epoch FROM old_cost) * 1000 / 8, 2) FROM eq_timing) || ' ms, now '
|| (SELECT round(extract(epoch FROM new_cost) * 1000 / 8, 2) FROM eq_timing) || ' ms per call)',
(SELECT new_cost * 10 < old_cost FROM eq_timing), TRUE);
DROP TABLE eq_timing;
DROP TABLE morbac.eq_sample, morbac.eq_results, morbac.eq_cases, morbac.eq_roles, morbac.eq_orgs, morbac.eq_users;
DROP FUNCTION morbac.is_allowed_nocache_1_0_0(UUID, UUID, TEXT, TEXT);
RESET client_min_messages;
\echo ''
\echo '=== Equivalence Tests Completed ==='
+2 -1
View File
@@ -40,8 +40,9 @@ sed -i.bak "s/default_version = '$OLD_VERSION'/default_version = '$NEW_VERSION'/
echo " Updated $CONTROL_FILE" echo " Updated $CONTROL_FILE"
# --- META.json --- # --- META.json ---
# The meta-spec block carries the PGXN spec version, not ours
sed -i.bak \ sed -i.bak \
-e "s/\"version\": \"$OLD_VERSION\"/\"version\": \"$NEW_VERSION\"/g" \ -e "/\"meta-spec\"/,/}/!s/\"version\": \"$OLD_VERSION\"/\"version\": \"$NEW_VERSION\"/g" \
-e "s/pgmorbac--$OLD_VERSION\.sql/pgmorbac--$NEW_VERSION.sql/g" \ -e "s/pgmorbac--$OLD_VERSION\.sql/pgmorbac--$NEW_VERSION.sql/g" \
"$META_FILE" "$META_FILE"
echo " Updated $META_FILE" echo " Updated $META_FILE"