Organizations
The org tree, ancestors, descendants, and scope resolution.
Organizations are the backbone of Multi-OrBAC. morbac.orgs is a tree:
every org has a unique name, an optional parent_id, and free-form JSONB
metadata. Deleting an org cascades to its subtree, its roles and its rules.
INSERT INTO morbac.orgs (name) VALUES ('Groupe Hospitalier Valmont');
INSERT INTO morbac.orgs (name, parent_id)
SELECT 'Clinique Valmont Nord', id FROM morbac.orgs WHERE name = 'Groupe Hospitalier Valmont';
INSERT INTO morbac.orgs (name, parent_id)
SELECT 'Pharmacie Valmont Nord', id FROM morbac.orgs WHERE name = 'Clinique Valmont Nord';Traversal functions
| Function | Returns |
|---|---|
get_org_ancestors(org) | the org and every ancestor, with depth (0 = self) |
get_org_descendants(org) | the org and every descendant, with depth |
get_org_scope(org, scope, max_depth) | a named slice of the tree (below) |
SELECT o.name, s.depth
FROM morbac.get_org_scope(
(SELECT id FROM morbac.orgs WHERE name = 'Clinique Valmont Nord'),
'subtree', NULL) s
JOIN morbac.orgs o ON o.id = s.org_id;
-- Clinique Valmont Nord (0), Pharmacie Valmont Nord (1), Cardiologie Valmont Nord (1)Scope names
Rules carry a scope that is resolved against the tree at query time, so new
organizations are covered the moment they are created:
| Scope | Meaning |
|---|---|
self | exactly this org (default) |
children | direct children only |
descendants | all descendants, excluding self |
subtree | self plus all descendants |
parent | direct parent only |
ancestors | all ancestors, excluding self |
lineage | self plus all ancestors |
root | the topmost ancestor only |
unattributed | objects with no org at all |
all | every org, unattributed objects included |
A rule written at Groupe Hospitalier Valmont with scope subtree applies to both
hospitals, every department, and any clinic acquired next year, with no
further writes. See the org hierarchies recipe.
The last two are not positions in the tree; they are covered next.
Records with no organization
Some records belong to no organization. An import still in flight, an inbound
webhook, a patient mid-admission, a catalog entry that will never have a
tenant: their org_id is NULL. pgmorbac calls these unattributed, and they
are a first-class target rather than a gap in the model.
NULL in the org dimension always means exactly this. It never means "any org" and never means "all orgs".
That gives every rule a choice of three org targets, and the vocabulary is the same whichever kind of rule you write:
| Target | Role rule (morbac.rules) | User rule (no role) |
|---|---|---|
| A specific organization | scope = self, subtree, ... | org_id = that org |
| Records with no org | scope = unattributed | org_id = NULL |
| Every org, no-org included | scope = all | use global rules |
The first two are partitioned. An unattributed rule can never reach a
record that has an org, and a tree scope can never reach one that does not.
Only all and global rules deliberately span both.
Granting the unassigned pile
The declaring organization stays the policy authority and the role is still held there; only the records it reaches are the org-less ones.
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT o.id, r.id, 'read', 'patients', (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission', 'unattributed'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'nurse'
WHERE o.name = 'Clinique Valmont Nord';Grant, revoke and delegate that role exactly as usual: access to the pile follows the role, and nothing org-wide is handed over with it. Several organizations may each declare their own policy over the same pool, which is ordinary Multi-OrBAC: independent authorities over one shared object space.
Prohibitions, priorities, contexts, validity windows, role hierarchy, delegation, derived roles, negative assignments and separation of duty all apply unchanged, because unattributed rules flow through the same steps of the decision algorithm.
To grant one person without a role, use a user rule with no org:
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
SELECT :julien, NULL, 'read', 'patients', c.id, 'permission'
FROM morbac.contexts c WHERE c.name = 'always';Checking one record means passing its org, or NULL when it has none:
SELECT morbac.is_allowed(:user, :org_id, 'read', 'patients'); -- in an org
SELECT morbac.is_allowed(:user, NULL, 'read', 'patients'); -- unattributedChoosing which unattributed records a query returns is a separate question, answered by the session variables in row-level security.
Two kinds of no-org
The database cannot tell "should have an org and does not yet" from "will never
have one". Both are NULL, and the unattributed target reaches both alike. If
they must be granted separately, keep an explicit state column and give each
kind its own view; the view dimension separates them
while the org dimension stays unattributed.
Design guidance
- Model authority boundaries, not org charts. If Pharmacie Valmont Nord never needs its own roles or rules, it does not need to be an org.
- Never invent an "Unassigned" organization to stand in for a missing one.
Records from different tenants would share it, so a triage role there would
see across customers, and it contradicts the principle that organizations are
real places. The
unattributedtarget exists so no such placeholder is needed. - Org names are globally unique. Use qualified names (
Pharmacie Valmont Nord, notPharmacy) rather than relying on the tree for disambiguation. metadatais yours: store display labels, external ids, feature flags. The engine never reads it.