feat(scope): unattributed and all org targets

Adds a first-class org target vocabulary shared by every rule kind: a
specific organization, unattributed (objects whose org is NULL), or all.
A role can now be granted the unassigned pile without a global rule.

- rules.scope gains 'unattributed' and 'all'
- user_rules.org_id accepts NULL to target unattributed objects
- org_in_scope partitions the classes: 'unattributed' matches only a NULL
  target, tree scopes never match one
- has_permission(user, activity, view) capability probe for UI gating
- current_org_filter() parses morbac.org_ids once into org UUIDs plus the
  unattributed-bucket flag (a JSON null element requests it)
- rls_check split by arity so NULL never carries two meanings:
  rls_check(activity, view) for tables with no org column,
  rls_check(activity, view, row_org_id[, row_user_id]) where a NULL
  row_org_id means the record is unattributed
- detect_rule_conflicts is scope-aware, so rules targeting different
  object sets no longer collide

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:35:03 +02:00
parent 6dd1026c5a
commit fa7567f5f0
11 changed files with 630 additions and 72 deletions
+127 -6
View File
@@ -385,6 +385,8 @@ Every rule has a `scope` column (default `'self'`) that controls which organizat
| `'ancestors'` | All ancestors, excluding the rule's org itself |
| `'lineage'` | The rule's org + all ancestors |
| `'root'` | Topmost ancestor of the rule's org |
| `'unattributed'` | Objects with **no** org (`org_id IS NULL`) only |
| `'all'` | Every organization, unattributed objects included |
**Example: Analyst reads reports across the whole company**
@@ -414,6 +416,106 @@ WHERE o.name = 'EMEA Region';
**`get_org_scope(org_id, scope, max_depth?)`** is the underlying helper — it returns `(org_id, depth)` rows and can be used directly when you need to iterate over an org set. An optional `p_max_depth` limits traversal depth.
### Unattributed (no-org) Objects
An object whose `org_id` is `NULL` is **unattributed**: it belongs to no organization, typically because it is awaiting attribution. This is the only meaning `NULL` carries in the org dimension — it never means "any org" and never means "all orgs".
#### The org target vocabulary
Every rule kind selects its target the same way. There are exactly three targets:
| Target | Role-based (`morbac.rules`) | User-level (roleless) |
|---|---|---|
| A specific organization | `scope` = `'self'`, `'subtree'`, … | `user_rules` with an `org_id` |
| Unattributed objects | `scope` = `'unattributed'` | `user_rules` with `org_id = NULL` |
| All orgs (unattributed included) | `scope` = `'all'` | `global_rules` |
The two object classes are **partitioned**: an `'unattributed'` rule can never reach an object that has an org, and the tree scopes (`'self'`, `'subtree'`, …) can never reach an unattributed object. Only `'all'` and `global_rules` deliberately span both.
#### Granting a role access to unattributed objects
The declaring org is the policy authority; the role must be held **in that org**. Grant, revoke, and delegate the role exactly as usual — access to the unattributed pool follows.
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT o.id, r.id, 'read', 'documents', c.id, 'permission', 'unattributed'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'triage'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'Acme Corp';
```
Several organizations may each declare their own policy over the same unattributed pool — that is ordinary Multi-OrBAC: independent authorities over a shared object space.
Prohibitions, priorities, contexts, temporal validity, role hierarchy, delegation, derived roles, negative assignments and SoD all apply unchanged:
```sql
-- block the same role during an embargo, outranking the permission
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope, priority)
SELECT o.id, r.id, 'read', 'documents', c.id, 'prohibition', 'unattributed', 10
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'triage'
JOIN morbac.contexts c ON c.name = 'embargo'
WHERE o.name = 'Acme Corp';
```
Granting a single user access without a role uses `user_rules` with no org:
```sql
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
SELECT '…user…'::uuid, NULL, 'read', 'documents', c.id, 'permission'
FROM morbac.contexts c WHERE c.name = 'always';
```
#### Checking authorization
`is_allowed(user, org, activity, view)` takes a specific org, or `NULL` for an unattributed object:
```sql
SELECT morbac.is_allowed(user_id, NULL, 'read', 'documents'); -- unattributed object
```
Unattributed decisions are **never cached** (the cache is keyed by a non-null org), so they always reflect current policy.
#### Selecting which records to return
`rls_check` has two forms, distinguished by arity so that a `NULL` never carries two meanings:
```sql
morbac.rls_check(activity, view) -- table has no org column
morbac.rls_check(activity, view, row_org_id [, row_user_id]) -- row-scoped by org
```
In the row-scoped form, `row_org_id` is the record's org and a `NULL` value means the record is unattributed. The 2-argument form carries no org dimension and evaluates against the session org context.
Which records come back is chosen with the session variables. A JSON `null` element in `morbac.org_ids` names the unattributed bucket:
| Session | Returns |
|---|---|
| *(nothing set)* | all authorized records — every org **and** unattributed |
| `morbac.org_id = '<uuid>'` | that org only — unattributed excluded |
| `morbac.org_ids = '["<uuid>"]'` | those orgs only — unattributed excluded |
| `morbac.org_ids = '[null]'` | **unattributed only** (the attribution queue) |
| `morbac.org_ids = '["<uuid>", null]'` | that org **plus** unattributed |
```sql
-- the attribution queue: only records awaiting an org
SET morbac.org_ids = '[null]';
SELECT * FROM app.documents;
```
`morbac.current_org_filter()` is the underlying parser; it reads `morbac.org_ids` once and returns the real org UUIDs plus whether the unattributed bucket was requested.
#### Capability probe for UI gating
To decide whether to show a feature at all — rather than authorize a specific object — use:
```sql
SELECT morbac.has_permission(user_id, 'read', 'documents');
```
It returns TRUE when the user is allowed in **any** context: any org they are a member of, the unattributed bucket, or via global rules. Prohibitions are honored per context. It is not a substitute for object-level `is_allowed()`; a pure cross-org grant into a non-member org is not counted.
### Hierarchies
Organizations, roles, activities, and views support hierarchical relationships with transitive closure.
@@ -851,10 +953,17 @@ WHERE table_name = 'rules'
### Authorization Functions
**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny. Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests).
**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny. `org_id` is a specific organization, or `NULL` when the object is unattributed (no org) — `NULL` never means "any org". Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests). Unattributed decisions are not cached.
```sql
SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
SELECT morbac.is_allowed(user_uuid, NULL, 'read', 'documents'); -- unattributed object
```
**`has_permission(user_id, activity, view)`**: Capability probe for UI gating. Returns TRUE if the user is allowed in any member org, the unattributed bucket, or via global rules. Not a substitute for object-level `is_allowed()`.
```sql
SELECT morbac.has_permission(user_uuid, 'read', 'documents');
```
**`get_comprehensive_roles(user_id, org_id)`**: Returns all roles for user (direct, delegated, derived, hierarchy, minus negative assignments).
@@ -908,13 +1017,24 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2);
**`current_org_id()`**: Get org ID from `request.header.x-org-id` (PostgREST) or `current_setting('morbac.org_id')`.
**`rls_check(activity, view)`**: Authorization check for RLS policies using current user/org context.
**`current_org_filter()`**: Parses `morbac.org_ids` once, returning `org_ids` (the real org UUIDs) and `include_unattributed` (TRUE when the array holds a JSON `null` element).
**`current_org_ids()`**: Convenience wrapper returning only the real org UUIDs from `current_org_filter()`.
**`rls_check(activity, view)`**: Authorization check for RLS policies on tables **without** an org column. Uses the session org context.
```sql
CREATE POLICY my_policy ON app.table
FOR SELECT USING (morbac.rls_check('read', 'documents'));
```
**`rls_check(activity, view, row_org_id [, row_user_id])`**: Row-scoped check for tables **with** an org column. `row_org_id` is the record's org; a `NULL` value means the record is unattributed. The arity distinguishes the two cases so `NULL` never carries two meanings.
```sql
CREATE POLICY my_policy ON app.documents
FOR SELECT USING (morbac.rls_check('read', 'documents', org_id));
```
### Informational Functions
**`pending_obligations(user_id, org_id)`**: Returns obligations for user (informational only).
@@ -959,13 +1079,14 @@ WITH CHECK (morbac.rls_check('write', 'documents', org_id));
#### Org scoping modes
`rls_check` resolves the org scope from session variables in priority order:
The row-scoped `rls_check` resolves which records to return from session variables in priority order. A JSON `null` element in `morbac.org_ids` names the unattributed (no-org) bucket:
| Session variable | Behaviour |
|---|---|
| `morbac.org_id` set | scoped to that single org |
| `morbac.org_ids` set | scoped to the provided list of orgs |
| neither set | all orgs the user belongs to |
| `morbac.org_id` set | that single org — unattributed excluded |
| `morbac.org_ids` set | the listed orgs; a `null` element adds unattributed records |
| `morbac.org_ids = '[null]'` | unattributed records only |
| neither set | all authorized records — every org **and** unattributed |
#### Setting context from HTTP headers