From 10c1ea4a7ec51b1e9ef73161ab9f300e30ffb082 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Sun, 22 Mar 2026 23:41:05 +0100 Subject: [PATCH] feat(hirearchy): add helpers --- docs/DOCUMENTATION.md | 88 ++++++++++++++++---- src/hierarchy_functions.sql | 86 ++++++++++++++++++++ tests/02_hierarchies.sql | 158 ++++++++++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 15 deletions(-) diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index d2e396e..3d4756c 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -322,21 +322,72 @@ SELECT * FROM morbac.compile_policy(); ### Organization Rule Scope -Rules in pgmorbac are always scoped to an organization (`org_id`). +Rules in pgmorbac are always scoped to a single organization (`org_id`). To apply a rule across multiple organizations in a hierarchy, use `get_org_scope(org_id, scope)`, which returns a set of `(org_id, depth)` rows for the named scope relative to the given org. -- To apply a rule only to a specific org, insert it with that `org_id`. -- To apply a rule to all descendants, use `get_org_descendants(org_id)`: - ```sql - INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) - SELECT descendant_id, :role_id, :activity, :view, 'permission' - FROM morbac.get_org_descendants(:org_id) - UNION ALL - SELECT :org_id, :role_id, :activity, :view, 'permission'; - ``` -- To reference parent orgs, use `get_org_ancestors(org_id)`. -- For cross-organization permissions or admin delegation, use `cross_org_rules` and `admin_rules`. +| Scope | Returns | +|---|---| +| `'self'` | The org itself only | +| `'children'` | Direct children only (depth = 1) | +| `'descendants'` | All descendants, excluding self | +| `'subtree'` | Self + all descendants | +| `'parent'` | Direct parent only | +| `'ancestors'` | All ancestors, excluding self | +| `'lineage'` | Self + all ancestors | +| `'root'` | Topmost ancestor only | -See API Reference for details on these helper functions and tables. +An optional third argument `p_max_depth` limits how many levels are traversed. + +**Example: Entire subtree (org + all subsidiaries)** + +A holding company grants its auditor role read access across all subsidiaries: + +```sql +INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) +SELECT org_id, :role_id, 'read', 'financials', 'permission' +FROM morbac.get_org_scope(:holding_org_id, 'subtree'); +``` + +**Example: Direct children only** + +A regional manager role applies only to first-level divisions, not deeper sub-divisions: + +```sql +INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) +SELECT org_id, :role_id, 'manage', 'teams', 'permission' +FROM morbac.get_org_scope(:region_org_id, 'children'); +``` + +**Example: Two levels deep** + +A policy that covers a org, its direct children, and their children: + +```sql +INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) +SELECT org_id, :role_id, 'read', 'documents', 'permission' +FROM morbac.get_org_scope(:org_id, 'subtree', 2); +``` + +**Example: Root org only** + +A compliance rule attached to the top-level org, regardless of where you start: + +```sql +INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) +SELECT org_id, :role_id, 'audit', 'all_data', 'permission' +FROM morbac.get_org_scope(:any_child_org_id, 'root'); +``` + +**Example: All ancestors (upward propagation)** + +A report created in a child org becomes visible to all parent orgs: + +```sql +INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) +SELECT org_id, :role_id, 'read', 'reports', 'permission' +FROM morbac.get_org_scope(:child_org_id, 'ancestors'); +``` + +For cross-organization access between unrelated orgs, use `cross_org_rules` and `admin_rules`. ### Hierarchies @@ -613,9 +664,16 @@ SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents'); ### Hierarchy Functions -**`get_org_ancestors(org_id)`**: Returns all parent organizations with depth. +**`get_org_ancestors(org_id)`**: Returns all parent organizations with depth (including self at depth 0). -**`get_org_descendants(org_id)`**: Returns all child organizations with depth. +**`get_org_descendants(org_id)`**: Returns all child organizations with depth (including self at depth 0). + +**`get_org_scope(org_id, scope, max_depth?)`**: Returns a named set of organizations relative to `org_id`. Scope values: `self`, `children`, `descendants`, `subtree`, `parent`, `ancestors`, `lineage`, `root`. Optional `max_depth` limits traversal depth. + +```sql +-- All orgs in the subtree, up to 2 levels deep +SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2); +``` **`get_inherited_roles(role_id)`**: Returns all junior roles (transitive). diff --git a/src/hierarchy_functions.sql b/src/hierarchy_functions.sql index 7b944e6..68dd7d8 100644 --- a/src/hierarchy_functions.sql +++ b/src/hierarchy_functions.sql @@ -52,6 +52,92 @@ $$; COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS 'Returns all descendant organizations (including self) with depth in hierarchy'; +-- Get a named scope of organizations relative to a given org +-- +-- Supported scopes: +-- 'self' — the org itself only (depth = 0) +-- 'children' — direct children only (descendants at depth = 1) +-- 'descendants' — all descendants, excluding self (depth > 0) +-- 'subtree' — self + all descendants (equivalent to get_org_descendants) +-- 'parent' — direct parent only (ancestor at depth = 1) +-- 'ancestors' — all ancestors, excluding self (depth > 0) +-- 'lineage' — self + all ancestors (equivalent to get_org_ancestors) +-- 'root' — topmost ancestor only (max depth ancestor) +-- +-- Optional p_max_depth limits how many levels are traversed (NULL = unlimited). +-- For upward scopes (parent/ancestors/lineage/root) depth counts steps toward root. +-- For downward scopes (children/descendants/subtree) depth counts steps toward leaves. +CREATE OR REPLACE FUNCTION morbac.get_org_scope( + p_org_id UUID, + p_scope TEXT, + p_max_depth INTEGER DEFAULT NULL +) +RETURNS TABLE(org_id UUID, depth INTEGER) +LANGUAGE plpgsql +STABLE +AS $$ +BEGIN + CASE p_scope + WHEN 'self' THEN + RETURN QUERY + SELECT p_org_id, 0; + + WHEN 'children' THEN + RETURN QUERY + SELECT d.org_id, d.depth + FROM morbac.get_org_descendants(p_org_id) d + WHERE d.depth = 1 + AND (p_max_depth IS NULL OR d.depth <= p_max_depth); + + WHEN 'descendants' THEN + RETURN QUERY + SELECT d.org_id, d.depth + FROM morbac.get_org_descendants(p_org_id) d + WHERE d.depth > 0 + AND (p_max_depth IS NULL OR d.depth <= p_max_depth); + + WHEN 'subtree' THEN + RETURN QUERY + SELECT d.org_id, d.depth + FROM morbac.get_org_descendants(p_org_id) d + WHERE (p_max_depth IS NULL OR d.depth <= p_max_depth); + + WHEN 'parent' THEN + RETURN QUERY + SELECT a.org_id, a.depth + FROM morbac.get_org_ancestors(p_org_id) a + WHERE a.depth = 1 + AND (p_max_depth IS NULL OR a.depth <= p_max_depth); + + WHEN 'ancestors' THEN + RETURN QUERY + SELECT a.org_id, a.depth + FROM morbac.get_org_ancestors(p_org_id) a + WHERE a.depth > 0 + AND (p_max_depth IS NULL OR a.depth <= p_max_depth); + + WHEN 'lineage' THEN + RETURN QUERY + SELECT a.org_id, a.depth + FROM morbac.get_org_ancestors(p_org_id) a + WHERE (p_max_depth IS NULL OR a.depth <= p_max_depth); + + WHEN 'root' THEN + RETURN QUERY + SELECT a.org_id, a.depth + FROM morbac.get_org_ancestors(p_org_id) a + ORDER BY a.depth DESC + LIMIT 1; + + ELSE + RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root', p_scope; + END CASE; +END; +$$; + +COMMENT ON FUNCTION morbac.get_org_scope(UUID, TEXT, INTEGER) IS +'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root. Optional p_max_depth limits traversal depth.'; + -- Get all roles a user effectively has (direct + inherited via role hierarchy) CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id UUID) RETURNS TABLE(role_id UUID, depth INTEGER) diff --git a/tests/02_hierarchies.sql b/tests/02_hierarchies.sql index d901bf9..d2165f7 100644 --- a/tests/02_hierarchies.sql +++ b/tests/02_hierarchies.sql @@ -361,5 +361,163 @@ SELECT morbac.t('Dave (GlobalTech employee) reads Engineering docs [no cross-org 'read', 'documents' ), FALSE); +-- --------------------------------------------------------------------------- +-- Section 6: get_org_scope — named scope helper +-- --------------------------------------------------------------------------- +-- Org tree used in tests: +-- GlobalTech HQ (root) id: 10000000-0000-0000-0000-000000000001 +-- ├── Engineering Dept id: 10000000-0000-0000-0000-000000000002 +-- └── Sales Dept id: 10000000-0000-0000-0000-000000000003 +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. get_org_scope ---' + +-- 'self' — always returns exactly the org itself +SELECT morbac.t_eq('scope self (root) returns 1 row', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'self'))::bigint, 1); + +SELECT morbac.t_eq('scope self (leaf) returns 1 row', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'self'))::bigint, 1); + +SELECT morbac.t('scope self returns the org itself at depth 0', + EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'self') + WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 0 + ), TRUE); + +-- 'children' — direct children only (depth = 1 descendants) +SELECT morbac.t_eq('scope children of root returns 2 rows (Engineering + Sales)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'children'))::bigint, 2); + +SELECT morbac.t_eq('scope children of leaf returns 0 rows', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'children'))::bigint, 0); + +SELECT morbac.t('scope children does not include self (root)', + NOT EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'children') + WHERE org_id = '10000000-0000-0000-0000-000000000001' + ), TRUE); + +SELECT morbac.t('scope children includes Engineering at depth 1', + EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'children') + WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 1 + ), TRUE); + +-- 'descendants' — all descendants excluding self +SELECT morbac.t_eq('scope descendants of root returns 2 rows (Engineering + Sales, no self)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'descendants'))::bigint, 2); + +SELECT morbac.t('scope descendants does not include self', + NOT EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'descendants') + WHERE org_id = '10000000-0000-0000-0000-000000000001' + ), TRUE); + +SELECT morbac.t_eq('scope descendants of leaf returns 0 rows', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'descendants'))::bigint, 0); + +-- 'subtree' — self + all descendants +SELECT morbac.t_eq('scope subtree of root returns 3 rows (self + Engineering + Sales)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree'))::bigint, 3); + +SELECT morbac.t('scope subtree includes self at depth 0', + EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree') + WHERE org_id = '10000000-0000-0000-0000-000000000001' AND depth = 0 + ), TRUE); + +SELECT morbac.t_eq('scope subtree of leaf returns 1 row (self only)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'subtree'))::bigint, 1); + +-- 'parent' — direct parent only +SELECT morbac.t_eq('scope parent of Engineering returns 1 row (GlobalTech HQ)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'parent'))::bigint, 1); + +SELECT morbac.t('scope parent of Engineering returns GlobalTech HQ at depth 1', + EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'parent') + WHERE org_id = '10000000-0000-0000-0000-000000000001' AND depth = 1 + ), TRUE); + +SELECT morbac.t_eq('scope parent of root returns 0 rows (no parent)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'parent'))::bigint, 0); + +-- 'ancestors' — all ancestors excluding self +SELECT morbac.t_eq('scope ancestors of Engineering returns 1 row (GlobalTech HQ only)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'ancestors'))::bigint, 1); + +SELECT morbac.t('scope ancestors does not include self', + NOT EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'ancestors') + WHERE org_id = '10000000-0000-0000-0000-000000000002' + ), TRUE); + +SELECT morbac.t_eq('scope ancestors of root returns 0 rows', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'ancestors'))::bigint, 0); + +-- 'lineage' — self + all ancestors +SELECT morbac.t_eq('scope lineage of Engineering returns 2 rows (self + GlobalTech HQ)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'lineage'))::bigint, 2); + +SELECT morbac.t('scope lineage includes self at depth 0', + EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'lineage') + WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 0 + ), TRUE); + +SELECT morbac.t_eq('scope lineage of root returns 1 row (self only)', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'lineage'))::bigint, 1); + +-- 'root' — topmost ancestor only +SELECT morbac.t_eq('scope root of Engineering returns 1 row', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'root'))::bigint, 1); + +SELECT morbac.t('scope root of Engineering returns GlobalTech HQ', + EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'root') + WHERE org_id = '10000000-0000-0000-0000-000000000001' + ), TRUE); + +SELECT morbac.t_eq('scope root of root returns the root itself', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'root'))::bigint, 1); + +SELECT morbac.t('scope root of root returns the org itself', + EXISTS( + SELECT 1 FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'root') + WHERE org_id = '10000000-0000-0000-0000-000000000001' + ), TRUE); + +-- p_max_depth — depth limiting +SELECT morbac.t_eq('scope subtree max_depth=0 returns only self', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree', 0))::bigint, 1); + +SELECT morbac.t_eq('scope subtree max_depth=1 returns self + direct children', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree', 1))::bigint, 3); + +SELECT morbac.t_eq('scope descendants max_depth=1 returns only direct children', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'descendants', 1))::bigint, 2); + +SELECT morbac.t_eq('scope lineage max_depth=0 returns only self', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'lineage', 0))::bigint, 1); + +SELECT morbac.t_eq('scope ancestors max_depth=1 returns only direct parent', + (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'ancestors', 1))::bigint, 1); + +-- Unknown scope raises an error +DO $$ +BEGIN + PERFORM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'invalid_scope'); + RAISE EXCEPTION 'CHECK FAIL: unknown scope should have raised an error'; +EXCEPTION + WHEN OTHERS THEN + IF SQLERRM LIKE '%unknown scope%' THEN + RAISE NOTICE 'CHECK PASS: unknown scope raises error as expected'; + ELSE + RAISE EXCEPTION 'CHECK FAIL: unexpected error: %', SQLERRM; + END IF; +END; +$$; + \echo '' \echo '=== Hierarchy Tests Completed ==='