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
+14
View File
@@ -40,5 +40,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comprehensive test suite with 20 test scenarios - Comprehensive test suite with 20 test scenarios
- Complete documentation - Complete documentation
- Build and installation automation (Makefile, install.sh) - Build and installation automation (Makefile, install.sh)
- Unattributed (no-org) object support:
- Org target vocabulary shared by every rule kind: a specific organization,
`unattributed` (objects with no org), or `all` (every org, unattributed included)
- `rules.scope` values `unattributed` and `all`
- `user_rules.org_id` accepts NULL to target unattributed objects
- `org_in_scope()` partitions the two object classes: `unattributed` matches only
a NULL target, tree scopes never match one
- `morbac.has_permission(user, activity, view)` capability probe for UI gating
- `morbac.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])` for row-scoped tables
where a NULL `row_org_id` means the record is unattributed
[0.1.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v0.1.0 [0.1.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v0.1.0
+40 -1
View File
@@ -10,6 +10,7 @@ A PostgreSQL extension implementing the Multi-OrBAC access control model - enabl
## Features ## Features
- Multi-organization with organizational hierarchy - Multi-organization with organizational hierarchy
- Unattributed (no-org) objects as a first-class rule target
- Role-based access with full hierarchy support - Role-based access with full hierarchy support
- Activity and view hierarchies with transitive permission inheritance - Activity and view hierarchies with transitive permission inheritance
- Prohibition precedence over permissions - Prohibition precedence over permissions
@@ -117,6 +118,12 @@ SELECT morbac.is_allowed(user_id, org_id, activity, view);
-- Debugging (bypasses cache) -- Debugging (bypasses cache)
SELECT morbac.is_allowed_nocache(user_id, org_id, activity, view); SELECT morbac.is_allowed_nocache(user_id, org_id, activity, view);
-- Unattributed object (no org): pass NULL as the org
SELECT morbac.is_allowed(user_id, NULL, activity, view);
-- Capability probe for UI gating (any org, unattributed, or global)
SELECT morbac.has_permission(user_id, activity, view);
``` ```
See [PERFORMANCE.md](docs/PERFORMANCE.md) for optimization details. See [PERFORMANCE.md](docs/PERFORMANCE.md) for optimization details.
@@ -127,12 +134,44 @@ See [PERFORMANCE.md](docs/PERFORMANCE.md) for optimization details.
-- Enable RLS on your table -- Enable RLS on your table
ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY; ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY;
-- Create policy using Multi-OrBAC -- Table without an org column
CREATE POLICY doc_access ON app.documents CREATE POLICY doc_access ON app.documents
FOR SELECT FOR SELECT
USING (morbac.rls_check('read', 'documents')); USING (morbac.rls_check('read', 'documents'));
-- Table with an org column: pass it. A NULL org_id means the record
-- is unattributed (awaiting attribution).
CREATE POLICY doc_access ON app.documents
FOR SELECT
USING (morbac.rls_check('read', 'documents', org_id));
``` ```
### Unattributed (no-org) Records
A record whose `org_id` is `NULL` is *unattributed*. Give a role access to that pool
without granting anything org-wide:
```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';
```
Choose which records a query returns:
```sql
SET morbac.org_ids = '[null]'; -- unattributed only (attribution queue)
SET morbac.org_ids = '["<org-uuid>", null]';-- that org plus unattributed
SET morbac.org_id = '<org-uuid>'; -- that org only
-- nothing set -- everything authorized, unattributed included
```
See [DOCUMENTATION.md](docs/DOCUMENTATION.md) for the full org target vocabulary
(a specific organization, `unattributed`, or `all`).
### Advanced Features ### Advanced Features
```sql ```sql
+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 | | `'ancestors'` | All ancestors, excluding the rule's org itself |
| `'lineage'` | The rule's org + all ancestors | | `'lineage'` | The rule's org + all ancestors |
| `'root'` | Topmost ancestor of the rule's org | | `'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** **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. **`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 ### Hierarchies
Organizations, roles, activities, and views support hierarchical relationships with transitive closure. Organizations, roles, activities, and views support hierarchical relationships with transitive closure.
@@ -851,10 +953,17 @@ WHERE table_name = 'rules'
### Authorization Functions ### 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 ```sql
SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents'); 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). **`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')`. **`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 ```sql
CREATE POLICY my_policy ON app.table CREATE POLICY my_policy ON app.table
FOR SELECT USING (morbac.rls_check('read', 'documents')); 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 ### Informational Functions
**`pending_obligations(user_id, org_id)`**: Returns obligations for user (informational only). **`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 #### 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 | | Session variable | Behaviour |
|---|---| |---|---|
| `morbac.org_id` set | scoped to that single org | | `morbac.org_id` set | that single org — unattributed excluded |
| `morbac.org_ids` set | scoped to the provided list of orgs | | `morbac.org_ids` set | the listed orgs; a `null` element adds unattributed records |
| neither set | all orgs the user belongs to | | `morbac.org_ids = '[null]'` | unattributed records only |
| neither set | all authorized records — every org **and** unattributed |
#### Setting context from HTTP headers #### Setting context from HTTP headers
+37 -4
View File
@@ -11,11 +11,17 @@
-- - When both a prohibition and a permission apply, the higher-priority rule wins -- - When both a prohibition and a permission apply, the higher-priority rule wins
-- - Tie goes to prohibition (modality precedence from the Multi-OrBAC paper) -- - Tie goes to prohibition (modality precedence from the Multi-OrBAC paper)
-- --
-- Org target (p_org_id): a specific org, or NULL meaning the object is
-- unattributed (has no org). NULL never means "any org".
--
-- Scope: -- Scope:
-- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...). -- - rules.scope selects which objects a rule covers: a specific org
-- (self/subtree/descendants/...), 'unattributed', or 'all'.
-- Evaluated at query time via org_in_scope() — new orgs are covered automatically. -- Evaluated at query time via org_in_scope() — new orgs are covered automatically.
-- - cross_org_rules.source_org_id is always required: user must hold the role there. -- - cross_org_rules.source_org_id is always required: user must hold the role there.
-- - user_rules target a specific user directly (no role required). -- - user_rules target a specific user directly (no role required); their org_id
-- is a specific org, or NULL for unattributed objects.
-- - global_rules apply to every org, unattributed included.
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
p_user_id UUID, p_user_id UUID,
@@ -84,7 +90,7 @@ BEGIN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id = p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition' AND ur.modality = 'prohibition'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) 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.view IN (SELECT view FROM morbac.get_effective_views(p_view))
@@ -163,7 +169,7 @@ BEGIN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id = p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission' AND ur.modality = 'permission'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) 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.view IN (SELECT view FROM morbac.get_effective_views(p_view))
@@ -275,3 +281,30 @@ $$;
COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS
'Complete OrBAC authorization with caching (default) - use is_allowed_nocache() for debugging'; 'Complete OrBAC authorization with caching (default) - use is_allowed_nocache() for debugging';
-- Capability probe: does the user hold the permission in ANY context (any org
-- they are a member of, or the no-org bucket, or via global rules)? Intended
-- for UI feature gating, not object-level enforcement. Prohibitions are honored
-- per context: a context counts only if is_allowed() returns true there.
-- Pure cross-org grants into a non-member org are not counted.
CREATE OR REPLACE FUNCTION morbac.has_permission(
p_user_id UUID,
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM morbac.get_user_orgs(p_user_id) o
WHERE morbac.is_allowed(p_user_id, o.org_id, p_activity, p_view)
) OR morbac.is_allowed(p_user_id, NULL, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.has_permission(UUID, TEXT, TEXT) IS
'Capability probe for UI gating: TRUE if the user is allowed the activity/view in any member org, the no-org bucket, or via global rules. Not a substitute for object-level is_allowed().';
+27 -3
View File
@@ -61,6 +61,9 @@ COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS
-- 'ancestors' — all ancestors, excluding self (depth > 0) -- 'ancestors' — all ancestors, excluding self (depth > 0)
-- 'lineage' — self + all ancestors (equivalent to get_org_ancestors) -- 'lineage' — self + all ancestors (equivalent to get_org_ancestors)
-- 'root' — topmost ancestor only (max depth ancestor) -- 'root' — topmost ancestor only (max depth ancestor)
-- 'unattributed' — the no-org bucket; resolves to no real orgs (empty set)
-- 'all' — every organization (unattributed is not an org, so it is
-- not listed here; org_in_scope('all') does cover it)
-- --
-- Optional p_max_depth limits how many levels are traversed (NULL = unlimited). -- Optional p_max_depth limits how many levels are traversed (NULL = unlimited).
CREATE OR REPLACE FUNCTION morbac.get_org_scope( CREATE OR REPLACE FUNCTION morbac.get_org_scope(
@@ -126,14 +129,22 @@ BEGIN
ORDER BY a.depth DESC ORDER BY a.depth DESC
LIMIT 1; LIMIT 1;
WHEN 'unattributed' THEN
RETURN QUERY
SELECT NULL::UUID, 0 WHERE FALSE;
WHEN 'all' THEN
RETURN QUERY
SELECT o.id, 0 FROM morbac.orgs o;
ELSE ELSE
RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root', p_scope; RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all', p_scope;
END CASE; END CASE;
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.get_org_scope(UUID, TEXT, INTEGER) IS 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.'; 'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all. Optional p_max_depth limits traversal depth.';
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)
@@ -345,6 +356,19 @@ STABLE
SECURITY DEFINER SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
-- Org target vocabulary: all (every org, unattributed included),
-- unattributed (no-org objects only), or a specific org via the tree scopes.
-- Tree scopes never match an unattributed object.
IF p_scope = 'all' THEN
RETURN TRUE;
END IF;
IF p_scope = 'unattributed' THEN
RETURN p_target_org_id IS NULL;
END IF;
IF p_target_org_id IS NULL THEN
RETURN FALSE;
END IF;
IF p_scope = 'self' THEN IF p_scope = 'self' THEN
RETURN p_target_org_id = p_rule_org_id; RETURN p_target_org_id = p_rule_org_id;
END IF; END IF;
@@ -356,4 +380,4 @@ END;
$$; $$;
COMMENT ON FUNCTION morbac.org_in_scope(UUID, UUID, TEXT) IS COMMENT ON FUNCTION morbac.org_in_scope(UUID, UUID, TEXT) IS
'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). SECURITY DEFINER to bypass RLS on morbac.orgs.'; 'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). Scope unattributed matches only a NULL target; other scopes never match NULL. SECURITY DEFINER to bypass RLS on morbac.orgs.';
+96 -18
View File
@@ -72,30 +72,56 @@ $$;
COMMENT ON FUNCTION morbac.current_target_user_id() IS COMMENT ON FUNCTION morbac.current_target_user_id() IS
'Returns target user ID filter from morbac.target_user_id session variable'; 'Returns target user ID filter from morbac.target_user_id session variable';
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' -- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' or '["uuid1", null]' or '[null]'.
CREATE OR REPLACE FUNCTION morbac.current_org_ids() -- A JSON null element names the no-org (unattributed) bucket, distinct from
RETURNS UUID[] -- the real org UUIDs which populate org_ids.
CREATE OR REPLACE FUNCTION morbac.current_org_filter(
OUT org_ids UUID[],
OUT include_unattributed BOOLEAN
)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
AS $$ AS $$
DECLARE DECLARE
v_raw TEXT; v_raw TEXT := current_setting('morbac.org_ids', TRUE);
v_json JSONB;
BEGIN BEGIN
v_raw := current_setting('morbac.org_ids', TRUE); org_ids := NULL;
include_unattributed := FALSE;
IF v_raw IS NULL OR v_raw = '' THEN IF v_raw IS NULL OR v_raw = '' THEN
RETURN NULL; RETURN;
END IF; END IF;
RETURN ARRAY(SELECT jsonb_array_elements_text(v_raw::jsonb)::UUID); v_json := v_raw::jsonb;
org_ids := ARRAY(
SELECT x::UUID
FROM jsonb_array_elements_text(v_json) x
WHERE x IS NOT NULL
);
include_unattributed := EXISTS (
SELECT 1 FROM jsonb_array_elements(v_json) e WHERE e = 'null'::jsonb
);
EXCEPTION EXCEPTION
WHEN OTHERS THEN WHEN OTHERS THEN
RETURN NULL; org_ids := NULL;
include_unattributed := FALSE;
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.current_org_filter() IS
'Parses morbac.org_ids (JSON array) once into real org UUIDs plus a flag for whether the no-org bucket (JSON null element) was requested.';
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
RETURNS UUID[]
LANGUAGE sql
STABLE
AS $$
SELECT org_ids FROM morbac.current_org_filter();
$$;
COMMENT ON FUNCTION morbac.current_org_ids() IS COMMENT ON FUNCTION morbac.current_org_ids() IS
'Returns org ID list from morbac.org_ids session variable (JSON array)'; 'Returns the real org UUIDs from morbac.org_ids (JSON null elements excluded). See current_org_filter() for the no-org bucket flag.';
CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID) CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID)
RETURNS TABLE(org_id UUID) RETURNS TABLE(org_id UUID)
@@ -117,13 +143,53 @@ $$;
COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
'Returns all org IDs the user has any direct role or active delegation in'; 'Returns all org IDs the user has any direct role or active delegation in';
-- rls_check has two forms, distinguished by arity so a NULL never carries two
-- meanings:
--
-- rls_check(activity, view) -- table has no org column
-- rls_check(activity, view, row_org_id[, row_user_id]) -- row-scoped by org
--
-- In the 3/4-arg form row_org_id is the record's org, and a NULL value means the
-- record is unattributed (no org) -- never "no org dimension". The 2-arg form
-- carries no org dimension and evaluates against the session org context.
DROP FUNCTION IF EXISTS morbac.rls_check(TEXT, TEXT, UUID, UUID);
CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_user_id UUID;
v_org_id UUID;
BEGIN
v_user_id := morbac.current_user_id();
IF v_user_id IS NULL THEN
RETURN FALSE;
END IF;
v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END IF;
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT) IS
'RLS helper for tables without an org column. Uses the session org context (morbac.org_id), else org-independent rules. For row-scoped tables use the 3/4-arg form.';
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs. -- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
-- User scoping: morbac.target_user_id filters rows to a specific user. -- User scoping: morbac.target_user_id filters rows to a specific user.
-- Pass row columns to enable scoping: rls_check('read', 'docs', org_id, user_id)
CREATE OR REPLACE FUNCTION morbac.rls_check( CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT, p_activity TEXT,
p_view TEXT, p_view TEXT,
p_row_org_id UUID DEFAULT NULL, p_row_org_id UUID,
p_row_user_id UUID DEFAULT NULL p_row_user_id UUID DEFAULT NULL
) )
RETURNS BOOLEAN RETURNS BOOLEAN
@@ -134,6 +200,7 @@ DECLARE
v_user_id UUID; v_user_id UUID;
v_org_id UUID; v_org_id UUID;
v_org_ids UUID[]; v_org_ids UUID[];
v_include_unattr BOOLEAN;
v_target_user_id UUID; v_target_user_id UUID;
BEGIN BEGIN
v_user_id := morbac.current_user_id(); v_user_id := morbac.current_user_id();
@@ -150,29 +217,40 @@ BEGIN
END IF; END IF;
END IF; END IF;
-- Single org selection: exact bucket only. An unattributed row (NULL org) is
-- DISTINCT from the pin and is filtered out (use the org_ids list for both).
v_org_id := morbac.current_org_id(); v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN IF v_org_id IS NOT NULL THEN
IF p_row_org_id IS NOT NULL AND p_row_org_id <> v_org_id THEN IF p_row_org_id IS DISTINCT FROM v_org_id THEN
RETURN FALSE; RETURN FALSE;
END IF; END IF;
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END IF; END IF;
v_org_ids := morbac.current_org_ids(); -- Org list selection: real orgs and/or the unattributed bucket (JSON null element).
SELECT f.org_ids, f.include_unattributed
INTO v_org_ids, v_include_unattr
FROM morbac.current_org_filter() f;
IF v_org_ids IS NOT NULL THEN IF v_org_ids IS NOT NULL OR v_include_unattr THEN
IF p_row_org_id IS NOT NULL AND NOT (p_row_org_id = ANY(v_org_ids)) THEN IF p_row_org_id IS NULL THEN
IF NOT v_include_unattr THEN
RETURN FALSE;
END IF;
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
END IF;
IF v_org_ids IS NULL OR NOT (p_row_org_id = ANY(v_org_ids)) THEN
RETURN FALSE; RETURN FALSE;
END IF; END IF;
-- p_row_org_id NULL: global row — is_allowed(NULL) checks global_rules only
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END IF; END IF;
-- No org context: use row's org (or NULL for global rows — global_rules only) -- No selection set: all authorized rows. The row's own org drives the decision
-- (unattributed row -> unattributed + org-independent rules).
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS
'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id for org scoping (single org, org list, or all orgs). Pass row user_id to filter by morbac.target_user_id session variable.'; 'RLS helper for row-scoped tables. p_row_org_id is the record org; NULL means unattributed. Select records via morbac.org_id (single org, unattributed excluded) or morbac.org_ids (JSON array; a null element adds the unattributed bucket). No selection = all authorized rows including unattributed. Pass row user_id to filter by morbac.target_user_id.';
+6 -2
View File
@@ -9,7 +9,11 @@
-- 'ancestors' — all ancestors, excluding self -- 'ancestors' — all ancestors, excluding self
-- 'lineage' — self + all ancestors -- 'lineage' — self + all ancestors
-- 'root' — topmost ancestor only -- 'root' — topmost ancestor only
-- 'unattributed' — no-org objects only (org_id column above is the declaring authority)
-- 'all' — every org, unattributed included
-- Evaluated at query time via org_in_scope() — new orgs are picked up automatically. -- Evaluated at query time via org_in_scope() — new orgs are picked up automatically.
--
-- Org target vocabulary: a specific org (the tree scopes), 'unattributed', or 'all'.
CREATE TABLE morbac.rules ( CREATE TABLE morbac.rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -28,7 +32,7 @@ CREATE TABLE morbac.rules (
metadata JSONB DEFAULT '{}'::jsonb, metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, activity, view, context_id, modality, scope), UNIQUE(org_id, role_id, activity, view, context_id, modality, scope),
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from), CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root')) CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'))
); );
CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id); CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id);
@@ -40,7 +44,7 @@ INCLUDE (role_id, context_id)
WHERE is_active = true; WHERE is_active = true;
COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
COMMENT ON COLUMN morbac.rules.scope IS 'Org scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root. Evaluated at query time — new orgs are covered automatically.'; COMMENT ON COLUMN morbac.rules.scope IS 'Object scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root, unattributed, all. Selects which objects (by org) the rule reaches: a specific org via the tree scopes, unattributed for no-org objects only, or all for every org including unattributed. Evaluated at query time — new orgs are covered automatically.';
COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.'; COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.';
+10 -2
View File
@@ -3,11 +3,14 @@
-- Grants or prohibits access for a specific user in an org, bypassing the role system. -- Grants or prohibits access for a specific user in an org, bypassing the role system.
-- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache(). -- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache().
-- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition. -- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition.
--
-- Org target: a specific org, or NULL for unattributed (no-org) objects.
-- For the same user across every org, use morbac.global_rules.
CREATE TABLE morbac.user_rules ( CREATE TABLE morbac.user_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, user_id UUID NOT NULL,
org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, org_id UUID REFERENCES morbac.orgs(id) ON DELETE CASCADE,
activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
@@ -21,6 +24,11 @@ CREATE TABLE morbac.user_rules (
UNIQUE(user_id, org_id, activity, view, context_id, modality) UNIQUE(user_id, org_id, activity, view, context_id, modality)
); );
-- NULLs are distinct in UNIQUE, so the unattributed target needs its own index
CREATE UNIQUE INDEX idx_user_rules_unattributed_unique
ON morbac.user_rules(user_id, activity, view, context_id, modality)
WHERE org_id IS NULL;
CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id); CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id);
CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view); CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view);
CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality); CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality);
@@ -28,6 +36,6 @@ CREATE INDEX idx_user_rules_lookup ON morbac.user_rules(user_id, org_id, activit
COMMENT ON TABLE morbac.user_rules IS 'Direct user-level rules — grant or prohibit access for a specific user, bypassing the role system'; COMMENT ON TABLE morbac.user_rules IS 'Direct user-level rules — grant or prohibit access for a specific user, bypassing the role system';
COMMENT ON COLUMN morbac.user_rules.user_id IS 'User this rule applies to directly'; COMMENT ON COLUMN morbac.user_rules.user_id IS 'User this rule applies to directly';
COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides'; COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides; NULL targets unattributed (no-org) objects';
COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.'; COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.';
+7 -4
View File
@@ -90,7 +90,8 @@ CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts(
p_view TEXT, p_view TEXT,
p_context_id UUID, p_context_id UUID,
p_modality morbac.modality, p_modality morbac.modality,
p_exclude_id UUID DEFAULT NULL p_exclude_id UUID DEFAULT NULL,
p_scope TEXT DEFAULT 'self'
) )
RETURNS TABLE( RETURNS TABLE(
conflicting_rule_id UUID, conflicting_rule_id UUID,
@@ -121,6 +122,7 @@ BEGIN
AND r.activity = p_activity AND r.activity = p_activity
AND r.view = p_view AND r.view = p_view
AND r.context_id = p_context_id AND r.context_id = p_context_id
AND r.scope = p_scope
AND r.modality != p_modality AND r.modality != p_modality
AND (p_exclude_id IS NULL OR r.id != p_exclude_id) AND (p_exclude_id IS NULL OR r.id != p_exclude_id)
AND ( AND (
@@ -132,8 +134,8 @@ BEGIN
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID) IS COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID, TEXT) IS
'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission).'; 'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission). Conflicts are scoped: only rules sharing the same scope compete, since different scopes target different object sets.';
-- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one -- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one
@@ -147,7 +149,8 @@ BEGIN
FOR v_conflict IN FOR v_conflict IN
SELECT * FROM morbac.detect_rule_conflicts( SELECT * FROM morbac.detect_rule_conflicts(
NEW.org_id, NEW.role_id, NEW.activity, NEW.view, NEW.context_id, NEW.modality, NEW.org_id, NEW.role_id, NEW.activity, NEW.view, NEW.context_id, NEW.modality,
CASE WHEN TG_OP = 'UPDATE' THEN NEW.id ELSE NULL END CASE WHEN TG_OP = 'UPDATE' THEN NEW.id ELSE NULL END,
NEW.scope
) )
LOOP LOOP
RAISE WARNING 'Rule conflict: % (conflicts with rule %)', RAISE WARNING 'Rule conflict: % (conflicts with rule %)',
+29 -20
View File
@@ -1,25 +1,27 @@
-- ============================================================================= -- =============================================================================
-- rls_check Tests -- rls_check Tests
-- ============================================================================= -- =============================================================================
-- Tests morbac.rls_check() with all session-org combinations, focusing on -- Tests morbac.rls_check() with all session-org combinations. A NULL row org
-- the two cases fixed to support global rows (p_row_org_id IS NULL): -- is an unattributed object: an org filter (single pin, or org_ids without a
-- null marker) excludes it; it is reachable via no filter or a null marker.
-- --
-- 1. No user_id set: always FALSE -- 1. No user_id set: always FALSE
-- 2. Single org context -- 2. Single org context
-- a. org-scoped row, matching org -- a. org-scoped row, matching org
-- b. org-scoped row, different org (blocked) -- b. org-scoped row, different org (blocked)
-- c. global row (NULL org_id): uses session org -- c. NULL row: filtered out under an org pin (orphan not requested)
-- 3. org_ids filter -- 3. org_ids filter
-- a. org-scoped row in list -- a. org-scoped row in list
-- b. org-scoped row not in list (blocked) -- b. org-scoped row not in list (blocked)
-- c. global row + global permission [was FALSE, now TRUE] -- c. NULL row, list without null marker: filtered out even with a grant
-- d. global row + global prohibition [was FALSE, now correctly FALSE] -- c2. NULL row, list with null marker + global permission: allowed
-- e. global row + no rule [was FALSE, still FALSE] -- d. NULL row, list with null marker + global prohibition: blocked
-- e. NULL row, list with null marker + no rule: blocked
-- 4. No org context -- 4. No org context
-- a. org-scoped row: uses row's org -- a. org-scoped row: uses row's org
-- b. global row + global permission [was FALSE, now TRUE] -- b. NULL row + global permission [orphan + global rules -> TRUE]
-- c. global row + global prohibition [was FALSE, now correctly FALSE] -- c. NULL row + global prohibition [blocked]
-- d. global row + no rule [was FALSE, still FALSE] -- d. NULL row + no rule [blocked]
-- --
-- User state carried from previous tests: -- User state carried from previous tests:
-- Karl (30000000-0000-0000-0000-000000000011): -- Karl (30000000-0000-0000-0000-000000000011):
@@ -74,13 +76,13 @@ SELECT morbac.t('rls_check single org, org row from different org (blocked)',
'10000000-0000-0000-0000-000000000002'::uuid), '10000000-0000-0000-0000-000000000002'::uuid),
FALSE); FALSE);
-- 2c: global row — uses session org, so Karl's user_rule on GlobalTech applies -- 2c: NULL row — filtered out under a single org pin (orphan not requested)
SELECT morbac.t('rls_check single org, global row (NULL org_id): uses session org', SELECT morbac.t('rls_check single org, NULL row filtered out under org pin',
morbac.rls_check('read', 'documents', NULL), morbac.rls_check('read', 'documents', NULL),
TRUE); FALSE);
-- 2c (no permission): Karl has no rule for contracts in GlobalTech -- 2c (contracts): still filtered out regardless of permission
SELECT morbac.t('rls_check single org, global row (NULL org_id): no permission for contracts', SELECT morbac.t('rls_check single org, NULL row filtered out (contracts)',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
FALSE); FALSE);
@@ -108,7 +110,7 @@ SELECT morbac.t('rls_check org_ids, org row not in list (blocked)',
'10000000-0000-0000-0000-000000000002'::uuid), '10000000-0000-0000-0000-000000000002'::uuid),
FALSE); FALSE);
-- 3c: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only -- 3c: NULL row, list WITHOUT null marker — filtered out even with a global grant
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES ( VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl '30000000-0000-0000-0000-000000000011', -- Karl
@@ -117,7 +119,14 @@ VALUES (
'permission' 'permission'
); );
SELECT morbac.t('rls_check org_ids, global row + global permission [new: was FALSE]', SELECT morbac.t('rls_check org_ids without null marker, NULL row filtered out despite grant',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
-- 3c2: NULL row, list WITH null marker + global permission — allowed
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001", null]';
SELECT morbac.t('rls_check org_ids with null marker, NULL row + global permission',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
TRUE); TRUE);
@@ -125,7 +134,7 @@ DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011' WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts'; AND activity = 'read' AND view = 'contracts';
-- 3d: global row + global prohibition -- 3d: NULL row, list with null marker + global prohibition
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES ( VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl '30000000-0000-0000-0000-000000000011', -- Karl
@@ -134,7 +143,7 @@ VALUES (
'prohibition' 'prohibition'
); );
SELECT morbac.t('rls_check org_ids, global row + global prohibition', SELECT morbac.t('rls_check org_ids with null marker, NULL row + global prohibition',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
FALSE); FALSE);
@@ -142,8 +151,8 @@ DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011' WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts'; AND activity = 'read' AND view = 'contracts';
-- 3e: global row + no rule -- 3e: NULL row, list with null marker + no rule
SELECT morbac.t('rls_check org_ids, global row + no rule', SELECT morbac.t('rls_check org_ids with null marker, NULL row + no rule',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
FALSE); FALSE);
+225
View File
@@ -0,0 +1,225 @@
-- =============================================================================
-- Unattributed (no-org) rule Tests
-- =============================================================================
-- Tests scope = 'unattributed': rules authored by an org that govern objects
-- with no org (org_id IS NULL), evaluated via is_allowed(user, NULL, ...).
--
-- Key properties:
-- - role-bound: the user must hold the rule's role in the declaring org
-- - partitioned: unattributed rules never reach real-org objects, and
-- org-scoped rules never reach no-org objects
-- - composes with prohibition precedence, revocation, delegation, multi-org
-- - has_permission() capability probe surfaces the grant
--
-- Fixtures created here (isolated from the GlobalTech scenario):
-- AttribCorp (org) role triage user Nomad
-- IntakeCorp (org) role intake user Nomad (multi-org over the same pool)
--
-- Prerequisites: 00_setup.sql -> 15_rls_check.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '16 -- UNATTRIBUTED'
\echo '================================================================'
RESET morbac.user_id;
RESET morbac.org_id;
RESET morbac.org_ids;
INSERT INTO morbac.orgs (id, name) VALUES
('40000000-0000-0000-0000-000000000001','AttribCorp'),
('40000000-0000-0000-0000-000000000002','IntakeCorp');
INSERT INTO morbac.roles (id, org_id, name) VALUES
('40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001','triage'),
('40000000-0000-0000-0000-0000000000a2','40000000-0000-0000-0000-000000000002','intake');
-- Nomad: triage in AttribCorp; Scout: delegatee
\set NOMAD '''40000000-0000-0000-0000-0000000000f1'''
\set SCOUT '''40000000-0000-0000-0000-0000000000f2'''
\set STRANGER '''40000000-0000-0000-0000-0000000000f9'''
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:NOMAD,'40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001');
\set CTX '(SELECT id FROM morbac.contexts WHERE name = ''always'')'
-- unattributed permission for triage
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'permission','unattributed');
\echo ''
\echo '--- 1. Authorization + role binding ---'
SELECT morbac.t('unattributed grant -> orphan object allowed',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), TRUE);
SELECT morbac.t('stranger without role -> orphan denied',
morbac.is_allowed_nocache(:STRANGER, NULL, 'read','documents'), FALSE);
\echo ''
\echo '--- 2. Partition: unattributed does not reach real-org objects ---'
SELECT morbac.t('unattributed rule does NOT grant AttribCorp object',
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), FALSE);
-- add a self permission; now the org object is allowed, orphan still allowed
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'permission','self');
SELECT morbac.t('self rule grants AttribCorp object',
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), TRUE);
SELECT morbac.t('orphan still allowed alongside self rule',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), TRUE);
\echo ''
\echo '--- 3. Partition: org-scoped does not reach no-org objects ---'
-- remove the unattributed rule; self remains
DELETE FROM morbac.rules
WHERE org_id = '40000000-0000-0000-0000-000000000001'
AND role_id = '40000000-0000-0000-0000-0000000000a1'
AND scope = 'unattributed';
SELECT morbac.t('self rule does NOT reach orphan object',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
SELECT morbac.t('AttribCorp object still allowed by self rule',
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), TRUE);
-- restore unattributed permission for the remaining tests
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'permission','unattributed');
\echo ''
\echo '--- 4. Prohibition precedence on orphan objects ---'
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope, priority)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'prohibition','unattributed', 10);
SELECT morbac.t('unattributed prohibition (prio 10) beats permission (prio 0)',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
DELETE FROM morbac.rules
WHERE org_id = '40000000-0000-0000-0000-000000000001'
AND role_id = '40000000-0000-0000-0000-0000000000a1'
AND scope = 'unattributed' AND modality = 'prohibition';
\echo ''
\echo '--- 5. Revocation ---'
DELETE FROM morbac.user_roles WHERE user_id = :NOMAD;
SELECT morbac.t('revoke role -> orphan access removed',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:NOMAD,'40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001');
\echo ''
\echo '--- 6. Delegation propagates orphan access ---'
INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_until)
VALUES (:NOMAD, :SCOUT, '40000000-0000-0000-0000-0000000000a1',
'40000000-0000-0000-0000-000000000001', now() + interval '1 day');
SELECT morbac.t('delegatee gains orphan access via delegated role',
morbac.is_allowed_nocache(:SCOUT, NULL, 'read','documents'), TRUE);
\echo ''
\echo '--- 7. Multi-org: independent authority over the same pool ---'
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:NOMAD,'40000000-0000-0000-0000-0000000000a2','40000000-0000-0000-0000-000000000002');
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000002','40000000-0000-0000-0000-0000000000a2',
'write','documents', :CTX,'permission','unattributed');
SELECT morbac.t('IntakeCorp role independently grants orphan write',
morbac.is_allowed_nocache(:NOMAD, NULL, 'write','documents'), TRUE);
\echo ''
\echo '--- 8. has_permission capability probe ---'
SELECT morbac.t('has_permission TRUE via orphan grant',
morbac.has_permission(:NOMAD, 'read','documents'), TRUE);
SELECT morbac.t('has_permission FALSE for ungranted activity/view',
morbac.has_permission(:STRANGER, 'read','documents'), FALSE);
\echo ''
\echo '--- 9. Org target triad: specific / unattributed / all ---'
-- role-based 'all': every org, unattributed included
INSERT INTO morbac.roles (id, org_id, name) VALUES
('40000000-0000-0000-0000-0000000000a3','40000000-0000-0000-0000-000000000001','overseer');
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:SCOUT,'40000000-0000-0000-0000-0000000000a3','40000000-0000-0000-0000-000000000001');
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a3',
'approve','documents', :CTX,'permission','all');
SELECT morbac.t('scope all reaches a specific org',
morbac.is_allowed_nocache(:SCOUT, '40000000-0000-0000-0000-000000000002','approve','documents'), TRUE);
SELECT morbac.t('scope all reaches unattributed objects',
morbac.is_allowed_nocache(:SCOUT, NULL,'approve','documents'), TRUE);
-- roleless user_rule targeting unattributed (org_id NULL)
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (:STRANGER, NULL, 'read','reports', :CTX,'permission');
SELECT morbac.t('user_rule with no org grants unattributed objects',
morbac.is_allowed_nocache(:STRANGER, NULL,'read','reports'), TRUE);
SELECT morbac.t('user_rule with no org does NOT reach a real org',
morbac.is_allowed_nocache(:STRANGER, '40000000-0000-0000-0000-000000000001','read','reports'), FALSE);
-- roleless user_rule targeting a specific org stays partitioned
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (:STRANGER, '40000000-0000-0000-0000-000000000002', 'read','documents', :CTX,'permission');
SELECT morbac.t('user_rule with an org grants that org',
morbac.is_allowed_nocache(:STRANGER, '40000000-0000-0000-0000-000000000002','read','documents'), TRUE);
SELECT morbac.t('user_rule with an org does NOT reach unattributed',
morbac.is_allowed_nocache(:STRANGER, NULL,'read','documents'), FALSE);
\echo ''
\echo '--- 10. rls_check filter matrix ---'
SELECT set_config('morbac.user_id', :NOMAD, false);
RESET morbac.org_id;
RESET morbac.org_ids;
SELECT morbac.t('no filter: orphan row visible',
morbac.rls_check('read','documents', NULL), TRUE);
SET morbac.org_id = '40000000-0000-0000-0000-000000000001';
SELECT morbac.t('single org pin: orphan row filtered out',
morbac.rls_check('read','documents', NULL), FALSE);
RESET morbac.org_id;
SET morbac.org_ids = '[null]';
SELECT morbac.t('org_ids [null]: orphan row visible',
morbac.rls_check('read','documents', NULL), TRUE);
SELECT morbac.t('org_ids [null]: real-org row filtered out',
morbac.rls_check('read','documents', '40000000-0000-0000-0000-000000000001'::uuid), FALSE);
SET morbac.org_ids = '["40000000-0000-0000-0000-000000000001", null]';
SELECT morbac.t('org_ids [AttribCorp, null]: orphan row visible',
morbac.rls_check('read','documents', NULL), TRUE);
SELECT morbac.t('org_ids [AttribCorp, null]: AttribCorp row visible',
morbac.rls_check('read','documents', '40000000-0000-0000-0000-000000000001'::uuid), TRUE);
RESET morbac.user_id;
RESET morbac.org_ids;
\echo ''
\echo '=== Unattributed Tests Completed ==='