fix: is_allowed_nocache resolves roles once per org, not per rule
release / release (push) Canceled after 0s
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:
@@ -20,3 +20,6 @@ Thumbs.db
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
|
||||
# Upgrade scripts are sources, not build output
|
||||
!src/upgrades/pgmorbac--*.sql
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <marc.villain@epita.fr>"
|
||||
],
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;"
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+117
-44
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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 ==='
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user