diff --git a/.gitignore b/.gitignore index 96e5bd6..c2b32d1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ Thumbs.db node_modules/ dist/ package-lock.json + +# Upgrade scripts are sources, not build output +!src/upgrades/pgmorbac--*.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index b64131f..5d8c537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### 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 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 diff --git a/META.json b/META.json index 9fa4910..93157b1 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.0", + "version": "1.0.1", "maintainer": [ "Marc VILLAIN " ], @@ -10,8 +10,8 @@ "provides": { "pgmorbac": { "abstract": "Multi-OrBAC access control extension", - "file": "pgmorbac--1.0.0.sql", - "version": "1.0.0" + "file": "pgmorbac--1.0.1.sql", + "version": "1.0.1" } }, "prereqs": { diff --git a/README.md b/README.md index 1e4de56..60f14ba 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.0.sql +make build # Concatenates src/ files into pgmorbac--1.0.1.sql # Copy files to PostgreSQL extension directory 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 psql -d mydb -c "CREATE EXTENSION pgmorbac;" diff --git a/pgmorbac.control b/pgmorbac.control index 0f32249..ea8a555 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.0' +default_version = '1.0.1' relocatable = false schema = morbac diff --git a/src/authorization.sql b/src/authorization.sql index a5fa2a1..e73b17f 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -23,6 +23,13 @@ -- is a specific org, or NULL for unattributed objects. -- - 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( p_user_id UUID, p_org_id UUID, @@ -39,25 +46,106 @@ DECLARE 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 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 + 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; @@ -67,15 +155,10 @@ BEGIN -- 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 + 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 @@ -92,8 +175,8 @@ BEGIN 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 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 @@ -111,8 +194,8 @@ BEGIN 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 (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 @@ -128,15 +211,10 @@ BEGIN -- 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 + 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; @@ -146,15 +224,10 @@ BEGIN -- 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 + 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 @@ -171,8 +244,8 @@ BEGIN 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 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 @@ -190,8 +263,8 @@ BEGIN 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 (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 diff --git a/src/upgrades/pgmorbac--1.0.0--1.0.1.sql b/src/upgrades/pgmorbac--1.0.0--1.0.1.sql new file mode 100644 index 0000000..31f419d --- /dev/null +++ b/src/upgrades/pgmorbac--1.0.0--1.0.1.sql @@ -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'; diff --git a/tests/17_is_allowed_equivalence.sql b/tests/17_is_allowed_equivalence.sql new file mode 100644 index 0000000..f8298c4 --- /dev/null +++ b/tests/17_is_allowed_equivalence.sql @@ -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 ===' diff --git a/tools/bump_version.sh b/tools/bump_version.sh index a4769e6..13faa14 100755 --- a/tools/bump_version.sh +++ b/tools/bump_version.sh @@ -40,8 +40,9 @@ sed -i.bak "s/default_version = '$OLD_VERSION'/default_version = '$NEW_VERSION'/ echo " Updated $CONTROL_FILE" # --- META.json --- +# The meta-spec block carries the PGXN spec version, not ours 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" \ "$META_FILE" echo " Updated $META_FILE"