Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d50676fb7 | |||
| cc3d65a013 | |||
| fa7567f5f0 |
+17
-3
@@ -5,10 +5,10 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2026-02-19
|
||||
## [1.0.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
- Initial release of Multi-OrBAC PostgreSQL extension
|
||||
- First stable release of the Multi-OrBAC PostgreSQL extension
|
||||
- Complete Multi-OrBAC implementation based on CNRS research paper
|
||||
- Core features:
|
||||
- Organization-centric access control
|
||||
@@ -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
|
||||
[1.0.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v1.0.0
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "pgmorbac",
|
||||
"abstract": "Multi-OrBAC: Organization-Based Access Control with multi-organization support",
|
||||
"description": "A PostgreSQL extension implementing the Multi-OrBAC access control model — enabling organization-centric, context-aware, and hierarchical access control with delegation, separation of duty, and cross-organizational policies.",
|
||||
"version": "0.1.0",
|
||||
"description": "A PostgreSQL extension implementing the Multi-OrBAC access control model \u2014 enabling organization-centric, context-aware, and hierarchical access control with delegation, separation of duty, and cross-organizational policies.",
|
||||
"version": "1.0.0",
|
||||
"maintainer": [
|
||||
"Marc VILLAIN <marc.villain@epita.fr>"
|
||||
],
|
||||
@@ -10,8 +10,8 @@
|
||||
"provides": {
|
||||
"pgmorbac": {
|
||||
"abstract": "Multi-OrBAC access control extension",
|
||||
"file": "pgmorbac--0.1.0.sql",
|
||||
"version": "0.1.0"
|
||||
"file": "pgmorbac--1.0.0.sql",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"prereqs": {
|
||||
|
||||
@@ -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
|
||||
@@ -58,11 +59,11 @@ sudo ./tools/install.sh
|
||||
|
||||
```bash
|
||||
# Build versioned file from source
|
||||
make build # Concatenates src/ files into pgmorbac--0.1.0.sql
|
||||
make build # Concatenates src/ files into pgmorbac--1.0.0.sql
|
||||
|
||||
# Copy files to PostgreSQL extension directory
|
||||
sudo cp pgmorbac.control $(pg_config --sharedir)/extension/
|
||||
sudo cp pgmorbac--0.1.0.sql $(pg_config --sharedir)/extension/
|
||||
sudo cp pgmorbac--1.0.0.sql $(pg_config --sharedir)/extension/
|
||||
|
||||
# Enable in PostgreSQL
|
||||
psql -d mydb -c "CREATE EXTENSION pgmorbac;"
|
||||
@@ -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
|
||||
|
||||
+149
-22
@@ -123,7 +123,7 @@ erDiagram
|
||||
|
||||
**morbac.contexts**: Contextual conditions as callable predicates. Column `evaluator` (REGPROC) references a function returning BOOLEAN (preferably STABLE). Built-in context `always` returns true.
|
||||
|
||||
**morbac.rules**: Core rules linking org, role, activity, view, context, modality, and scope. The `scope` column (default `'self'`) controls which orgs the rule covers relative to `org_id` — evaluated at query time so new child orgs are picked up automatically without re-inserting rules.
|
||||
**morbac.rules**: Core rules linking org, role, activity, view, context, modality, and scope. The `scope` column (default `'self'`) controls which orgs the rule covers relative to `org_id` - evaluated at query time so new child orgs are picked up automatically without re-inserting rules.
|
||||
|
||||
|
||||
### Advanced Feature Tables
|
||||
@@ -198,7 +198,7 @@ Inter-organizational access rules.
|
||||
|
||||
**morbac.system_principals**
|
||||
|
||||
Registry of backend service accounts. Registered user UUIDs are protected at the trigger level — no role assignment, rule, delegation, or prohibition can target them. Their permission rules in `global_rules` are equally immutable.
|
||||
Registry of backend service accounts. Registered user UUIDs are protected at the trigger level - no role assignment, rule, delegation, or prohibition can target them. Their permission rules in `global_rules` are equally immutable.
|
||||
|
||||
**Key columns:**
|
||||
- `user_id`: UUID of the service account
|
||||
@@ -218,7 +218,7 @@ System-wide rules with no org or role binding.
|
||||
- `modality`: Permission or prohibition
|
||||
- `priority`: Optional; same resolution semantics as `morbac.rules`
|
||||
|
||||
**Behavior:** Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in `is_allowed_nocache()`. NULL on `activity` or `view` matches any value — no hierarchy setup required for broad rules.
|
||||
**Behavior:** Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in `is_allowed_nocache()`. NULL on `activity` or `view` matches any value - no hierarchy setup required for broad rules.
|
||||
|
||||
**morbac.activity_view_bindings**
|
||||
|
||||
@@ -373,7 +373,7 @@ JOIN morbac.contexts c ON c.name = v.context_name;
|
||||
|
||||
### Organization Rule Scope
|
||||
|
||||
Every rule has a `scope` column (default `'self'`) that controls which organizations the rule covers relative to its `org_id`. Scope is **evaluated at query time** — adding a new child org to the hierarchy is enough for it to be covered by existing scoped rules. No rule re-creation needed.
|
||||
Every rule has a `scope` column (default `'self'`) that controls which organizations the rule covers relative to its `org_id`. Scope is **evaluated at query time** - adding a new child org to the hierarchy is enough for it to be covered by existing scoped rules. No rule re-creation needed.
|
||||
|
||||
| Scope | Covers |
|
||||
|---|---|
|
||||
@@ -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**
|
||||
|
||||
@@ -412,7 +414,107 @@ WHERE o.name = 'EMEA Region';
|
||||
|
||||
**Cache behavior:** The auth cache is fully invalidated whenever the org tree changes (`INSERT`/`UPDATE`/`DELETE` on `morbac.orgs`), so scoped rules are always consistent.
|
||||
|
||||
**`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
|
||||
|
||||
@@ -581,7 +683,7 @@ INSERT INTO morbac.cross_org_rules (
|
||||
SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TRUE
|
||||
```
|
||||
|
||||
**Scope vs. cross-org rules — when to use which:**
|
||||
**Scope vs. cross-org rules - when to use which:**
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
@@ -590,7 +692,7 @@ SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TR
|
||||
|
||||
### Global Rules
|
||||
|
||||
Global rules apply system-wide — no org or role required. Use them to define blanket access policies that cut across the entire org hierarchy.
|
||||
Global rules apply system-wide - no org or role required. Use them to define blanket access policies that cut across the entire org hierarchy.
|
||||
|
||||
**Table:** `morbac.global_rules`
|
||||
|
||||
@@ -634,7 +736,7 @@ Priority 100 ensures this prohibition overrides any role-based permission. To ex
|
||||
|
||||
### System Principals
|
||||
|
||||
Backend service accounts that must be fully immutable at the database level — no policy, no admin, no superadmin can touch them once registered.
|
||||
Backend service accounts that must be fully immutable at the database level - no policy, no admin, no superadmin can touch them once registered.
|
||||
|
||||
**Table:** `morbac.system_principals`
|
||||
|
||||
@@ -643,7 +745,7 @@ Backend service accounts that must be fully immutable at the database level —
|
||||
| `user_id` | UUID of the service account (external, from your auth system) |
|
||||
| `description` | Human-readable label |
|
||||
|
||||
**What is protected (trigger level — fires for all users including superusers):**
|
||||
**What is protected (trigger level - fires for all users including superusers):**
|
||||
|
||||
| Table | Blocked operations |
|
||||
|---|---|
|
||||
@@ -653,9 +755,9 @@ Backend service accounts that must be fully immutable at the database level —
|
||||
| `delegations` | INSERT, UPDATE involving the principal |
|
||||
| `global_rules` | All operations where `user_id` matches a system principal |
|
||||
|
||||
**Authorization behavior:** Prohibition evaluation (steps 1–3.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter.
|
||||
**Authorization behavior:** Prohibition evaluation (steps 1-3.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter.
|
||||
|
||||
**Ruleset:** Define permissions for system principals via `global_rules` at deploy time. Those rows are immutable once inserted — no one can modify or delete them. Use `activity=NULL, view=NULL` to grant full access, or restrict to specific activities/views:
|
||||
**Ruleset:** Define permissions for system principals via `global_rules` at deploy time. Those rows are immutable once inserted - no one can modify or delete them. Use `activity=NULL, view=NULL` to grant full access, or restrict to specific activities/views:
|
||||
|
||||
```sql
|
||||
-- Register the service account (DB owner only)
|
||||
@@ -673,23 +775,23 @@ VALUES (:service_uuid, 'read', NULL,
|
||||
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
|
||||
```
|
||||
|
||||
**Access control on the registry itself:** `morbac.system_principals` has a SELECT-only RLS policy — a user needs `is_allowed(..., 'read', 'system_principals')` to list them. INSERT/UPDATE/DELETE have no RLS policy, so they are blocked for all non-superusers automatically. Only the database owner can register or remove system principals.
|
||||
**Access control on the registry itself:** `morbac.system_principals` has a SELECT-only RLS policy - a user needs `is_allowed(..., 'read', 'system_principals')` to list them. INSERT/UPDATE/DELETE have no RLS policy, so they are blocked for all non-superusers automatically. Only the database owner can register or remove system principals.
|
||||
|
||||
### Administration
|
||||
|
||||
Admin operations use the same `is_allowed()` engine as everything else — no separate code path.
|
||||
Admin operations use the same `is_allowed()` engine as everything else - no separate code path.
|
||||
|
||||
**System table RLS**
|
||||
|
||||
`morbac.*` tables have RLS policies. `is_allowed()` and all its internal callees are `SECURITY DEFINER`, running as the extension owner and bypassing RLS. This breaks the recursion: RLS policies call `is_allowed()`, which queries morbac tables without re-triggering the policies.
|
||||
|
||||
The database owner (superuser) bypasses RLS by default — use that privilege only during bootstrap.
|
||||
The database owner (superuser) bypasses RLS by default - use that privilege only during bootstrap.
|
||||
|
||||
**System view names**
|
||||
|
||||
The extension seeds built-in activities (`create`, `read`, `update`, `delete`) and system view names (`orgs`, `roles`, `rules`, `user_roles`, `contexts`, `activities`, `views`, `delegations`, `cross_org_rules`, `user_rules`, `global_rules`, `system_principals`) at install time.
|
||||
|
||||
These names are config-driven. Override with `morbac.set_config()` to use your own naming conventions — the new name must then exist in `morbac.views` and your rules must reference it:
|
||||
These names are config-driven. Override with `morbac.set_config()` to use your own naming conventions - the new name must then exist in `morbac.views` and your rules must reference it:
|
||||
|
||||
```sql
|
||||
-- Rename 'rules' to 'policies' in your system
|
||||
@@ -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).
|
||||
@@ -867,7 +976,11 @@ SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
|
||||
|
||||
**`get_org_descendants(org_id)`**: Returns all child organizations with depth (including self at depth 0).
|
||||
|
||||
**`get_org_scope(org_id, scope, max_depth?)`**: Returns a named set of organizations relative to `org_id`. Scope values: `self`, `children`, `descendants`, `subtree`, `parent`, `ancestors`, `lineage`, `root`. Optional `max_depth` limits traversal depth.
|
||||
**`get_org_scope(org_id, scope, max_depth?)`**: Returns a named set of organizations relative to `org_id`. Scope values: `self`, `children`, `descendants`, `subtree`, `parent`, `ancestors`, `lineage`, `root`, plus `all` (every organization) and `unattributed` (no rows - an unattributed object has no org to return). Optional `max_depth` limits traversal depth.
|
||||
|
||||
**`org_in_scope(target_org_id, rule_org_id, scope)`**: Returns TRUE when a rule declared at `rule_org_id` with `scope` covers `target_org_id`. A NULL target is covered only by `unattributed` and `all`; the tree scopes never match one.
|
||||
|
||||
**`refresh_hierarchy_cache()`**: Rebuilds the materialized org, role, activity and view closures. Triggers call it whenever a hierarchy changes; call it manually after a bulk load.
|
||||
|
||||
```sql
|
||||
-- All orgs in the subtree, up to 2 levels deep
|
||||
@@ -898,6 +1011,8 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2);
|
||||
|
||||
**`eval_derived_role(evaluator, user_id, org_id)`**: Evaluate a derived role condition function (REGPROC). Returns BOOLEAN.
|
||||
|
||||
**`is_rule_valid(valid_from, valid_until)`**: Returns TRUE when a validity window covers the current timestamp. Every store applies it before a rule can match.
|
||||
|
||||
### Context Functions
|
||||
|
||||
**`eval_context(context_id)`**: Evaluate a context predicate.
|
||||
@@ -908,13 +1023,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 +1085,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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pgmorbac-release-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Release tooling for the pgmorbac PostgreSQL extension (build + sign + publish the PGXN distribution).",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# pgmorbac extension
|
||||
# Multi-OrBAC access control model for PostgreSQL
|
||||
comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support'
|
||||
default_version = '0.1.0'
|
||||
default_version = '1.0.0'
|
||||
relocatable = false
|
||||
schema = morbac
|
||||
|
||||
@@ -19,7 +19,7 @@ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity)
|
||||
AND NOT EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity AND view = NEW.view)
|
||||
THEN
|
||||
RAISE EXCEPTION 'Activity "%" is not allowed on view "%" — add a binding to morbac.activity_view_bindings to permit it',
|
||||
RAISE EXCEPTION 'Activity "%" is not allowed on view "%" - add a binding to morbac.activity_view_bindings to permit it',
|
||||
NEW.activity, NEW.view;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
|
||||
+2
-2
@@ -125,7 +125,7 @@ CREATE TRIGGER trg_invalidate_cache_user_rules
|
||||
AFTER INSERT OR UPDATE OR DELETE ON morbac.user_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_user_rule_change();
|
||||
|
||||
-- Global rules have no org scope — any change invalidates the entire cache
|
||||
-- Global rules have no org scope - any change invalidates the entire cache
|
||||
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_global_rule_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
@@ -141,7 +141,7 @@ CREATE TRIGGER trg_invalidate_cache_global_rules
|
||||
AFTER INSERT OR UPDATE OR DELETE ON morbac.global_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_global_rule_change();
|
||||
|
||||
-- system_principals changes affect prohibition bypass — invalidate per user
|
||||
-- system_principals changes affect prohibition bypass - invalidate per user
|
||||
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_system_principal_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
|
||||
+45
-12
@@ -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/...).
|
||||
-- Evaluated at query time via org_in_scope() — new orgs are covered automatically.
|
||||
-- - 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,
|
||||
@@ -38,10 +44,10 @@ BEGIN
|
||||
SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id
|
||||
);
|
||||
|
||||
-- STEPS 1-3.5: Prohibitions — skipped entirely for system principals
|
||||
-- STEPS 1-3.5: Prohibitions - skipped entirely for system principals
|
||||
IF NOT v_is_system_principal THEN
|
||||
|
||||
-- STEP 1: Local prohibitions — find the highest-priority applicable one
|
||||
-- STEP 1: Local prohibitions - find the highest-priority applicable one
|
||||
FOR v_rule IN
|
||||
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
|
||||
FROM morbac.rules r
|
||||
@@ -59,7 +65,7 @@ BEGIN
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- STEP 2: Cross-org prohibitions — update max if a higher priority is found
|
||||
-- STEP 2: Cross-org prohibitions - update max if a higher priority is found
|
||||
FOR v_rule IN
|
||||
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
|
||||
FROM morbac.cross_org_rules cr
|
||||
@@ -79,12 +85,12 @@ BEGIN
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- STEP 3: User-level prohibitions — direct user rules, update max if higher
|
||||
-- STEP 3: User-level prohibitions - direct user rules, update max if higher
|
||||
FOR v_rule IN
|
||||
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))
|
||||
@@ -120,7 +126,7 @@ BEGIN
|
||||
|
||||
END IF; -- v_is_system_principal
|
||||
|
||||
-- STEP 4: Local permissions — find the highest-priority applicable one
|
||||
-- STEP 4: Local permissions - find the highest-priority applicable one
|
||||
FOR v_rule IN
|
||||
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
|
||||
FROM morbac.rules r
|
||||
@@ -138,7 +144,7 @@ BEGIN
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- STEP 5: Cross-org permissions — update max if higher found
|
||||
-- STEP 5: Cross-org permissions - update max if higher found
|
||||
FOR v_rule IN
|
||||
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
|
||||
FROM morbac.cross_org_rules cr
|
||||
@@ -158,12 +164,12 @@ BEGIN
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- STEP 6: User-level permissions — direct user rules, update max if higher
|
||||
-- STEP 6: User-level permissions - direct user rules, update max if higher
|
||||
FOR v_rule IN
|
||||
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().';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Global rules: Rule(user_id, activity, view, context, modality)
|
||||
--
|
||||
-- No org_id or role_id — applies system-wide regardless of org membership or roles.
|
||||
-- No org_id or role_id - applies system-wide regardless of org membership or roles.
|
||||
-- user_id NULL = every user; non-NULL = specific user only.
|
||||
-- activity NULL = any activity; view NULL = any view.
|
||||
--
|
||||
|
||||
+35
-11
@@ -53,14 +53,17 @@ COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS
|
||||
-- Get a named scope of organizations relative to a given org.
|
||||
--
|
||||
-- Supported scopes:
|
||||
-- 'self' — the org itself only (depth = 0)
|
||||
-- 'children' — direct children only (descendants at depth = 1)
|
||||
-- 'descendants' — all descendants, excluding self (depth > 0)
|
||||
-- 'subtree' — self + all descendants (equivalent to get_org_descendants)
|
||||
-- 'parent' — direct parent only (ancestor at depth = 1)
|
||||
-- 'ancestors' — all ancestors, excluding self (depth > 0)
|
||||
-- 'lineage' — self + all ancestors (equivalent to get_org_ancestors)
|
||||
-- 'root' — topmost ancestor only (max depth ancestor)
|
||||
-- 'self' - the org itself only (depth = 0)
|
||||
-- 'children' - direct children only (descendants at depth = 1)
|
||||
-- 'descendants' - all descendants, excluding self (depth > 0)
|
||||
-- 'subtree' - self + all descendants (equivalent to get_org_descendants)
|
||||
-- 'parent' - direct parent only (ancestor at depth = 1)
|
||||
-- 'ancestors' - all ancestors, excluding self (depth > 0)
|
||||
-- 'lineage' - self + all ancestors (equivalent to get_org_ancestors)
|
||||
-- 'root' - topmost ancestor only (max depth ancestor)
|
||||
-- '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.';
|
||||
|
||||
+100
-22
@@ -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
|
||||
@@ -131,10 +197,11 @@ LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_org_id UUID;
|
||||
v_org_ids UUID[];
|
||||
v_target_user_id UUID;
|
||||
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.';
|
||||
|
||||
+15
-11
@@ -1,15 +1,19 @@
|
||||
-- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality)
|
||||
--
|
||||
-- scope controls which orgs this rule covers relative to org_id:
|
||||
-- 'self' — exact org only (default, current behavior)
|
||||
-- 'subtree' — org + all descendants
|
||||
-- 'descendants' — all descendants, excluding self
|
||||
-- 'children' — direct children only
|
||||
-- 'parent' — direct parent only
|
||||
-- 'ancestors' — all ancestors, excluding self
|
||||
-- 'lineage' — self + all ancestors
|
||||
-- 'root' — topmost ancestor only
|
||||
-- Evaluated at query time via org_in_scope() — new orgs are picked up automatically.
|
||||
-- 'self' - exact org only (default, current behavior)
|
||||
-- 'subtree' - org + all descendants
|
||||
-- 'descendants' - all descendants, excluding self
|
||||
-- 'children' - direct children only
|
||||
-- 'parent' - direct parent only
|
||||
-- '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.';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-- - No targeted global_rules prohibitions
|
||||
-- - All prohibitions are skipped in is_allowed_nocache() (see authorization.sql)
|
||||
--
|
||||
-- This table has no INSERT/UPDATE/DELETE RLS policies — only the database owner
|
||||
-- This table has no INSERT/UPDATE/DELETE RLS policies - only the database owner
|
||||
-- can register or remove system principals (done in SQL at deploy time).
|
||||
-- SELECT is gated by is_allowed() like all other system tables.
|
||||
|
||||
@@ -17,7 +17,7 @@ CREATE TABLE morbac.system_principals (
|
||||
);
|
||||
|
||||
COMMENT ON TABLE morbac.system_principals IS
|
||||
'Registry of backend service accounts. Immutable at the trigger level — no policy can touch them.';
|
||||
'Registry of backend service accounts. Immutable at the trigger level - no policy can touch them.';
|
||||
COMMENT ON COLUMN morbac.system_principals.user_id IS
|
||||
'External user UUID of the service account';
|
||||
|
||||
|
||||
+5
-5
@@ -85,7 +85,7 @@ CREATE POLICY user_roles_delete ON morbac.user_roles FOR DELETE
|
||||
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
|
||||
morbac.get_config('system_view.user_roles')));
|
||||
|
||||
-- morbac.contexts (global — use current session org for writes)
|
||||
-- morbac.contexts (global - use current session org for writes)
|
||||
ALTER TABLE morbac.contexts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY contexts_select ON morbac.contexts FOR SELECT
|
||||
@@ -104,7 +104,7 @@ CREATE POLICY contexts_delete ON morbac.contexts FOR DELETE
|
||||
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
|
||||
morbac.get_config('system_view.contexts')));
|
||||
|
||||
-- morbac.activities (global — use current session org for writes)
|
||||
-- morbac.activities (global - use current session org for writes)
|
||||
ALTER TABLE morbac.activities ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY activities_select ON morbac.activities FOR SELECT
|
||||
@@ -123,7 +123,7 @@ CREATE POLICY activities_delete ON morbac.activities FOR DELETE
|
||||
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
|
||||
morbac.get_config('system_view.activities')));
|
||||
|
||||
-- morbac.views (global — use current session org for writes)
|
||||
-- morbac.views (global - use current session org for writes)
|
||||
ALTER TABLE morbac.views ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY views_select ON morbac.views FOR SELECT
|
||||
@@ -180,14 +180,14 @@ CREATE POLICY user_rules_delete ON morbac.user_rules FOR DELETE
|
||||
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
|
||||
morbac.get_config('system_view.user_rules')));
|
||||
|
||||
-- morbac.system_principals (no org_id — SELECT only; INSERT/UPDATE/DELETE reserved for DB owner)
|
||||
-- morbac.system_principals (no org_id - SELECT only; INSERT/UPDATE/DELETE reserved for DB owner)
|
||||
ALTER TABLE morbac.system_principals ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY system_principals_select ON morbac.system_principals FOR SELECT
|
||||
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
|
||||
morbac.get_config('system_view.system_principals')));
|
||||
|
||||
-- morbac.global_rules (no org_id — use current session org for write checks)
|
||||
-- morbac.global_rules (no org_id - use current session org for write checks)
|
||||
ALTER TABLE morbac.global_rules ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY global_rules_select ON morbac.global_rules FOR SELECT
|
||||
|
||||
+11
-3
@@ -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,13 +24,18 @@ 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);
|
||||
CREATE INDEX idx_user_rules_lookup ON morbac.user_rules(user_id, org_id, activity, modality, view);
|
||||
|
||||
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.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 %)',
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ COMMENT ON TABLE morbac.view_hierarchy IS 'View hierarchy - senior views inherit
|
||||
COMMENT ON COLUMN morbac.view_hierarchy.senior_view IS 'Senior view (more specific)';
|
||||
COMMENT ON COLUMN morbac.view_hierarchy.junior_view IS 'Junior view (more general)';
|
||||
|
||||
-- Default system view names — match system_view.* config keys.
|
||||
-- Default system view names - match system_view.* config keys.
|
||||
-- Override config values to rename; the new name must be seeded here too.
|
||||
INSERT INTO morbac.views (name, description) VALUES
|
||||
('orgs', 'Organizations table'),
|
||||
|
||||
+23
-23
@@ -1,11 +1,11 @@
|
||||
-- =============================================================================
|
||||
-- Test Setup — GlobalTech Inc. Company Scenario
|
||||
-- Test Setup - GlobalTech Inc. Company Scenario
|
||||
-- =============================================================================
|
||||
-- This file establishes the full company structure used across all test files:
|
||||
--
|
||||
-- GlobalTech HQ (root)
|
||||
-- ├── Engineering Dept (child)
|
||||
-- └── Sales Dept (child)
|
||||
-- +-- Engineering Dept (child)
|
||||
-- \-- Sales Dept (child)
|
||||
--
|
||||
-- Role hierarchy in GlobalTech (senior -> junior, i.e. senior inherits junior perms):
|
||||
-- ceo -> director -> manager -> employee -> intern
|
||||
@@ -15,18 +15,18 @@
|
||||
-- tech_lead -> engineer
|
||||
--
|
||||
-- Users:
|
||||
-- Alice — CEO at GlobalTech
|
||||
-- Bob — Director at GlobalTech
|
||||
-- Carol — Manager at GlobalTech
|
||||
-- Dave — Employee at GlobalTech
|
||||
-- Eve — Intern at GlobalTech
|
||||
-- Frank — Contractor at GlobalTech
|
||||
-- Grace — HR Manager at GlobalTech
|
||||
-- Heidi — Auditor at GlobalTech
|
||||
-- Ivan — Accountant at GlobalTech
|
||||
-- Judy — Engineer at Engineering + Sales Rep at Sales (multi-org)
|
||||
-- Karl — No role (unauthorized user)
|
||||
-- Leo — Employee at GlobalTech (used for delegation target)
|
||||
-- Alice - CEO at GlobalTech
|
||||
-- Bob - Director at GlobalTech
|
||||
-- Carol - Manager at GlobalTech
|
||||
-- Dave - Employee at GlobalTech
|
||||
-- Eve - Intern at GlobalTech
|
||||
-- Frank - Contractor at GlobalTech
|
||||
-- Grace - HR Manager at GlobalTech
|
||||
-- Heidi - Auditor at GlobalTech
|
||||
-- Ivan - Accountant at GlobalTech
|
||||
-- Judy - Engineer at Engineering + Sales Rep at Sales (multi-org)
|
||||
-- Karl - No role (unauthorized user)
|
||||
-- Leo - Employee at GlobalTech (used for delegation target)
|
||||
-- =============================================================================
|
||||
|
||||
-- Clean slate
|
||||
@@ -55,7 +55,7 @@ WHERE id IN (
|
||||
'10000000-0000-0000-0000-000000000003'
|
||||
);
|
||||
|
||||
SELECT o.name, COALESCE(p.name, '—') AS parent
|
||||
SELECT o.name, COALESCE(p.name, ' - ') AS parent
|
||||
FROM morbac.orgs o LEFT JOIN morbac.orgs p ON o.parent_id = p.id
|
||||
ORDER BY o.parent_id NULLS FIRST, o.name;
|
||||
|
||||
@@ -122,7 +122,7 @@ JOIN morbac.roles jr ON jr.id = rh.junior_role_id
|
||||
ORDER BY sr.name;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- USER — ROLE ASSIGNMENTS
|
||||
-- USER - ROLE ASSIGNMENTS
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '=== Setup: User-Role Assignments ==='
|
||||
@@ -155,7 +155,7 @@ INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
|
||||
('30000000-0000-0000-0000-000000000010', '20000000-0002-0000-0000-000000000002', '10000000-0000-0000-0000-000000000002'),
|
||||
('30000000-0000-0000-0000-000000000010', '20000000-0003-0000-0000-000000000002', '10000000-0000-0000-0000-000000000003');
|
||||
|
||||
-- Karl: no role (unauthorized user — intentionally not assigned any role)
|
||||
-- Karl: no role (unauthorized user - intentionally not assigned any role)
|
||||
|
||||
SELECT ur.user_id, r.name AS role, o.name AS organization
|
||||
FROM morbac.user_roles ur
|
||||
@@ -214,7 +214,7 @@ BEGIN RETURN TRUE; END;
|
||||
$$;
|
||||
|
||||
INSERT INTO morbac.contexts (name, description, evaluator) VALUES
|
||||
('business_hours', 'During business hours (Mon–Fri 9–17)', 'morbac.ctx_business_hours'::regproc),
|
||||
('business_hours', 'During business hours (Mon-Fri 9-17)', 'morbac.ctx_business_hours'::regproc),
|
||||
('after_hours', 'Outside business hours', 'morbac.ctx_after_hours'::regproc),
|
||||
('end_of_quarter', 'End-of-quarter reporting window', 'morbac.ctx_end_of_quarter'::regproc);
|
||||
|
||||
@@ -274,7 +274,7 @@ JOIN morbac.contexts c ON c.name = v.context_name;
|
||||
-- Disable auth cache so state changes between calls are always reflected
|
||||
SELECT morbac.set_config('cache_ttl_seconds', '0');
|
||||
|
||||
-- morbac.t(label, actual, expected) — boolean assertion
|
||||
-- morbac.t(label, actual, expected) - boolean assertion
|
||||
CREATE OR REPLACE FUNCTION morbac.t(label TEXT, actual BOOLEAN, expect BOOLEAN)
|
||||
RETURNS TEXT LANGUAGE sql STABLE AS $$
|
||||
SELECT CASE WHEN actual IS NOT DISTINCT FROM expect
|
||||
@@ -284,7 +284,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- morbac.t_null(label, actual) — assert value is NULL
|
||||
-- morbac.t_null(label, actual) - assert value is NULL
|
||||
CREATE OR REPLACE FUNCTION morbac.t_null(label TEXT, actual TEXT)
|
||||
RETURNS TEXT LANGUAGE sql STABLE AS $$
|
||||
SELECT CASE WHEN actual IS NULL
|
||||
@@ -293,7 +293,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- morbac.t_not_null(label, actual) — assert value is NOT NULL
|
||||
-- morbac.t_not_null(label, actual) - assert value is NOT NULL
|
||||
CREATE OR REPLACE FUNCTION morbac.t_not_null(label TEXT, actual TEXT)
|
||||
RETURNS TEXT LANGUAGE sql STABLE AS $$
|
||||
SELECT CASE WHEN actual IS NOT NULL
|
||||
@@ -302,7 +302,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- morbac.t_eq(label, actual, expected) — assert two numeric values are equal
|
||||
-- morbac.t_eq(label, actual, expected) - assert two numeric values are equal
|
||||
CREATE OR REPLACE FUNCTION morbac.t_eq(label TEXT, actual NUMERIC, expect NUMERIC)
|
||||
RETURNS TEXT LANGUAGE sql STABLE AS $$
|
||||
SELECT CASE WHEN actual = expect
|
||||
|
||||
+40
-40
@@ -5,25 +5,25 @@
|
||||
-- No activity or view hierarchy is active yet (added in 02_hierarchies.sql).
|
||||
--
|
||||
-- Users and their roles at GlobalTech HQ:
|
||||
-- Alice — ceo (permission: inherits all via hierarchy)
|
||||
-- Bob — director (inherits manager -> employee -> intern)
|
||||
-- Carol — manager (inherits employee -> intern)
|
||||
-- Dave — employee (inherits intern)
|
||||
-- Eve — intern
|
||||
-- Frank — contractor (has prohibition on financial/hr data)
|
||||
-- Grace — hr_manager
|
||||
-- Heidi — auditor
|
||||
-- Ivan — accountant
|
||||
-- Judy — engineer@Engineering + sales_rep@Sales (multi-org)
|
||||
-- Karl — no role
|
||||
-- Leo — employee
|
||||
-- Alice - ceo (permission: inherits all via hierarchy)
|
||||
-- Bob - director (inherits manager -> employee -> intern)
|
||||
-- Carol - manager (inherits employee -> intern)
|
||||
-- Dave - employee (inherits intern)
|
||||
-- Eve - intern
|
||||
-- Frank - contractor (has prohibition on financial/hr data)
|
||||
-- Grace - hr_manager
|
||||
-- Heidi - auditor
|
||||
-- Ivan - accountant
|
||||
-- Judy - engineer@Engineering + sales_rep@Sales (multi-org)
|
||||
-- Karl - no role
|
||||
-- Leo - employee
|
||||
--
|
||||
-- Prerequisites: 00_setup.sql
|
||||
-- =============================================================================
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '01 — CORE AUTHORIZATION DECISIONS'
|
||||
\echo '01 - CORE AUTHORIZATION DECISIONS'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -32,7 +32,7 @@
|
||||
\echo ''
|
||||
\echo '--- 1. Basic permission grants ---'
|
||||
|
||||
-- Dave (employee) can read documents — has explicit permission
|
||||
-- Dave (employee) can read documents - has explicit permission
|
||||
SELECT morbac.t('Dave (employee) reads documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -40,7 +40,7 @@ SELECT morbac.t('Dave (employee) reads documents',
|
||||
'read', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Dave (employee) can write documents — context is business_hours (evaluates TRUE)
|
||||
-- Dave (employee) can write documents - context is business_hours (evaluates TRUE)
|
||||
SELECT morbac.t('Dave (employee) writes documents [business_hours context=true]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -89,12 +89,12 @@ SELECT morbac.t('Ivan (accountant) writes financial_data',
|
||||
), TRUE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 2: Default deny — no rule exists for the combination
|
||||
-- Section 2: Default deny - no rule exists for the combination
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 2. Default deny (no permission rule) ---'
|
||||
|
||||
-- Dave (employee) cannot delete documents — no delete permission for employee
|
||||
-- Dave (employee) cannot delete documents - no delete permission for employee
|
||||
SELECT morbac.t('Dave (employee) deletes documents [no permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -102,7 +102,7 @@ SELECT morbac.t('Dave (employee) deletes documents [no permission]',
|
||||
'delete', 'documents'
|
||||
), FALSE);
|
||||
|
||||
-- Eve (intern) cannot read documents — intern only has public_data permission
|
||||
-- Eve (intern) cannot read documents - intern only has public_data permission
|
||||
SELECT morbac.t('Eve (intern) reads documents [intern has no docs permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||
@@ -110,7 +110,7 @@ SELECT morbac.t('Eve (intern) reads documents [intern has no docs permission]',
|
||||
'read', 'documents'
|
||||
), FALSE);
|
||||
|
||||
-- Dave (employee) cannot read financial_data — no rule for employee -> financial_data
|
||||
-- Dave (employee) cannot read financial_data - no rule for employee -> financial_data
|
||||
SELECT morbac.t('Dave (employee) reads financial_data [no permission before view hierarchy]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -118,7 +118,7 @@ SELECT morbac.t('Dave (employee) reads financial_data [no permission before view
|
||||
'read', 'financial_data'
|
||||
), FALSE);
|
||||
|
||||
-- Grace (hr_manager) cannot read audit_logs — no rule for hr_manager -> audit_logs
|
||||
-- Grace (hr_manager) cannot read audit_logs - no rule for hr_manager -> audit_logs
|
||||
SELECT morbac.t('Grace (hr_manager) reads audit_logs [no permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000007'::uuid,
|
||||
@@ -126,7 +126,7 @@ SELECT morbac.t('Grace (hr_manager) reads audit_logs [no permission]',
|
||||
'read', 'audit_logs'
|
||||
), FALSE);
|
||||
|
||||
-- Dave (employee) cannot approve documents — no approve permission for employee
|
||||
-- Dave (employee) cannot approve documents - no approve permission for employee
|
||||
SELECT morbac.t('Dave (employee) approves documents [no permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -135,12 +135,12 @@ SELECT morbac.t('Dave (employee) approves documents [no permission]',
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 3: Default deny — user has no role at all
|
||||
-- Section 3: Default deny - user has no role at all
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 3. Default deny (user has no role) ---'
|
||||
|
||||
-- Karl has no role anywhere — all actions denied
|
||||
-- Karl has no role anywhere - all actions denied
|
||||
SELECT morbac.t('Karl (no role) reads documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||
@@ -148,7 +148,7 @@ SELECT morbac.t('Karl (no role) reads documents',
|
||||
'read', 'documents'
|
||||
), FALSE);
|
||||
|
||||
-- Completely unknown user UUID — EXPECT FALSE
|
||||
-- Completely unknown user UUID - EXPECT FALSE
|
||||
SELECT morbac.t('Unknown user reads documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid,
|
||||
@@ -162,7 +162,7 @@ SELECT morbac.t('Unknown user reads documents',
|
||||
\echo ''
|
||||
\echo '--- 4. Prohibition overrides permission ---'
|
||||
|
||||
-- Frank (contractor) reads documents — permission granted, no prohibition
|
||||
-- Frank (contractor) reads documents - permission granted, no prohibition
|
||||
SELECT morbac.t('Frank (contractor) reads documents [has permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000006'::uuid,
|
||||
@@ -170,7 +170,7 @@ SELECT morbac.t('Frank (contractor) reads documents [has permission]',
|
||||
'read', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Frank (contractor) reads financial_data — PROHIBITED
|
||||
-- Frank (contractor) reads financial_data - PROHIBITED
|
||||
SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000006'::uuid,
|
||||
@@ -178,7 +178,7 @@ SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]',
|
||||
'read', 'financial_data'
|
||||
), FALSE);
|
||||
|
||||
-- Frank (contractor) reads hr_data — prohibited
|
||||
-- Frank (contractor) reads hr_data - prohibited
|
||||
SELECT morbac.t('Frank (contractor) reads hr_data [prohibited]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000006'::uuid,
|
||||
@@ -211,7 +211,7 @@ WHERE org_id = '10000000-0000-0000-0000-000000000001'
|
||||
AND activity = 'read' AND view = 'financial_data' AND modality = 'permission';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 5: Context filtering — rule only applies when context is TRUE
|
||||
-- Section 5: Context filtering - rule only applies when context is TRUE
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 5. Context filtering ---'
|
||||
@@ -256,7 +256,7 @@ WHERE org_id = '10000000-0000-0000-0000-000000000001'
|
||||
AND activity = 'export' AND view = 'documents';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 6: Wrong organization — user has no role in the target org
|
||||
-- Section 6: Wrong organization - user has no role in the target org
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 6. Wrong organization ---'
|
||||
@@ -278,12 +278,12 @@ SELECT morbac.t('Judy (Engineering engineer) reads GlobalTech financial_data [no
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 7: Multi-organization user — access scoped to each org independently
|
||||
-- Section 7: Multi-organization user - access scoped to each org independently
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 7. Multi-org user (Judy) ---'
|
||||
|
||||
-- Judy is engineer at Engineering — can read documents there
|
||||
-- Judy is engineer at Engineering - can read documents there
|
||||
SELECT morbac.t('Judy (engineer) reads Engineering documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000010'::uuid,
|
||||
@@ -291,7 +291,7 @@ SELECT morbac.t('Judy (engineer) reads Engineering documents',
|
||||
'read', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Judy is sales_rep at Sales — can write contracts there
|
||||
-- Judy is sales_rep at Sales - can write contracts there
|
||||
SELECT morbac.t('Judy (sales_rep) writes Sales contracts',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000010'::uuid,
|
||||
@@ -313,7 +313,7 @@ SELECT morbac.t('Judy writes documents at Sales [engineer perms dont carry over]
|
||||
\echo ''
|
||||
\echo '--- 8. Role hierarchy inheritance ---'
|
||||
|
||||
-- Carol (manager) inherits employee permissions — can read documents (employee perm)
|
||||
-- Carol (manager) inherits employee permissions - can read documents (employee perm)
|
||||
SELECT morbac.t('Carol (manager, inherits employee) reads documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000003'::uuid,
|
||||
@@ -321,7 +321,7 @@ SELECT morbac.t('Carol (manager, inherits employee) reads documents',
|
||||
'read', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Carol (manager) has own permission — can approve documents
|
||||
-- Carol (manager) has own permission - can approve documents
|
||||
SELECT morbac.t('Carol (manager) approves documents [own permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000003'::uuid,
|
||||
@@ -329,7 +329,7 @@ SELECT morbac.t('Carol (manager) approves documents [own permission]',
|
||||
'approve', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Bob (director) inherits manager -> employee chain — can read documents
|
||||
-- Bob (director) inherits manager -> employee chain - can read documents
|
||||
SELECT morbac.t('Bob (director, inherits manager+employee) reads documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000002'::uuid,
|
||||
@@ -337,7 +337,7 @@ SELECT morbac.t('Bob (director, inherits manager+employee) reads documents',
|
||||
'read', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Bob (director) inherits manager — can approve documents
|
||||
-- Bob (director) inherits manager - can approve documents
|
||||
SELECT morbac.t('Bob (director, inherits manager) approves documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000002'::uuid,
|
||||
@@ -345,7 +345,7 @@ SELECT morbac.t('Bob (director, inherits manager) approves documents',
|
||||
'approve', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Alice (CEO) inherits the entire hierarchy — can do everything below
|
||||
-- Alice (CEO) inherits the entire hierarchy - can do everything below
|
||||
SELECT morbac.t('Alice (CEO, inherits all) reads documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000001'::uuid,
|
||||
@@ -367,7 +367,7 @@ SELECT morbac.t('Alice (CEO, inherits all) deletes documents',
|
||||
'delete', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Eve (intern) cannot approve — intern has no approve permission
|
||||
-- Eve (intern) cannot approve - intern has no approve permission
|
||||
SELECT morbac.t('Eve (intern) approves documents [intern has no approve permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||
@@ -464,7 +464,7 @@ SELECT morbac.t('Dave reads contracts: prohibition priority=10 beats permission
|
||||
-- Equal priorities: prohibition wins (modality tiebreaker)
|
||||
UPDATE morbac.rules SET priority = 5 WHERE id = 'e0000000-0000-0000-0000-000000000001';
|
||||
|
||||
SELECT morbac.t('Dave reads contracts: equal priority — prohibition wins by modality precedence [denied]',
|
||||
SELECT morbac.t('Dave reads contracts: equal priority - prohibition wins by modality precedence [denied]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||
@@ -477,7 +477,7 @@ UPDATE morbac.rules SET priority = NULL WHERE id IN (
|
||||
'e0000000-0000-0000-0000-000000000002'
|
||||
);
|
||||
|
||||
SELECT morbac.t('Dave reads contracts: no priority set — prohibition wins by default [denied]',
|
||||
SELECT morbac.t('Dave reads contracts: no priority set - prohibition wins by default [denied]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||
|
||||
+38
-38
@@ -2,10 +2,10 @@
|
||||
-- Hierarchy Tests
|
||||
-- =============================================================================
|
||||
-- Tests all four hierarchy types:
|
||||
-- 1. Role hierarchy — senior roles inherit permissions of junior roles (transitive)
|
||||
-- 2. Activity hierarchy — requesting a senior activity also matches junior-activity rules
|
||||
-- 3. View hierarchy — requesting a senior view also matches junior-view rules
|
||||
-- 4. Org hierarchy — get_org_ancestors / get_org_descendants traversal
|
||||
-- 1. Role hierarchy - senior roles inherit permissions of junior roles (transitive)
|
||||
-- 2. Activity hierarchy - requesting a senior activity also matches junior-activity rules
|
||||
-- 3. View hierarchy - requesting a senior view also matches junior-view rules
|
||||
-- 4. Org hierarchy - get_org_ancestors / get_org_descendants traversal
|
||||
--
|
||||
-- Hierarchy semantics in this system:
|
||||
-- Activity: (senior='write', junior='read') means get_effective_activities('write')
|
||||
@@ -19,50 +19,50 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '02 — HIERARCHIES'
|
||||
\echo '02 - HIERARCHIES'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: Role hierarchy — get_effective_roles and get_inherited_roles
|
||||
-- Section 1: Role hierarchy - get_effective_roles and get_inherited_roles
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. Role hierarchy introspection ---'
|
||||
|
||||
-- Eve (intern) direct assignment only — 1 effective role
|
||||
-- Eve (intern) direct assignment only - 1 effective role
|
||||
SELECT morbac.t_eq('Eve has 1 effective role (intern only)',
|
||||
(SELECT COUNT(*) FROM morbac.get_effective_roles(
|
||||
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
))::bigint, 1);
|
||||
|
||||
-- Dave (employee) has employee + intern via hierarchy — 2 effective roles
|
||||
-- Dave (employee) has employee + intern via hierarchy - 2 effective roles
|
||||
SELECT morbac.t_eq('Dave has 2 effective roles (employee, intern)',
|
||||
(SELECT COUNT(*) FROM morbac.get_effective_roles(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
))::bigint, 2);
|
||||
|
||||
-- Carol (manager) has manager + employee + intern via hierarchy — 3 effective roles
|
||||
-- Carol (manager) has manager + employee + intern via hierarchy - 3 effective roles
|
||||
SELECT morbac.t_eq('Carol has 3 effective roles (manager, employee, intern)',
|
||||
(SELECT COUNT(*) FROM morbac.get_effective_roles(
|
||||
'30000000-0000-0000-0000-000000000003'::uuid,
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
))::bigint, 3);
|
||||
|
||||
-- Alice (CEO) has ceo, director, manager, employee, intern — 5 effective roles
|
||||
-- Alice (CEO) has ceo, director, manager, employee, intern - 5 effective roles
|
||||
SELECT morbac.t_eq('Alice has 5 effective roles (ceo through intern)',
|
||||
(SELECT COUNT(*) FROM morbac.get_effective_roles(
|
||||
'30000000-0000-0000-0000-000000000001'::uuid,
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
))::bigint, 5);
|
||||
|
||||
-- get_inherited_roles for manager — manager itself + employee + intern = 3
|
||||
-- get_inherited_roles for manager - manager itself + employee + intern = 3
|
||||
SELECT morbac.t_eq('get_inherited_roles(manager) returns 3 roles (manager, employee, intern)',
|
||||
(SELECT COUNT(*) FROM morbac.get_inherited_roles(
|
||||
'20000000-0001-0000-0000-000000000003'::uuid
|
||||
))::bigint, 3);
|
||||
|
||||
-- get_inherited_roles for ceo — entire chain = 5
|
||||
-- get_inherited_roles for ceo - entire chain = 5
|
||||
SELECT morbac.t_eq('get_inherited_roles(ceo) returns 5 roles (ceo through intern)',
|
||||
(SELECT COUNT(*) FROM morbac.get_inherited_roles(
|
||||
'20000000-0001-0000-0000-000000000001'::uuid
|
||||
@@ -83,10 +83,10 @@ SELECT morbac.t('intern role appears in manager inherited roles',
|
||||
), TRUE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 2: Role hierarchy — authorization via inheritance (transitive)
|
||||
-- Section 2: Role hierarchy - authorization via inheritance (transitive)
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 2. Role hierarchy — authorization via inheritance ---'
|
||||
\echo '--- 2. Role hierarchy - authorization via inheritance ---'
|
||||
|
||||
-- Carol (manager) has employee permission (read documents) via 1-level inheritance
|
||||
SELECT morbac.t('Carol (manager, 1-level inherit) reads documents',
|
||||
@@ -128,7 +128,7 @@ SELECT morbac.t('Carol (manager) approves documents (own perm)',
|
||||
'approve', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Eve (intern) cannot approve — no role above intern has approve perm
|
||||
-- Eve (intern) cannot approve - no role above intern has approve perm
|
||||
SELECT morbac.t('Eve (intern) approves documents [inheritance only goes up in seniority]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||
@@ -306,7 +306,7 @@ SELECT morbac.t_eq('get_effective_activities(export) returns 2 (export, read)',
|
||||
-- Rule: employee has 'read documents' permission.
|
||||
-- With hierarchy (write->read), requesting 'write' also matches the 'read' rule.
|
||||
|
||||
-- Dave (employee) requests 'write' — matches 'read' rule via write->read hierarchy
|
||||
-- Dave (employee) requests 'write' - matches 'read' rule via write->read hierarchy
|
||||
SELECT morbac.t('Dave (employee, has read perm) writes docs via activity hierarchy',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -314,7 +314,7 @@ SELECT morbac.t('Dave (employee, has read perm) writes docs via activity hierarc
|
||||
'write', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Dave (employee) requests 'delete' — delete->write->read chain, 'read' rule matches
|
||||
-- Dave (employee) requests 'delete' - delete->write->read chain, 'read' rule matches
|
||||
SELECT morbac.t('Dave (employee, has read perm) deletes docs via delete->write->read hierarchy',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -322,7 +322,7 @@ SELECT morbac.t('Dave (employee, has read perm) deletes docs via delete->write->
|
||||
'delete', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Dave (employee) requests 'export' — export->read, 'read' rule matches
|
||||
-- Dave (employee) requests 'export' - export->read, 'read' rule matches
|
||||
SELECT morbac.t('Dave (employee, has read perm) exports docs via export->read hierarchy',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -330,7 +330,7 @@ SELECT morbac.t('Dave (employee, has read perm) exports docs via export->read hi
|
||||
'export', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Eve (intern) has only 'read public_data' — requesting 'write public_data' also matches
|
||||
-- Eve (intern) has only 'read public_data' - requesting 'write public_data' also matches
|
||||
SELECT morbac.t('Eve (intern, has read perm) writes public_data via activity hierarchy',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||
@@ -338,7 +338,7 @@ SELECT morbac.t('Eve (intern, has read perm) writes public_data via activity hie
|
||||
'write', 'public_data'
|
||||
), TRUE);
|
||||
|
||||
-- 'audit' is not in the hierarchy — no junior, no senior — no match for employee
|
||||
-- 'audit' is not in the hierarchy - no junior, no senior - no match for employee
|
||||
SELECT morbac.t('Dave (employee) audits documents [audit not in hierarchy, no permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -385,7 +385,7 @@ SELECT morbac.t('documents is in effective views of financial_data',
|
||||
-- requesting 'financial_data' -> get_effective_views('financial_data') = {financial_data, documents}
|
||||
-- the 'documents' rule matches -> access granted
|
||||
|
||||
-- Dave (employee) reads financial_data — matches employee's 'read documents' rule via view hierarchy
|
||||
-- Dave (employee) reads financial_data - matches employee's 'read documents' rule via view hierarchy
|
||||
SELECT morbac.t('Dave (employee, has read documents) reads financial_data via view hierarchy',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -393,7 +393,7 @@ SELECT morbac.t('Dave (employee, has read documents) reads financial_data via vi
|
||||
'read', 'financial_data'
|
||||
), TRUE);
|
||||
|
||||
-- Dave (employee) reads hr_data — matches 'read documents' via view hierarchy
|
||||
-- Dave (employee) reads hr_data - matches 'read documents' via view hierarchy
|
||||
SELECT morbac.t('Dave (employee, has read documents) reads hr_data via view hierarchy',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -401,7 +401,7 @@ SELECT morbac.t('Dave (employee, has read documents) reads hr_data via view hier
|
||||
'read', 'hr_data'
|
||||
), TRUE);
|
||||
|
||||
-- Frank (contractor) reads documents — permitted
|
||||
-- Frank (contractor) reads documents - permitted
|
||||
SELECT morbac.t('Frank (contractor) reads documents [has permission]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000006'::uuid,
|
||||
@@ -409,8 +409,8 @@ SELECT morbac.t('Frank (contractor) reads documents [has permission]',
|
||||
'read', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Frank (contractor) reads financial_data — matches contractor's 'read documents' rule via view hierarchy
|
||||
-- BUT contractor has a PROHIBITION on financial_data — prohibition wins
|
||||
-- Frank (contractor) reads financial_data - matches contractor's 'read documents' rule via view hierarchy
|
||||
-- BUT contractor has a PROHIBITION on financial_data - prohibition wins
|
||||
SELECT morbac.t('Frank (contractor) reads financial_data [prohibition overrides view-hierarchy match]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000006'::uuid,
|
||||
@@ -436,7 +436,7 @@ SELECT morbac.t_eq('Sales Dept has 2 ancestors (self + GlobalTech HQ)',
|
||||
'10000000-0000-0000-0000-000000000003'::uuid
|
||||
))::bigint, 2);
|
||||
|
||||
-- GlobalTech HQ is the root — only 1 ancestor (itself)
|
||||
-- GlobalTech HQ is the root - only 1 ancestor (itself)
|
||||
SELECT morbac.t_eq('GlobalTech HQ (root) has 1 ancestor (itself only)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_ancestors(
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
@@ -478,17 +478,17 @@ SELECT morbac.t('Dave (GlobalTech employee) reads Engineering docs [no cross-org
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 6: get_org_scope — named scope helper
|
||||
-- Section 6: get_org_scope - named scope helper
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Org tree used in tests:
|
||||
-- GlobalTech HQ (root) id: 10000000-0000-0000-0000-000000000001
|
||||
-- ├── Engineering Dept id: 10000000-0000-0000-0000-000000000002
|
||||
-- └── Sales Dept id: 10000000-0000-0000-0000-000000000003
|
||||
-- +-- Engineering Dept id: 10000000-0000-0000-0000-000000000002
|
||||
-- \-- Sales Dept id: 10000000-0000-0000-0000-000000000003
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 6. get_org_scope ---'
|
||||
|
||||
-- 'self' — always returns exactly the org itself
|
||||
-- 'self' - always returns exactly the org itself
|
||||
SELECT morbac.t_eq('scope self (root) returns 1 row',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'self'))::bigint, 1);
|
||||
|
||||
@@ -501,7 +501,7 @@ SELECT morbac.t('scope self returns the org itself at depth 0',
|
||||
WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 0
|
||||
), TRUE);
|
||||
|
||||
-- 'children' — direct children only (depth = 1 descendants)
|
||||
-- 'children' - direct children only (depth = 1 descendants)
|
||||
SELECT morbac.t_eq('scope children of root returns 2 rows (Engineering + Sales)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'children'))::bigint, 2);
|
||||
|
||||
@@ -520,7 +520,7 @@ SELECT morbac.t('scope children includes Engineering at depth 1',
|
||||
WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 1
|
||||
), TRUE);
|
||||
|
||||
-- 'descendants' — all descendants excluding self
|
||||
-- 'descendants' - all descendants excluding self
|
||||
SELECT morbac.t_eq('scope descendants of root returns 2 rows (Engineering + Sales, no self)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'descendants'))::bigint, 2);
|
||||
|
||||
@@ -533,7 +533,7 @@ SELECT morbac.t('scope descendants does not include self',
|
||||
SELECT morbac.t_eq('scope descendants of leaf returns 0 rows',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'descendants'))::bigint, 0);
|
||||
|
||||
-- 'subtree' — self + all descendants
|
||||
-- 'subtree' - self + all descendants
|
||||
SELECT morbac.t_eq('scope subtree of root returns 3 rows (self + Engineering + Sales)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree'))::bigint, 3);
|
||||
|
||||
@@ -546,7 +546,7 @@ SELECT morbac.t('scope subtree includes self at depth 0',
|
||||
SELECT morbac.t_eq('scope subtree of leaf returns 1 row (self only)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'subtree'))::bigint, 1);
|
||||
|
||||
-- 'parent' — direct parent only
|
||||
-- 'parent' - direct parent only
|
||||
SELECT morbac.t_eq('scope parent of Engineering returns 1 row (GlobalTech HQ)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'parent'))::bigint, 1);
|
||||
|
||||
@@ -559,7 +559,7 @@ SELECT morbac.t('scope parent of Engineering returns GlobalTech HQ at depth 1',
|
||||
SELECT morbac.t_eq('scope parent of root returns 0 rows (no parent)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'parent'))::bigint, 0);
|
||||
|
||||
-- 'ancestors' — all ancestors excluding self
|
||||
-- 'ancestors' - all ancestors excluding self
|
||||
SELECT morbac.t_eq('scope ancestors of Engineering returns 1 row (GlobalTech HQ only)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'ancestors'))::bigint, 1);
|
||||
|
||||
@@ -572,7 +572,7 @@ SELECT morbac.t('scope ancestors does not include self',
|
||||
SELECT morbac.t_eq('scope ancestors of root returns 0 rows',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'ancestors'))::bigint, 0);
|
||||
|
||||
-- 'lineage' — self + all ancestors
|
||||
-- 'lineage' - self + all ancestors
|
||||
SELECT morbac.t_eq('scope lineage of Engineering returns 2 rows (self + GlobalTech HQ)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'lineage'))::bigint, 2);
|
||||
|
||||
@@ -585,7 +585,7 @@ SELECT morbac.t('scope lineage includes self at depth 0',
|
||||
SELECT morbac.t_eq('scope lineage of root returns 1 row (self only)',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'lineage'))::bigint, 1);
|
||||
|
||||
-- 'root' — topmost ancestor only
|
||||
-- 'root' - topmost ancestor only
|
||||
SELECT morbac.t_eq('scope root of Engineering returns 1 row',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'root'))::bigint, 1);
|
||||
|
||||
@@ -604,7 +604,7 @@ SELECT morbac.t('scope root of root returns the org itself',
|
||||
WHERE org_id = '10000000-0000-0000-0000-000000000001'
|
||||
), TRUE);
|
||||
|
||||
-- p_max_depth — depth limiting
|
||||
-- p_max_depth - depth limiting
|
||||
SELECT morbac.t_eq('scope subtree max_depth=0 returns only self',
|
||||
(SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree', 0))::bigint, 1);
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '03 — DELEGATION'
|
||||
\echo '03 - DELEGATION'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: Baseline — Leo (employee) before any delegation
|
||||
-- Section 1: Baseline - Leo (employee) before any delegation
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. Baseline: Leo before delegation ---'
|
||||
@@ -37,7 +37,7 @@ SELECT morbac.t('Leo (employee) reads documents before delegation',
|
||||
'read', 'documents'
|
||||
), TRUE);
|
||||
|
||||
-- Leo cannot approve documents — that requires manager role
|
||||
-- Leo cannot approve documents - that requires manager role
|
||||
SELECT morbac.t('Leo (employee) approves documents before delegation [no manager perm]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000012'::uuid,
|
||||
@@ -66,7 +66,7 @@ SELECT morbac.t('Leo (employee) does not have approve permission before delegati
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 2: Active delegation — Carol delegates manager role to Leo
|
||||
-- Section 2: Active delegation - Carol delegates manager role to Leo
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 2. Active delegation: Carol -> Leo (manager role, 1 day) ---'
|
||||
@@ -83,7 +83,7 @@ VALUES (
|
||||
now() + interval '1 day'
|
||||
);
|
||||
|
||||
-- Leo now has delegated manager role — can approve documents
|
||||
-- Leo now has delegated manager role - can approve documents
|
||||
SELECT morbac.t('Leo (delegated manager) approves documents',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000012'::uuid,
|
||||
@@ -217,7 +217,7 @@ VALUES (
|
||||
TRUE -- revoked
|
||||
);
|
||||
|
||||
-- Dave should NOT get accountant from revoked delegation — verify revoked flag is set
|
||||
-- Dave should NOT get accountant from revoked delegation - verify revoked flag is set
|
||||
-- Note: employee already has write financial_data via view hierarchy;
|
||||
-- so we verify the delegation is actually revoked in the DB.
|
||||
SELECT morbac.t('Revoked accountant delegation has revoked=TRUE in DB',
|
||||
@@ -296,7 +296,7 @@ INSERT INTO morbac.delegations
|
||||
(id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until)
|
||||
VALUES (
|
||||
'de000001-0000-0000-0000-000000000005',
|
||||
'30000000-0000-0000-0000-000000000004', -- Dave (employee — does NOT hold manager)
|
||||
'30000000-0000-0000-0000-000000000004', -- Dave (employee - does NOT hold manager)
|
||||
'30000000-0000-0000-0000-000000000005', -- Eve
|
||||
'20000000-0001-0000-0000-000000000003', -- manager role
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
@@ -330,7 +330,7 @@ SELECT morbac.t('Invalid delegation not in Eve comprehensive roles',
|
||||
\echo ''
|
||||
\echo '--- 8. get_comprehensive_roles source reporting ---'
|
||||
|
||||
-- Alice (CEO, direct) — source should be 'direct'
|
||||
-- Alice (CEO, direct) - source should be 'direct'
|
||||
SELECT morbac.t('Alice CEO role has source=direct in comprehensive roles',
|
||||
EXISTS(
|
||||
SELECT 1 FROM morbac.get_comprehensive_roles(
|
||||
|
||||
+30
-30
@@ -2,10 +2,10 @@
|
||||
-- Constraint Tests
|
||||
-- =============================================================================
|
||||
-- Tests business constraints:
|
||||
-- 1. Separation of Duty (SoD) — mutually exclusive roles
|
||||
-- 2. Negative role assignments — explicit blocking of a role
|
||||
-- 3. Role cardinality constraints — min/max users per role
|
||||
-- 4. Rule conflict detection — modality conflicts on same tuple
|
||||
-- 1. Separation of Duty (SoD) - mutually exclusive roles
|
||||
-- 2. Negative role assignments - explicit blocking of a role
|
||||
-- 3. Role cardinality constraints - min/max users per role
|
||||
-- 4. Rule conflict detection - modality conflicts on same tuple
|
||||
--
|
||||
-- Scenario:
|
||||
-- - auditor and accountant are mutually exclusive (no one can hold both)
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '04 — CONSTRAINTS'
|
||||
\echo '04 - CONSTRAINTS'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: Separation of Duty — define conflict
|
||||
-- Section 1: Separation of Duty - define conflict
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. Separation of Duty setup ---'
|
||||
@@ -49,7 +49,7 @@ SELECT morbac.t_eq('SoD conflict between auditor and accountant created',
|
||||
\echo ''
|
||||
\echo '--- 2. SoD violation detection ---'
|
||||
|
||||
-- Heidi (auditor) — check if assigning accountant role would violate SoD
|
||||
-- Heidi (auditor) - check if assigning accountant role would violate SoD
|
||||
SELECT morbac.t('Assigning accountant to Heidi (auditor) violates SoD',
|
||||
morbac.check_sod_violation(
|
||||
'30000000-0000-0000-0000-000000000008'::uuid, -- Heidi
|
||||
@@ -57,7 +57,7 @@ SELECT morbac.t('Assigning accountant to Heidi (auditor) violates SoD',
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
), TRUE);
|
||||
|
||||
-- Ivan (accountant) — check if assigning auditor role would violate SoD
|
||||
-- Ivan (accountant) - check if assigning auditor role would violate SoD
|
||||
SELECT morbac.t('Assigning auditor to Ivan (accountant) violates SoD [symmetric]',
|
||||
morbac.check_sod_violation(
|
||||
'30000000-0000-0000-0000-000000000009'::uuid, -- Ivan
|
||||
@@ -65,8 +65,8 @@ SELECT morbac.t('Assigning auditor to Ivan (accountant) violates SoD [symmetric]
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
), TRUE);
|
||||
|
||||
-- Dave (employee) — check if assigning accountant would violate SoD
|
||||
-- Dave is not an auditor — no conflict
|
||||
-- Dave (employee) - check if assigning accountant would violate SoD
|
||||
-- Dave is not an auditor - no conflict
|
||||
SELECT morbac.t('Assigning accountant to Dave (not an auditor) does not violate SoD',
|
||||
morbac.check_sod_violation(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid, -- Dave
|
||||
@@ -74,7 +74,7 @@ SELECT morbac.t('Assigning accountant to Dave (not an auditor) does not violate
|
||||
'10000000-0000-0000-0000-000000000001'::uuid
|
||||
), FALSE);
|
||||
|
||||
-- Heidi (auditor) — assigning a non-conflicting role (manager) is fine
|
||||
-- Heidi (auditor) - assigning a non-conflicting role (manager) is fine
|
||||
SELECT morbac.t('Assigning manager to Heidi (auditor) does not violate SoD',
|
||||
morbac.check_sod_violation(
|
||||
'30000000-0000-0000-0000-000000000008'::uuid, -- Heidi
|
||||
@@ -126,7 +126,7 @@ VALUES (
|
||||
'Frank is a contractor and must not gain employee-level access'
|
||||
);
|
||||
|
||||
-- Frank's employee role is negated — verify via get_comprehensive_roles
|
||||
-- Frank's employee role is negated - verify via get_comprehensive_roles
|
||||
-- employee role must not appear (negated by negative assignment)
|
||||
SELECT morbac.t('Frank: employee role excluded by negative assignment',
|
||||
NOT EXISTS(
|
||||
@@ -148,7 +148,7 @@ SELECT morbac.t('Frank: contractor role still present after employee negated',
|
||||
), TRUE);
|
||||
|
||||
-- Negative assignment on a role the user never had is harmless
|
||||
-- Karl has no role — adding negative assignment for manager is a no-op
|
||||
-- Karl has no role - adding negative assignment for manager is a no-op
|
||||
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason)
|
||||
VALUES (
|
||||
'30000000-0000-0000-0000-000000000011', -- Karl
|
||||
@@ -181,7 +181,7 @@ WHERE user_id = '30000000-0000-0000-0000-000000000006'
|
||||
\echo ''
|
||||
\echo '--- 4. Role cardinality constraints ---'
|
||||
|
||||
-- Set a cardinality constraint: compliance_officer role — min 1, max 2
|
||||
-- Set a cardinality constraint: compliance_officer role - min 1, max 2
|
||||
INSERT INTO morbac.role_cardinality (role_id, min_users, max_users, description)
|
||||
VALUES (
|
||||
'20000000-0001-0000-0000-000000000010', -- compliance_officer
|
||||
@@ -189,8 +189,8 @@ VALUES (
|
||||
'Compliance officer role: at least 1, at most 2'
|
||||
);
|
||||
|
||||
-- Currently 0 users have compliance_officer — adding one should be fine (0 < max=2)
|
||||
SELECT morbac.t_null('Adding first compliance_officer (0 users, max=2) — no violation',
|
||||
-- Currently 0 users have compliance_officer - adding one should be fine (0 < max=2)
|
||||
SELECT morbac.t_null('Adding first compliance_officer (0 users, max=2) - no violation',
|
||||
morbac.check_cardinality_violation(
|
||||
'20000000-0001-0000-0000-000000000010'::uuid,
|
||||
TRUE -- adding
|
||||
@@ -201,15 +201,15 @@ INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
|
||||
('30000000-0000-0000-0000-000000000004', '20000000-0001-0000-0000-000000000010', '10000000-0000-0000-0000-000000000001'),
|
||||
('30000000-0000-0000-0000-000000000005', '20000000-0001-0000-0000-000000000010', '10000000-0000-0000-0000-000000000001');
|
||||
|
||||
-- Now 2 users — at max. Trying to add a 3rd should violate
|
||||
SELECT morbac.t_not_null('Adding 3rd compliance_officer (2 users, max=2) — violation returned',
|
||||
-- Now 2 users - at max. Trying to add a 3rd should violate
|
||||
SELECT morbac.t_not_null('Adding 3rd compliance_officer (2 users, max=2) - violation returned',
|
||||
morbac.check_cardinality_violation(
|
||||
'20000000-0001-0000-0000-000000000010'::uuid,
|
||||
TRUE -- adding
|
||||
));
|
||||
|
||||
-- Removing one — 2 users, min=1 — removing leaves 1 which is ≥ min=1, should be fine
|
||||
SELECT morbac.t_null('Removing from 2 compliance_officers (min=1) — no violation (still above min)',
|
||||
-- Removing one - 2 users, min=1 - removing leaves 1 which is >= min=1, should be fine
|
||||
SELECT morbac.t_null('Removing from 2 compliance_officers (min=1) - no violation (still above min)',
|
||||
morbac.check_cardinality_violation(
|
||||
'20000000-0001-0000-0000-000000000010'::uuid,
|
||||
FALSE -- removing
|
||||
@@ -221,21 +221,21 @@ WHERE user_id = '30000000-0000-0000-0000-000000000005'
|
||||
AND role_id = '20000000-0001-0000-0000-000000000010';
|
||||
|
||||
-- 1 user remaining = min. Removing the last one would violate min=1
|
||||
SELECT morbac.t_not_null('Removing last compliance_officer (1 user, min=1) — violation returned',
|
||||
SELECT morbac.t_not_null('Removing last compliance_officer (1 user, min=1) - violation returned',
|
||||
morbac.check_cardinality_violation(
|
||||
'20000000-0001-0000-0000-000000000010'::uuid,
|
||||
FALSE -- removing
|
||||
));
|
||||
|
||||
-- Adding again after being at 1 — 1 user, max=2 — ok
|
||||
SELECT morbac.t_null('Adding when at 1 compliance_officer (max=2) — no violation',
|
||||
-- Adding again after being at 1 - 1 user, max=2 - ok
|
||||
SELECT morbac.t_null('Adding when at 1 compliance_officer (max=2) - no violation',
|
||||
morbac.check_cardinality_violation(
|
||||
'20000000-0001-0000-0000-000000000010'::uuid,
|
||||
TRUE -- adding
|
||||
));
|
||||
|
||||
-- Role with no cardinality constraint — no violation for any operation
|
||||
SELECT morbac.t_null('Checking cardinality for employee role (no constraint) — no violation',
|
||||
-- Role with no cardinality constraint - no violation for any operation
|
||||
SELECT morbac.t_null('Checking cardinality for employee role (no constraint) - no violation',
|
||||
morbac.check_cardinality_violation(
|
||||
'20000000-0001-0000-0000-000000000004'::uuid, -- employee
|
||||
TRUE
|
||||
@@ -263,8 +263,8 @@ VALUES (
|
||||
'permission'
|
||||
);
|
||||
|
||||
-- No conflict yet — only a permission exists
|
||||
SELECT morbac.t_eq('detect_rule_conflicts: permission alone — no conflicts',
|
||||
-- No conflict yet - only a permission exists
|
||||
SELECT morbac.t_eq('detect_rule_conflicts: permission alone - no conflicts',
|
||||
(SELECT COUNT(*) FROM morbac.detect_rule_conflicts(
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
'20000000-0001-0000-0000-000000000004',
|
||||
@@ -309,7 +309,7 @@ SELECT morbac.t('detect_rule_conflicts: conflicting rule is the permission',
|
||||
WHERE conflicting_modality = 'permission'
|
||||
), TRUE);
|
||||
|
||||
-- Insert an obligation for the same tuple — conflicts with the prohibition
|
||||
-- Insert an obligation for the same tuple - conflicts with the prohibition
|
||||
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality)
|
||||
VALUES (
|
||||
'f0000000-0000-0000-0000-000000000003',
|
||||
@@ -331,7 +331,7 @@ SELECT morbac.t_eq('detect_rule_conflicts: obligation conflicts with existing pr
|
||||
))::bigint,
|
||||
1);
|
||||
|
||||
-- Insert a recommendation — conflicts with both obligation and prohibition
|
||||
-- Insert a recommendation - conflicts with both obligation and prohibition
|
||||
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality)
|
||||
VALUES (
|
||||
'f0000000-0000-0000-0000-000000000004',
|
||||
@@ -354,7 +354,7 @@ SELECT morbac.t_eq('detect_rule_conflicts: recommendation conflicts with prohibi
|
||||
2);
|
||||
|
||||
-- No conflict between permission and recommendation (they coexist meaningfully)
|
||||
SELECT morbac.t_eq('detect_rule_conflicts: permission vs recommendation — no conflict',
|
||||
SELECT morbac.t_eq('detect_rule_conflicts: permission vs recommendation - no conflict',
|
||||
(SELECT COUNT(*) FROM morbac.detect_rule_conflicts(
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
'20000000-0001-0000-0000-000000000004',
|
||||
|
||||
+14
-14
@@ -9,7 +9,7 @@
|
||||
-- 3. Expired rule (valid_until in the past) -> denied
|
||||
-- 4. Future rule (valid_from in the future) -> denied
|
||||
-- 5. Active time window (valid_from past, valid_until future) -> allowed
|
||||
-- 6. Multiple rules for same combination — only active ones count
|
||||
-- 6. Multiple rules for same combination - only active ones count
|
||||
-- 7. Expired prohibition: no longer blocks access after it expires
|
||||
-- 8. Temporal rules interact correctly with role hierarchy
|
||||
--
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '05 — TEMPORAL CONSTRAINTS'
|
||||
\echo '05 - TEMPORAL CONSTRAINTS'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -37,35 +37,35 @@ INSERT INTO morbac.views (name, description) VALUES
|
||||
\echo '--- 1. is_rule_valid() helper ---'
|
||||
|
||||
-- No bounds: always valid
|
||||
SELECT morbac.t('is_rule_valid(NULL, NULL) — always valid',
|
||||
SELECT morbac.t('is_rule_valid(NULL, NULL) - always valid',
|
||||
morbac.is_rule_valid(NULL::timestamptz, NULL::timestamptz), TRUE);
|
||||
|
||||
-- Past valid_from, no valid_until: currently active
|
||||
SELECT morbac.t('is_rule_valid(past, NULL) — started in past, no end',
|
||||
SELECT morbac.t('is_rule_valid(past, NULL) - started in past, no end',
|
||||
morbac.is_rule_valid('2000-01-01'::timestamptz, NULL), TRUE);
|
||||
|
||||
-- Future valid_from: not yet active
|
||||
SELECT morbac.t('is_rule_valid(future, NULL) — not yet started',
|
||||
SELECT morbac.t('is_rule_valid(future, NULL) - not yet started',
|
||||
morbac.is_rule_valid('2099-01-01'::timestamptz, NULL), FALSE);
|
||||
|
||||
-- Past valid_until: expired
|
||||
SELECT morbac.t('is_rule_valid(NULL, past) — already expired',
|
||||
SELECT morbac.t('is_rule_valid(NULL, past) - already expired',
|
||||
morbac.is_rule_valid(NULL, '2000-01-01'::timestamptz), FALSE);
|
||||
|
||||
-- Future valid_until, no valid_from: currently active
|
||||
SELECT morbac.t('is_rule_valid(NULL, future) — no start, future end',
|
||||
SELECT morbac.t('is_rule_valid(NULL, future) - no start, future end',
|
||||
morbac.is_rule_valid(NULL, '2099-01-01'::timestamptz), TRUE);
|
||||
|
||||
-- Active window: past start, future end
|
||||
SELECT morbac.t('is_rule_valid(past, future) — within active window',
|
||||
SELECT morbac.t('is_rule_valid(past, future) - within active window',
|
||||
morbac.is_rule_valid('2000-01-01'::timestamptz, '2099-01-01'::timestamptz), TRUE);
|
||||
|
||||
-- Fully past window (both start and end in the past)
|
||||
SELECT morbac.t('is_rule_valid(past_start, past_end) — entirely expired',
|
||||
SELECT morbac.t('is_rule_valid(past_start, past_end) - entirely expired',
|
||||
morbac.is_rule_valid('2000-01-01'::timestamptz, '2001-01-01'::timestamptz), FALSE);
|
||||
|
||||
-- Fully future window (both start and end in the future)
|
||||
SELECT morbac.t('is_rule_valid(future_start, future_end) — entirely in the future',
|
||||
SELECT morbac.t('is_rule_valid(future_start, future_end) - entirely in the future',
|
||||
morbac.is_rule_valid('2090-01-01'::timestamptz, '2099-01-01'::timestamptz), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -105,7 +105,7 @@ SELECT morbac.t('is_active = FALSE after valid_until set to past',
|
||||
DELETE FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 3: Expired rule — valid_until in the past
|
||||
-- Section 3: Expired rule - valid_until in the past
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 3. Expired rule (valid_until in the past) ---'
|
||||
@@ -129,7 +129,7 @@ SELECT morbac.t('Dave (employee) exports temp_view via expired rule [denied]',
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 4: Future rule — valid_from in the future
|
||||
-- Section 4: Future rule - valid_from in the future
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 4. Future rule (valid_from in the future) ---'
|
||||
@@ -209,7 +209,7 @@ SELECT morbac.t('Dave exports temp_view (export rule now active)',
|
||||
), TRUE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 7: Temporal prohibition — expired prohibition no longer blocks
|
||||
-- Section 7: Temporal prohibition - expired prohibition no longer blocks
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 7. Expired prohibition no longer blocks ---'
|
||||
@@ -295,7 +295,7 @@ SELECT morbac.t('Carol (manager) approves temp_view [temporal rule, active]',
|
||||
'approve', 'temp_view'
|
||||
), TRUE);
|
||||
|
||||
-- Alice (CEO) inherits from manager — should also get the temporal permission
|
||||
-- Alice (CEO) inherits from manager - should also get the temporal permission
|
||||
SELECT morbac.t('Alice (CEO, inherits manager) approves temp_view [temporal rule, active]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000001'::uuid,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '06 — CROSS-ORGANIZATIONAL RULES'
|
||||
\echo '06 - CROSS-ORGANIZATIONAL RULES'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -53,7 +53,7 @@ SELECT morbac.t('Nina has eng_auditor role at Engineering',
|
||||
), TRUE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: No cross-org rule — access between orgs is denied by default
|
||||
-- Section 1: No cross-org rule - access between orgs is denied by default
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. No cross-org rule: access denied by default ---'
|
||||
@@ -75,7 +75,7 @@ SELECT morbac.t('Judy (Engineering engineer) reads GlobalTech financial_data [no
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 2: Cross-org permission — Sales sales_rep reads GlobalTech reports
|
||||
-- Section 2: Cross-org permission - Sales sales_rep reads GlobalTech reports
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 2. Cross-org permission ---'
|
||||
@@ -123,7 +123,7 @@ SELECT morbac.t('Judy (Sales sales_rep) reads GlobalTech documents [no rule for
|
||||
\echo ''
|
||||
\echo '--- 3. Role must be held in source org ---'
|
||||
|
||||
-- Karl has no role anywhere — cannot use the Sales->GlobalTech cross-org rule
|
||||
-- Karl has no role anywhere - cannot use the Sales->GlobalTech cross-org rule
|
||||
SELECT morbac.t('Karl (no role) reads GlobalTech reports via cross-org rule [no role in source]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||
@@ -140,7 +140,7 @@ SELECT morbac.t('Karl (no role) reads GlobalTech documents [no access anywhere]'
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 4: Cross-org prohibition — blocks access even with regular permission
|
||||
-- Section 4: Cross-org prohibition - blocks access even with regular permission
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 4. Cross-org prohibition ---'
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '07 — AUDIT LOGGING'
|
||||
\echo '07 - AUDIT LOGGING'
|
||||
\echo '================================================================'
|
||||
|
||||
-- Clear any existing audit log entries to start fresh
|
||||
@@ -160,7 +160,7 @@ DELETE FROM morbac.rules WHERE id = 'a0000000-0000-0000-0000-000000000001';
|
||||
\echo ''
|
||||
\echo '--- 5. Query audit log by record_id ---'
|
||||
|
||||
-- The test rule had INSERT, UPDATE, DELETE — should be 3 entries
|
||||
-- The test rule had INSERT, UPDATE, DELETE - should be 3 entries
|
||||
SELECT morbac.t_eq('Audit log has 3 entries for test rule record (INSERT + UPDATE + DELETE)',
|
||||
(SELECT COUNT(*) FROM morbac.audit_log
|
||||
WHERE table_name = 'rules'
|
||||
@@ -245,7 +245,7 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Perform another user_roles change — should NOT be logged
|
||||
-- Perform another user_roles change - should NOT be logged
|
||||
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
|
||||
VALUES (
|
||||
'30000000-0000-0000-0000-000000000011',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
-- 2. Grant permissions via regular rules, verify access
|
||||
-- 3. Prohibition overrides permission (standard engine behavior)
|
||||
-- 4. Role hierarchy applies: senior role inherits permissions
|
||||
-- 5. assign_role() / revoke_role() — SoD/cardinality enforcement, RLS guards the INSERT/DELETE
|
||||
-- 5. assign_role() / revoke_role() - SoD/cardinality enforcement, RLS guards the INSERT/DELETE
|
||||
-- 6. RLS on morbac tables: session user cannot read/write without rules
|
||||
-- 7. Rules are org-scoped
|
||||
--
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '08 — SYSTEM ACCESS'
|
||||
\echo '08 - SYSTEM ACCESS'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -218,7 +218,7 @@ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO morbac_rls_tester;
|
||||
|
||||
SET SESSION AUTHORIZATION morbac_rls_tester;
|
||||
|
||||
-- Grace (hr_manager) has create/delete on user_roles — RLS should allow
|
||||
-- Grace (hr_manager) has create/delete on user_roles - RLS should allow
|
||||
SET morbac.user_id = '30000000-0000-0000-0000-000000000007';
|
||||
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
|
||||
|
||||
@@ -241,7 +241,7 @@ WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
||||
AND role_id = '20000000-0001-0000-0000-000000000005'
|
||||
AND org_id = '10000000-0000-0000-0000-000000000001';
|
||||
|
||||
-- Dave (employee) has no rules for user_roles — RLS should block
|
||||
-- Dave (employee) has no rules for user_roles - RLS should block
|
||||
SET morbac.user_id = '30000000-0000-0000-0000-000000000004';
|
||||
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
|
||||
|
||||
|
||||
+11
-11
@@ -3,14 +3,14 @@
|
||||
-- =============================================================================
|
||||
-- Tests miscellaneous utility functions and advanced features:
|
||||
--
|
||||
-- 1. pending_obligations — returns obligation rules for a user
|
||||
-- 2. pending_recommendations — returns recommendation rules for a user
|
||||
-- 1. pending_obligations - returns obligation rules for a user
|
||||
-- 2. pending_recommendations - returns recommendation rules for a user
|
||||
-- 3. Obligations/recommendations do NOT affect is_allowed()
|
||||
-- 3b. Conflict resolution: prohibition voids obligation; prohibition/obligation voids recommendation
|
||||
-- 4. user_has_role — checks if user holds a named role
|
||||
-- 5. user_roles_in_org — lists all roles for user in org
|
||||
-- 6. eval_context — evaluates context predicates directly
|
||||
-- 7. Derived roles — computed via evaluator function
|
||||
-- 4. user_has_role - checks if user holds a named role
|
||||
-- 5. user_roles_in_org - lists all roles for user in org
|
||||
-- 6. eval_context - evaluates context predicates directly
|
||||
-- 7. Derived roles - computed via evaluator function
|
||||
-- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping
|
||||
--
|
||||
-- Prerequisites: 00_setup.sql -> 08_system_access.sql
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '09 — UTILITIES, DERIVED ROLES, RLS'
|
||||
\echo '09 - UTILITIES, DERIVED ROLES, RLS'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -139,7 +139,7 @@ SELECT morbac.t('Dave has recommendation for read audit_logs but no permission [
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 3b: Conflict resolution — prohibition voids obligation/recommendation
|
||||
-- Section 3b: Conflict resolution - prohibition voids obligation/recommendation
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 3b. Conflict resolution: prohibition voids obligation and recommendation ---'
|
||||
@@ -330,13 +330,13 @@ SELECT morbac.t('eval_context(end_of_quarter) = TRUE',
|
||||
\echo ''
|
||||
\echo '--- 7. Derived roles ---'
|
||||
|
||||
-- Create a derived role: 'senior_employee' — dynamically granted to Dave (only)
|
||||
-- Create a derived role: 'senior_employee' - dynamically granted to Dave (only)
|
||||
INSERT INTO morbac.roles (id, org_id, name, description)
|
||||
VALUES (
|
||||
'20000000-0001-0000-0000-000000000011',
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
'senior_employee',
|
||||
'Senior employee — granted dynamically based on tenure'
|
||||
'Senior employee - granted dynamically based on tenure'
|
||||
);
|
||||
|
||||
-- Evaluator function: returns TRUE only for Dave at GlobalTech
|
||||
@@ -379,7 +379,7 @@ SELECT morbac.t('Dave has senior_employee derived role in comprehensive roles',
|
||||
AND source = 'derived'
|
||||
), TRUE);
|
||||
|
||||
-- Eve (intern) does NOT satisfy the evaluator — no derived role
|
||||
-- Eve (intern) does NOT satisfy the evaluator - no derived role
|
||||
SELECT morbac.t('Eve has no derived roles in comprehensive roles',
|
||||
NOT EXISTS(
|
||||
SELECT 1 FROM morbac.get_comprehensive_roles(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
-- Activity-View Binding Tests
|
||||
-- =============================================================================
|
||||
-- Tests opt-in activity-to-view restrictions:
|
||||
-- 1. No bindings defined — any view is allowed
|
||||
-- 2. Binding defined — listed view is allowed, unlisted view is blocked
|
||||
-- 1. No bindings defined - any view is allowed
|
||||
-- 2. Binding defined - listed view is allowed, unlisted view is blocked
|
||||
-- 3. Blocking applies to cross_org_rules as well
|
||||
-- 4. Removing all bindings lifts the restriction
|
||||
--
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '10 — ACTIVITY-VIEW BINDINGS'
|
||||
\echo '10 - ACTIVITY-VIEW BINDINGS'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: No bindings — unconstrained
|
||||
-- Section 1: No bindings - unconstrained
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. No bindings: any view is allowed ---'
|
||||
@@ -39,7 +39,7 @@ SELECT morbac.t('No bindings: audit/documents rule inserted successfully',
|
||||
DELETE FROM morbac.rules WHERE id = 'b0000000-0000-0000-0000-000000000001';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 2: Binding defined — listed view allowed, unlisted view blocked
|
||||
-- Section 2: Binding defined - listed view allowed, unlisted view blocked
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 2. Binding defined: allowed view works, unlisted view blocked ---'
|
||||
|
||||
+13
-13
@@ -3,18 +3,18 @@
|
||||
-- =============================================================================
|
||||
-- Tests rules.scope and cross_org_rules.source_org_id = NULL:
|
||||
--
|
||||
-- 1. scope='self' (default) — exact org only, unchanged behavior
|
||||
-- 2. scope='subtree' — rule at root covers self + Engineering + Sales
|
||||
-- 3. scope='descendants' — covers Engineering + Sales but NOT GlobalTech itself
|
||||
-- 4. scope='children' — covers direct children only
|
||||
-- 5. New org added after rule creation — picked up automatically (cache invalidation)
|
||||
-- 1. scope='self' (default) - exact org only, unchanged behavior
|
||||
-- 2. scope='subtree' - rule at root covers self + Engineering + Sales
|
||||
-- 3. scope='descendants' - covers Engineering + Sales but NOT GlobalTech itself
|
||||
-- 4. scope='children' - covers direct children only
|
||||
-- 5. New org added after rule creation - picked up automatically (cache invalidation)
|
||||
--
|
||||
-- Prerequisites: 00_setup.sql -> 10_activity_view_bindings.sql
|
||||
-- =============================================================================
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '11 — SCOPE RULES AND GLOBAL CROSS-ORG RULES'
|
||||
\echo '11 - SCOPE RULES AND GLOBAL CROSS-ORG RULES'
|
||||
\echo '================================================================'
|
||||
|
||||
-- Setup: create a dedicated role for scope tests (avoid polluting existing rules)
|
||||
@@ -23,7 +23,7 @@ VALUES (
|
||||
'20000000-0001-0000-0000-000000000012',
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
'analyst',
|
||||
'Data analyst — scope tests'
|
||||
'Data analyst - scope tests'
|
||||
);
|
||||
|
||||
-- Assign Karl (previously no role) as analyst at GlobalTech
|
||||
@@ -35,7 +35,7 @@ VALUES (
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: scope='self' (default) — exact org only
|
||||
-- Section 1: scope='self' (default) - exact org only
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. scope=self (default) ---'
|
||||
@@ -67,7 +67,7 @@ SELECT morbac.t('Karl (analyst, scope=self) reads reports in Engineering [denied
|
||||
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000001';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 2: scope='subtree' — root + all descendants
|
||||
-- Section 2: scope='subtree' - root + all descendants
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 2. scope=subtree ---'
|
||||
@@ -106,7 +106,7 @@ SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in Sales [allowed]'
|
||||
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000002';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 3: scope='descendants' — children only, NOT self
|
||||
-- Section 3: scope='descendants' - children only, NOT self
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 3. scope=descendants ---'
|
||||
@@ -145,7 +145,7 @@ SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in Sales [allow
|
||||
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000003';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 4: scope='children' — direct children only
|
||||
-- Section 4: scope='children' - direct children only
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 4. scope=children ---'
|
||||
@@ -193,7 +193,7 @@ DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000004';
|
||||
DELETE FROM morbac.orgs WHERE id = '10000000-0000-0000-0000-000000000004';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 5: New org added after rule creation — scope picks it up automatically
|
||||
-- Section 5: New org added after rule creation - scope picks it up automatically
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 5. Dynamic scope: new org covered automatically ---'
|
||||
@@ -225,7 +225,7 @@ VALUES (
|
||||
'10000000-0000-0000-0000-000000000001'
|
||||
);
|
||||
|
||||
-- The scoped rule was defined before Legal Dept existed — still covers it
|
||||
-- The scoped rule was defined before Legal Dept existed - still covers it
|
||||
SELECT morbac.t('Karl (analyst) reads documents in Legal Dept [new org, covered by subtree scope]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '12 — USER RULES'
|
||||
\echo '12 - USER RULES'
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: No user rule — Karl (no role) is denied
|
||||
-- Section 1: No user rule - Karl (no role) is denied
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. No user rule: access denied ---'
|
||||
@@ -41,7 +41,7 @@ SELECT morbac.t('Karl (no role) reads GlobalTech financial_data [no user rule]',
|
||||
), FALSE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 2: Direct user permission — Karl gets access without a role
|
||||
-- Section 2: Direct user permission - Karl gets access without a role
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 2. Direct user permission ---'
|
||||
@@ -78,7 +78,7 @@ SELECT morbac.t('Karl reads GlobalTech financial_data [documents rule covers it
|
||||
'read', 'financial_data'
|
||||
), TRUE);
|
||||
|
||||
-- contracts has no hierarchy relationship — documents rule does not cover it
|
||||
-- contracts has no hierarchy relationship - documents rule does not cover it
|
||||
SELECT morbac.t('Karl reads GlobalTech contracts [no user rule, no hierarchy coverage]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||
@@ -141,7 +141,7 @@ SELECT morbac.t('Eve (intern) reads GlobalTech public_data [user prohibition blo
|
||||
'read', 'public_data'
|
||||
), FALSE);
|
||||
|
||||
-- Dave (employee) is unaffected — only Eve has the prohibition
|
||||
-- Dave (employee) is unaffected - only Eve has the prohibition
|
||||
SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibition]',
|
||||
morbac.is_allowed_nocache(
|
||||
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||
@@ -150,7 +150,7 @@ SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibiti
|
||||
), TRUE);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 5: Priority — higher-priority user permission overrides prohibition
|
||||
-- Section 5: Priority - higher-priority user permission overrides prohibition
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 5. Priority: user permission overrides user prohibition ---'
|
||||
@@ -236,7 +236,7 @@ SELECT morbac.t('rls_check passes without target_user_id filter',
|
||||
'30000000-0000-0000-0000-000000000004'::uuid
|
||||
), TRUE);
|
||||
|
||||
-- Set target_user_id to Dave — rows belonging to Dave pass
|
||||
-- Set target_user_id to Dave - rows belonging to Dave pass
|
||||
SET morbac.target_user_id = '30000000-0000-0000-0000-000000000004';
|
||||
|
||||
SELECT morbac.t('rls_check passes when row user_id matches target_user_id',
|
||||
@@ -254,7 +254,7 @@ SELECT morbac.t('rls_check blocked when row user_id differs from target_user_id'
|
||||
'30000000-0000-0000-0000-000000000001'::uuid
|
||||
), FALSE);
|
||||
|
||||
-- No p_row_user_id passed — user filter does not apply
|
||||
-- No p_row_user_id passed - user filter does not apply
|
||||
SELECT morbac.t('rls_check passes when no row user_id passed (filter skipped)',
|
||||
morbac.rls_check(
|
||||
'read', 'documents',
|
||||
|
||||
+36
-27
@@ -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):
|
||||
@@ -35,7 +37,7 @@
|
||||
\echo '================================================================'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section 1: No user_id set — always FALSE
|
||||
-- Section 1: No user_id set - always FALSE
|
||||
-- ---------------------------------------------------------------------------
|
||||
\echo ''
|
||||
\echo '--- 1. No user_id: always FALSE ---'
|
||||
@@ -62,25 +64,25 @@ SELECT morbac.t('rls_check without user_id, global row',
|
||||
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
|
||||
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ
|
||||
|
||||
-- 2a: org-scoped row, matching org — Karl has user_rule for read documents
|
||||
-- 2a: org-scoped row, matching org - Karl has user_rule for read documents
|
||||
SELECT morbac.t('rls_check single org, org row matches session org (Karl/documents)',
|
||||
morbac.rls_check('read', 'documents',
|
||||
'10000000-0000-0000-0000-000000000001'::uuid),
|
||||
TRUE);
|
||||
|
||||
-- 2b: org-scoped row, different org — blocked before is_allowed
|
||||
-- 2b: org-scoped row, different org - blocked before is_allowed
|
||||
SELECT morbac.t('rls_check single org, org row from different org (blocked)',
|
||||
morbac.rls_check('read', 'documents',
|
||||
'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);
|
||||
|
||||
@@ -96,19 +98,19 @@ RESET morbac.org_id;
|
||||
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
|
||||
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001"]'; -- [GlobalTech HQ]
|
||||
|
||||
-- 3a: org-scoped row in the list — Karl has user_rule for read documents in GlobalTech
|
||||
-- 3a: org-scoped row in the list - Karl has user_rule for read documents in GlobalTech
|
||||
SELECT morbac.t('rls_check org_ids, org row in list (Karl/documents/GlobalTech)',
|
||||
morbac.rls_check('read', 'documents',
|
||||
'10000000-0000-0000-0000-000000000001'::uuid),
|
||||
TRUE);
|
||||
|
||||
-- 3b: org-scoped row not in the list — blocked
|
||||
-- 3b: org-scoped row not in the list - blocked
|
||||
SELECT morbac.t('rls_check org_ids, org row not in list (blocked)',
|
||||
morbac.rls_check('read', 'documents',
|
||||
'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);
|
||||
|
||||
@@ -158,7 +167,7 @@ RESET morbac.org_ids;
|
||||
|
||||
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
|
||||
|
||||
-- 4a: org-scoped row — uses row's org_id (Karl has user_rule in GlobalTech)
|
||||
-- 4a: org-scoped row - uses row's org_id (Karl has user_rule in GlobalTech)
|
||||
SELECT morbac.t('rls_check no org context, org row: uses row org (Karl/documents/GlobalTech)',
|
||||
morbac.rls_check('read', 'documents',
|
||||
'10000000-0000-0000-0000-000000000001'::uuid),
|
||||
@@ -170,7 +179,7 @@ SELECT morbac.t('rls_check no org context, org row: no permission in row org (En
|
||||
'10000000-0000-0000-0000-000000000002'::uuid),
|
||||
FALSE);
|
||||
|
||||
-- 4b: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only
|
||||
-- 4b: NULL row + global permission - is_allowed(Karl, NULL, ...) sees unattributed + global rules
|
||||
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
|
||||
VALUES (
|
||||
'30000000-0000-0000-0000-000000000011', -- Karl
|
||||
|
||||
@@ -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