feat(hirearchy): add helpers

This commit is contained in:
2026-03-22 23:41:05 +01:00
parent c22d18063b
commit 10c1ea4a7e
3 changed files with 317 additions and 15 deletions
+70 -12
View File
@@ -322,21 +322,72 @@ SELECT * FROM morbac.compile_policy();
### Organization Rule Scope ### 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.
| 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 |
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:
- 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 ```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) INSERT INTO morbac.rules (org_id, role_id, activity, view, modality)
SELECT descendant_id, :role_id, :activity, :view, 'permission' SELECT org_id, :role_id, 'read', 'financials', 'permission'
FROM morbac.get_org_descendants(:org_id) FROM morbac.get_org_scope(:holding_org_id, 'subtree');
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`.
See API Reference for details on these helper functions and tables. **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 ### Hierarchies
@@ -613,9 +664,16 @@ SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
### Hierarchy Functions ### 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). **`get_inherited_roles(role_id)`**: Returns all junior roles (transitive).
+86
View File
@@ -52,6 +52,92 @@ $$;
COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS
'Returns all descendant organizations (including self) with depth in hierarchy'; '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) -- 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) CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER) RETURNS TABLE(role_id UUID, depth INTEGER)
+158
View File
@@ -361,5 +361,163 @@ SELECT morbac.t('Dave (GlobalTech employee) reads Engineering docs [no cross-org
'read', 'documents' 'read', 'documents'
), FALSE); ), 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 ''
\echo '=== Hierarchy Tests Completed ===' \echo '=== Hierarchy Tests Completed ==='