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:
@@ -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
|
||||
- Complete documentation
|
||||
- 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
|
||||
|
||||
@@ -10,6 +10,7 @@ A PostgreSQL extension implementing the Multi-OrBAC access control model - enabl
|
||||
## Features
|
||||
|
||||
- Multi-organization with organizational hierarchy
|
||||
- Unattributed (no-org) objects as a first-class rule target
|
||||
- Role-based access with full hierarchy support
|
||||
- Activity and view hierarchies with transitive permission inheritance
|
||||
- Prohibition precedence over permissions
|
||||
@@ -117,6 +118,12 @@ SELECT morbac.is_allowed(user_id, org_id, activity, view);
|
||||
|
||||
-- Debugging (bypasses cache)
|
||||
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.
|
||||
@@ -127,12 +134,44 @@ See [PERFORMANCE.md](docs/PERFORMANCE.md) for optimization details.
|
||||
-- Enable RLS on your table
|
||||
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
|
||||
FOR SELECT
|
||||
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
|
||||
|
||||
```sql
|
||||
|
||||
+127
-6
@@ -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
|
||||
|
||||
|
||||
+37
-4
@@ -11,11 +11,17 @@
|
||||
-- - When both a prohibition and a permission apply, the higher-priority rule wins
|
||||
-- - 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:
|
||||
-- - 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.
|
||||
-- - 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(
|
||||
p_user_id UUID,
|
||||
@@ -84,7 +90,7 @@ BEGIN
|
||||
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
|
||||
FROM morbac.user_rules ur
|
||||
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.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
|
||||
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
|
||||
FROM morbac.user_rules ur
|
||||
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.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
|
||||
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
|
||||
'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().';
|
||||
|
||||
@@ -61,6 +61,9 @@ COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS
|
||||
-- 'ancestors' — all ancestors, excluding self (depth > 0)
|
||||
-- 'lineage' — self + all ancestors (equivalent to get_org_ancestors)
|
||||
-- '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).
|
||||
CREATE OR REPLACE FUNCTION morbac.get_org_scope(
|
||||
@@ -126,14 +129,22 @@ BEGIN
|
||||
ORDER BY a.depth DESC
|
||||
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
|
||||
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;
|
||||
$$;
|
||||
|
||||
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)
|
||||
RETURNS TABLE(role_id UUID, depth INTEGER)
|
||||
@@ -345,6 +356,19 @@ STABLE
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
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
|
||||
RETURN p_target_org_id = p_rule_org_id;
|
||||
END IF;
|
||||
@@ -356,4 +380,4 @@ END;
|
||||
$$;
|
||||
|
||||
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
@@ -72,30 +72,56 @@ $$;
|
||||
COMMENT ON FUNCTION morbac.current_target_user_id() IS
|
||||
'Returns target user ID filter from morbac.target_user_id session variable';
|
||||
|
||||
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]'
|
||||
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
|
||||
RETURNS UUID[]
|
||||
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' or '["uuid1", null]' or '[null]'.
|
||||
-- A JSON null element names the no-org (unattributed) bucket, distinct from
|
||||
-- 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
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw TEXT;
|
||||
v_raw TEXT := current_setting('morbac.org_ids', TRUE);
|
||||
v_json JSONB;
|
||||
BEGIN
|
||||
v_raw := current_setting('morbac.org_ids', TRUE);
|
||||
org_ids := NULL;
|
||||
include_unattributed := FALSE;
|
||||
|
||||
IF v_raw IS NULL OR v_raw = '' THEN
|
||||
RETURN NULL;
|
||||
RETURN;
|
||||
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
|
||||
WHEN OTHERS THEN
|
||||
RETURN NULL;
|
||||
org_ids := NULL;
|
||||
include_unattributed := FALSE;
|
||||
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
|
||||
'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)
|
||||
RETURNS TABLE(org_id UUID)
|
||||
@@ -117,13 +143,53 @@ $$;
|
||||
COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
|
||||
'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.
|
||||
-- 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(
|
||||
p_activity TEXT,
|
||||
p_view TEXT,
|
||||
p_row_org_id UUID DEFAULT NULL,
|
||||
p_row_org_id UUID,
|
||||
p_row_user_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
@@ -134,6 +200,7 @@ DECLARE
|
||||
v_user_id UUID;
|
||||
v_org_id UUID;
|
||||
v_org_ids UUID[];
|
||||
v_include_unattr BOOLEAN;
|
||||
v_target_user_id UUID;
|
||||
BEGIN
|
||||
v_user_id := morbac.current_user_id();
|
||||
@@ -150,29 +217,40 @@ BEGIN
|
||||
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();
|
||||
|
||||
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;
|
||||
END IF;
|
||||
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
|
||||
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 p_row_org_id IS NOT NULL AND NOT (p_row_org_id = ANY(v_org_ids)) THEN
|
||||
IF v_org_ids IS NOT NULL OR v_include_unattr 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;
|
||||
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);
|
||||
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);
|
||||
END;
|
||||
$$;
|
||||
|
||||
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
@@ -9,7 +9,11 @@
|
||||
-- 'ancestors' — all ancestors, excluding self
|
||||
-- 'lineage' — self + all ancestors
|
||||
-- '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.
|
||||
--
|
||||
-- Org target vocabulary: a specific org (the tree scopes), 'unattributed', or 'all'.
|
||||
|
||||
CREATE TABLE morbac.rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -28,7 +32,7 @@ CREATE TABLE morbac.rules (
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
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 (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);
|
||||
@@ -40,7 +44,7 @@ INCLUDE (role_id, context_id)
|
||||
WHERE is_active = true;
|
||||
|
||||
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.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.';
|
||||
|
||||
|
||||
+10
-2
@@ -3,11 +3,14 @@
|
||||
-- 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().
|
||||
-- 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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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,
|
||||
view TEXT NOT NULL REFERENCES morbac.views(name) 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)
|
||||
);
|
||||
|
||||
-- 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_activity_view ON morbac.user_rules(activity, view);
|
||||
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 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.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.';
|
||||
|
||||
+7
-4
@@ -90,7 +90,8 @@ CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts(
|
||||
p_view TEXT,
|
||||
p_context_id UUID,
|
||||
p_modality morbac.modality,
|
||||
p_exclude_id UUID DEFAULT NULL
|
||||
p_exclude_id UUID DEFAULT NULL,
|
||||
p_scope TEXT DEFAULT 'self'
|
||||
)
|
||||
RETURNS TABLE(
|
||||
conflicting_rule_id UUID,
|
||||
@@ -121,6 +122,7 @@ BEGIN
|
||||
AND r.activity = p_activity
|
||||
AND r.view = p_view
|
||||
AND r.context_id = p_context_id
|
||||
AND r.scope = p_scope
|
||||
AND r.modality != p_modality
|
||||
AND (p_exclude_id IS NULL OR r.id != p_exclude_id)
|
||||
AND (
|
||||
@@ -132,8 +134,8 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID) IS
|
||||
'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission).';
|
||||
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). 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
|
||||
|
||||
@@ -147,7 +149,8 @@ BEGIN
|
||||
FOR v_conflict IN
|
||||
SELECT * FROM morbac.detect_rule_conflicts(
|
||||
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
|
||||
RAISE WARNING 'Rule conflict: % (conflicts with rule %)',
|
||||
|
||||
+29
-20
@@ -1,25 +1,27 @@
|
||||
-- =============================================================================
|
||||
-- rls_check Tests
|
||||
-- =============================================================================
|
||||
-- Tests morbac.rls_check() with all session-org combinations, focusing on
|
||||
-- the two cases fixed to support global rows (p_row_org_id IS NULL):
|
||||
-- Tests morbac.rls_check() with all session-org combinations. A NULL row org
|
||||
-- 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
|
||||
-- 2. Single org context
|
||||
-- a. org-scoped row, matching org
|
||||
-- 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
|
||||
-- a. org-scoped row in list
|
||||
-- b. org-scoped row not in list (blocked)
|
||||
-- c. global row + global permission [was FALSE, now TRUE]
|
||||
-- d. global row + global prohibition [was FALSE, now correctly FALSE]
|
||||
-- e. global row + no rule [was FALSE, still FALSE]
|
||||
-- c. NULL row, list without null marker: filtered out even with a grant
|
||||
-- c2. NULL row, list with null marker + global permission: allowed
|
||||
-- d. NULL row, list with null marker + global prohibition: blocked
|
||||
-- e. NULL row, list with null marker + no rule: blocked
|
||||
-- 4. No org context
|
||||
-- a. org-scoped row: uses row's org
|
||||
-- b. global row + global permission [was FALSE, now TRUE]
|
||||
-- c. global row + global prohibition [was FALSE, now correctly FALSE]
|
||||
-- d. global row + no rule [was FALSE, still FALSE]
|
||||
-- b. NULL row + global permission [orphan + global rules -> TRUE]
|
||||
-- c. NULL row + global prohibition [blocked]
|
||||
-- d. NULL row + no rule [blocked]
|
||||
--
|
||||
-- User state carried from previous tests:
|
||||
-- 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),
|
||||
FALSE);
|
||||
|
||||
-- 2c: global row — uses session org, so Karl's user_rule on GlobalTech applies
|
||||
SELECT morbac.t('rls_check single org, global row (NULL org_id): uses session org',
|
||||
-- 2c: NULL row — filtered out under a single org pin (orphan not requested)
|
||||
SELECT morbac.t('rls_check single org, NULL row filtered out under org pin',
|
||||
morbac.rls_check('read', 'documents', NULL),
|
||||
TRUE);
|
||||
FALSE);
|
||||
|
||||
-- 2c (no permission): Karl has no rule for contracts in GlobalTech
|
||||
SELECT morbac.t('rls_check single org, global row (NULL org_id): no permission for contracts',
|
||||
-- 2c (contracts): still filtered out regardless of permission
|
||||
SELECT morbac.t('rls_check single org, NULL row filtered out (contracts)',
|
||||
morbac.rls_check('read', 'contracts', NULL),
|
||||
FALSE);
|
||||
|
||||
@@ -108,7 +110,7 @@ SELECT morbac.t('rls_check org_ids, org row not in list (blocked)',
|
||||
'10000000-0000-0000-0000-000000000002'::uuid),
|
||||
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)
|
||||
VALUES (
|
||||
'30000000-0000-0000-0000-000000000011', -- Karl
|
||||
@@ -117,7 +119,14 @@ VALUES (
|
||||
'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),
|
||||
TRUE);
|
||||
|
||||
@@ -125,7 +134,7 @@ DELETE FROM morbac.global_rules
|
||||
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
||||
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)
|
||||
VALUES (
|
||||
'30000000-0000-0000-0000-000000000011', -- Karl
|
||||
@@ -134,7 +143,7 @@ VALUES (
|
||||
'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),
|
||||
FALSE);
|
||||
|
||||
@@ -142,8 +151,8 @@ DELETE FROM morbac.global_rules
|
||||
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
||||
AND activity = 'read' AND view = 'contracts';
|
||||
|
||||
-- 3e: global row + no rule
|
||||
SELECT morbac.t('rls_check org_ids, global row + no rule',
|
||||
-- 3e: NULL row, list with null marker + no rule
|
||||
SELECT morbac.t('rls_check org_ids with null marker, NULL row + no rule',
|
||||
morbac.rls_check('read', 'contracts', NULL),
|
||||
FALSE);
|
||||
|
||||
|
||||
@@ -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 ==='
|
||||
Reference in New Issue
Block a user