Compare commits

...

3 Commits

Author SHA1 Message Date
marc 4d50676fb7 release: 1.0.0
First stable release. Bumps the control file, META.json and the release
tooling, and renames the install script to pgmorbac--1.0.0.sql.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 07:45:06 +02:00
marc cc3d65a013 docs: complete function reference, drop unicode punctuation
Documents refresh_hierarchy_cache, is_rule_valid and org_in_scope, and
corrects the get_org_scope scope list which still omitted unattributed
and all.

Replaces em dashes and other typographic unicode with ASCII throughout
the schema comments, the test suite and the documentation. Comments and
prose are ASCII only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 07:26:54 +02:00
marc fa7567f5f0 feat(scope): unattributed and all org targets
Adds a first-class org target vocabulary shared by every rule kind: a
specific organization, unattributed (objects whose org is NULL), or all.
A role can now be granted the unassigned pile without a global rule.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:35:03 +02:00
33 changed files with 902 additions and 338 deletions
+17 -3
View File
@@ -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/), 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). 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 ### 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 - Complete Multi-OrBAC implementation based on CNRS research paper
- Core features: - Core features:
- Organization-centric access control - 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 - Comprehensive test suite with 20 test scenarios
- Complete documentation - Complete documentation
- Build and installation automation (Makefile, install.sh) - Build and installation automation (Makefile, install.sh)
- Unattributed (no-org) object support:
- Org target vocabulary shared by every rule kind: a specific organization,
`unattributed` (objects with no org), or `all` (every org, unattributed included)
- `rules.scope` values `unattributed` and `all`
- `user_rules.org_id` accepts NULL to target unattributed objects
- `org_in_scope()` partitions the two object classes: `unattributed` matches only
a NULL target, tree scopes never match one
- `morbac.has_permission(user, activity, view)` capability probe for UI gating
- `morbac.current_org_filter()` parses `morbac.org_ids` once into org UUIDs plus
the unattributed-bucket flag (a JSON `null` element requests it)
- `rls_check()` split by arity so NULL never carries two meanings:
`rls_check(activity, view)` for tables with no org column,
`rls_check(activity, view, row_org_id[, row_user_id])` for row-scoped tables
where a NULL `row_org_id` means the record is unattributed
[0.1.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v0.1.0 [1.0.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v1.0.0
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"name": "pgmorbac", "name": "pgmorbac",
"abstract": "Multi-OrBAC: Organization-Based Access Control with multi-organization support", "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.", "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": "0.1.0", "version": "1.0.0",
"maintainer": [ "maintainer": [
"Marc VILLAIN <marc.villain@epita.fr>" "Marc VILLAIN <marc.villain@epita.fr>"
], ],
@@ -10,8 +10,8 @@
"provides": { "provides": {
"pgmorbac": { "pgmorbac": {
"abstract": "Multi-OrBAC access control extension", "abstract": "Multi-OrBAC access control extension",
"file": "pgmorbac--0.1.0.sql", "file": "pgmorbac--1.0.0.sql",
"version": "0.1.0" "version": "1.0.0"
} }
}, },
"prereqs": { "prereqs": {
+42 -3
View File
@@ -10,6 +10,7 @@ A PostgreSQL extension implementing the Multi-OrBAC access control model - enabl
## Features ## Features
- Multi-organization with organizational hierarchy - Multi-organization with organizational hierarchy
- Unattributed (no-org) objects as a first-class rule target
- Role-based access with full hierarchy support - Role-based access with full hierarchy support
- Activity and view hierarchies with transitive permission inheritance - Activity and view hierarchies with transitive permission inheritance
- Prohibition precedence over permissions - Prohibition precedence over permissions
@@ -58,11 +59,11 @@ sudo ./tools/install.sh
```bash ```bash
# Build versioned file from source # 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 # Copy files to PostgreSQL extension directory
sudo cp pgmorbac.control $(pg_config --sharedir)/extension/ 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 # Enable in PostgreSQL
psql -d mydb -c "CREATE EXTENSION pgmorbac;" psql -d mydb -c "CREATE EXTENSION pgmorbac;"
@@ -117,6 +118,12 @@ SELECT morbac.is_allowed(user_id, org_id, activity, view);
-- Debugging (bypasses cache) -- Debugging (bypasses cache)
SELECT morbac.is_allowed_nocache(user_id, org_id, activity, view); SELECT morbac.is_allowed_nocache(user_id, org_id, activity, view);
-- Unattributed object (no org): pass NULL as the org
SELECT morbac.is_allowed(user_id, NULL, activity, view);
-- Capability probe for UI gating (any org, unattributed, or global)
SELECT morbac.has_permission(user_id, activity, view);
``` ```
See [PERFORMANCE.md](docs/PERFORMANCE.md) for optimization details. See [PERFORMANCE.md](docs/PERFORMANCE.md) for optimization details.
@@ -127,12 +134,44 @@ See [PERFORMANCE.md](docs/PERFORMANCE.md) for optimization details.
-- Enable RLS on your table -- Enable RLS on your table
ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY; ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY;
-- Create policy using Multi-OrBAC -- Table without an org column
CREATE POLICY doc_access ON app.documents CREATE POLICY doc_access ON app.documents
FOR SELECT FOR SELECT
USING (morbac.rls_check('read', 'documents')); USING (morbac.rls_check('read', 'documents'));
-- Table with an org column: pass it. A NULL org_id means the record
-- is unattributed (awaiting attribution).
CREATE POLICY doc_access ON app.documents
FOR SELECT
USING (morbac.rls_check('read', 'documents', org_id));
``` ```
### Unattributed (no-org) Records
A record whose `org_id` is `NULL` is *unattributed*. Give a role access to that pool
without granting anything org-wide:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT o.id, r.id, 'read', 'documents', c.id, 'permission', 'unattributed'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'triage'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'Acme Corp';
```
Choose which records a query returns:
```sql
SET morbac.org_ids = '[null]'; -- unattributed only (attribution queue)
SET morbac.org_ids = '["<org-uuid>", null]';-- that org plus unattributed
SET morbac.org_id = '<org-uuid>'; -- that org only
-- nothing set -- everything authorized, unattributed included
```
See [DOCUMENTATION.md](docs/DOCUMENTATION.md) for the full org target vocabulary
(a specific organization, `unattributed`, or `all`).
### Advanced Features ### Advanced Features
```sql ```sql
+149 -22
View File
@@ -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.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 ### Advanced Feature Tables
@@ -198,7 +198,7 @@ Inter-organizational access rules.
**morbac.system_principals** **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:** **Key columns:**
- `user_id`: UUID of the service account - `user_id`: UUID of the service account
@@ -218,7 +218,7 @@ System-wide rules with no org or role binding.
- `modality`: Permission or prohibition - `modality`: Permission or prohibition
- `priority`: Optional; same resolution semantics as `morbac.rules` - `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** **morbac.activity_view_bindings**
@@ -373,7 +373,7 @@ JOIN morbac.contexts c ON c.name = v.context_name;
### Organization Rule Scope ### 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 | | 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 | | `'ancestors'` | All ancestors, excluding the rule's org itself |
| `'lineage'` | The rule's org + all ancestors | | `'lineage'` | The rule's org + all ancestors |
| `'root'` | Topmost ancestor of the rule's org | | `'root'` | Topmost ancestor of the rule's org |
| `'unattributed'` | Objects with **no** org (`org_id IS NULL`) only |
| `'all'` | Every organization, unattributed objects included |
**Example: Analyst reads reports across the whole company** **Example: Analyst reads reports across the whole company**
@@ -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. **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 ### Hierarchies
@@ -581,7 +683,7 @@ INSERT INTO morbac.cross_org_rules (
SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TRUE 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 | | Need | Use |
|---|---| |---|---|
@@ -590,7 +692,7 @@ SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TR
### Global Rules ### 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` **Table:** `morbac.global_rules`
@@ -634,7 +736,7 @@ Priority 100 ensures this prohibition overrides any role-based permission. To ex
### System Principals ### 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` **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) | | `user_id` | UUID of the service account (external, from your auth system) |
| `description` | Human-readable label | | `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 | | 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 | | `delegations` | INSERT, UPDATE involving the principal |
| `global_rules` | All operations where `user_id` matches a system principal | | `global_rules` | All operations where `user_id` matches a system principal |
**Authorization behavior:** Prohibition evaluation (steps 13.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 ```sql
-- Register the service account (DB owner only) -- 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'); (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 ### 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** **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. `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** **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. 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 ```sql
-- Rename 'rules' to 'policies' in your system -- Rename 'rules' to 'policies' in your system
@@ -851,10 +953,17 @@ WHERE table_name = 'rules'
### Authorization Functions ### Authorization Functions
**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny. Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests). **`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny. `org_id` is a specific organization, or `NULL` when the object is unattributed (no org) - `NULL` never means "any org". Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests). Unattributed decisions are not cached.
```sql ```sql
SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents'); SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
SELECT morbac.is_allowed(user_uuid, NULL, 'read', 'documents'); -- unattributed object
```
**`has_permission(user_id, activity, view)`**: Capability probe for UI gating. Returns TRUE if the user is allowed in any member org, the unattributed bucket, or via global rules. Not a substitute for object-level `is_allowed()`.
```sql
SELECT morbac.has_permission(user_uuid, 'read', 'documents');
``` ```
**`get_comprehensive_roles(user_id, org_id)`**: Returns all roles for user (direct, delegated, derived, hierarchy, minus negative assignments). **`get_comprehensive_roles(user_id, org_id)`**: Returns all roles for user (direct, delegated, derived, hierarchy, minus negative assignments).
@@ -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_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 ```sql
-- All orgs in the subtree, up to 2 levels deep -- 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. **`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 ### Context Functions
**`eval_context(context_id)`**: Evaluate a context predicate. **`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')`. **`current_org_id()`**: Get org ID from `request.header.x-org-id` (PostgREST) or `current_setting('morbac.org_id')`.
**`rls_check(activity, view)`**: Authorization check for RLS policies using current user/org context. **`current_org_filter()`**: Parses `morbac.org_ids` once, returning `org_ids` (the real org UUIDs) and `include_unattributed` (TRUE when the array holds a JSON `null` element).
**`current_org_ids()`**: Convenience wrapper returning only the real org UUIDs from `current_org_filter()`.
**`rls_check(activity, view)`**: Authorization check for RLS policies on tables **without** an org column. Uses the session org context.
```sql ```sql
CREATE POLICY my_policy ON app.table CREATE POLICY my_policy ON app.table
FOR SELECT USING (morbac.rls_check('read', 'documents')); FOR SELECT USING (morbac.rls_check('read', 'documents'));
``` ```
**`rls_check(activity, view, row_org_id [, row_user_id])`**: Row-scoped check for tables **with** an org column. `row_org_id` is the record's org; a `NULL` value means the record is unattributed. The arity distinguishes the two cases so `NULL` never carries two meanings.
```sql
CREATE POLICY my_policy ON app.documents
FOR SELECT USING (morbac.rls_check('read', 'documents', org_id));
```
### Informational Functions ### Informational Functions
**`pending_obligations(user_id, org_id)`**: Returns obligations for user (informational only). **`pending_obligations(user_id, org_id)`**: Returns obligations for user (informational only).
@@ -959,13 +1085,14 @@ WITH CHECK (morbac.rls_check('write', 'documents', org_id));
#### Org scoping modes #### Org scoping modes
`rls_check` resolves the org scope from session variables in priority order: The row-scoped `rls_check` resolves which records to return from session variables in priority order. A JSON `null` element in `morbac.org_ids` names the unattributed (no-org) bucket:
| Session variable | Behaviour | | Session variable | Behaviour |
|---|---| |---|---|
| `morbac.org_id` set | scoped to that single org | | `morbac.org_id` set | that single org - unattributed excluded |
| `morbac.org_ids` set | scoped to the provided list of orgs | | `morbac.org_ids` set | the listed orgs; a `null` element adds unattributed records |
| neither set | all orgs the user belongs to | | `morbac.org_ids = '[null]'` | unattributed records only |
| neither set | all authorized records - every org **and** unattributed |
#### Setting context from HTTP headers #### Setting context from HTTP headers
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pgmorbac-release-tools", "name": "pgmorbac-release-tools",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Release tooling for the pgmorbac PostgreSQL extension (build + sign + publish the PGXN distribution).", "description": "Release tooling for the pgmorbac PostgreSQL extension (build + sign + publish the PGXN distribution).",
+1 -1
View File
@@ -1,6 +1,6 @@
# pgmorbac extension # pgmorbac extension
# Multi-OrBAC access control model for PostgreSQL # Multi-OrBAC access control model for PostgreSQL
comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support' comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support'
default_version = '0.1.0' default_version = '1.0.0'
relocatable = false relocatable = false
schema = morbac schema = morbac
+1 -1
View File
@@ -19,7 +19,7 @@ BEGIN
IF EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity) 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) AND NOT EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity AND view = NEW.view)
THEN 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; NEW.activity, NEW.view;
END IF; END IF;
RETURN NEW; RETURN NEW;
+2 -2
View File
@@ -125,7 +125,7 @@ CREATE TRIGGER trg_invalidate_cache_user_rules
AFTER INSERT OR UPDATE OR DELETE ON morbac.user_rules AFTER INSERT OR UPDATE OR DELETE ON morbac.user_rules
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_user_rule_change(); 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() CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_global_rule_change()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -141,7 +141,7 @@ CREATE TRIGGER trg_invalidate_cache_global_rules
AFTER INSERT OR UPDATE OR DELETE ON morbac.global_rules AFTER INSERT OR UPDATE OR DELETE ON morbac.global_rules
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_global_rule_change(); 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() CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_system_principal_change()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
+45 -12
View File
@@ -11,11 +11,17 @@
-- - When both a prohibition and a permission apply, the higher-priority rule wins -- - When both a prohibition and a permission apply, the higher-priority rule wins
-- - Tie goes to prohibition (modality precedence from the Multi-OrBAC paper) -- - Tie goes to prohibition (modality precedence from the Multi-OrBAC paper)
-- --
-- Org target (p_org_id): a specific org, or NULL meaning the object is
-- unattributed (has no org). NULL never means "any org".
--
-- Scope: -- Scope:
-- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...). -- - rules.scope selects which objects a rule covers: a specific org
-- Evaluated at query time via org_in_scope() — new orgs are covered automatically. -- (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. -- - cross_org_rules.source_org_id is always required: user must hold the role there.
-- - user_rules target a specific user directly (no role required). -- - user_rules target a specific user directly (no role required); their org_id
-- is a specific org, or NULL for unattributed objects.
-- - global_rules apply to every org, unattributed included.
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
p_user_id UUID, p_user_id UUID,
@@ -38,10 +44,10 @@ BEGIN
SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id 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 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 FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio SELECT r.context_id, COALESCE(r.priority, 0) AS prio
FROM morbac.rules r FROM morbac.rules r
@@ -59,7 +65,7 @@ BEGIN
END IF; END IF;
END LOOP; 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 FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr FROM morbac.cross_org_rules cr
@@ -79,12 +85,12 @@ BEGIN
END IF; END IF;
END LOOP; 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 FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id = p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition' AND ur.modality = 'prohibition'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
@@ -120,7 +126,7 @@ BEGIN
END IF; -- v_is_system_principal 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 FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio SELECT r.context_id, COALESCE(r.priority, 0) AS prio
FROM morbac.rules r FROM morbac.rules r
@@ -138,7 +144,7 @@ BEGIN
END IF; END IF;
END LOOP; 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 FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr FROM morbac.cross_org_rules cr
@@ -158,12 +164,12 @@ BEGIN
END IF; END IF;
END LOOP; 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 FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id WHERE ur.user_id = p_user_id
AND ur.org_id = p_org_id AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission' AND ur.modality = 'permission'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
@@ -275,3 +281,30 @@ $$;
COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS
'Complete OrBAC authorization with caching (default) - use is_allowed_nocache() for debugging'; 'Complete OrBAC authorization with caching (default) - use is_allowed_nocache() for debugging';
-- Capability probe: does the user hold the permission in ANY context (any org
-- they are a member of, or the no-org bucket, or via global rules)? Intended
-- for UI feature gating, not object-level enforcement. Prohibitions are honored
-- per context: a context counts only if is_allowed() returns true there.
-- Pure cross-org grants into a non-member org are not counted.
CREATE OR REPLACE FUNCTION morbac.has_permission(
p_user_id UUID,
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM morbac.get_user_orgs(p_user_id) o
WHERE morbac.is_allowed(p_user_id, o.org_id, p_activity, p_view)
) OR morbac.is_allowed(p_user_id, NULL, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.has_permission(UUID, TEXT, TEXT) IS
'Capability probe for UI gating: TRUE if the user is allowed the activity/view in any member org, the no-org bucket, or via global rules. Not a substitute for object-level is_allowed().';
+1 -1
View File
@@ -1,6 +1,6 @@
-- Global rules: Rule(user_id, activity, view, context, modality) -- 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. -- user_id NULL = every user; non-NULL = specific user only.
-- activity NULL = any activity; view NULL = any view. -- activity NULL = any activity; view NULL = any view.
-- --
+35 -11
View File
@@ -53,14 +53,17 @@ COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS
-- Get a named scope of organizations relative to a given org. -- Get a named scope of organizations relative to a given org.
-- --
-- Supported scopes: -- Supported scopes:
-- 'self' the org itself only (depth = 0) -- 'self' - the org itself only (depth = 0)
-- 'children' direct children only (descendants at depth = 1) -- 'children' - direct children only (descendants at depth = 1)
-- 'descendants' all descendants, excluding self (depth > 0) -- 'descendants' - all descendants, excluding self (depth > 0)
-- 'subtree' self + all descendants (equivalent to get_org_descendants) -- 'subtree' - self + all descendants (equivalent to get_org_descendants)
-- 'parent' direct parent only (ancestor at depth = 1) -- 'parent' - direct parent only (ancestor at depth = 1)
-- 'ancestors' all ancestors, excluding self (depth > 0) -- 'ancestors' - all ancestors, excluding self (depth > 0)
-- 'lineage' self + all ancestors (equivalent to get_org_ancestors) -- 'lineage' - self + all ancestors (equivalent to get_org_ancestors)
-- 'root' topmost ancestor only (max depth ancestor) -- 'root' - topmost ancestor only (max depth ancestor)
-- 'unattributed' - the no-org bucket; resolves to no real orgs (empty set)
-- 'all' - every organization (unattributed is not an org, so it is
-- not listed here; org_in_scope('all') does cover it)
-- --
-- Optional p_max_depth limits how many levels are traversed (NULL = unlimited). -- Optional p_max_depth limits how many levels are traversed (NULL = unlimited).
CREATE OR REPLACE FUNCTION morbac.get_org_scope( CREATE OR REPLACE FUNCTION morbac.get_org_scope(
@@ -126,14 +129,22 @@ BEGIN
ORDER BY a.depth DESC ORDER BY a.depth DESC
LIMIT 1; LIMIT 1;
WHEN 'unattributed' THEN
RETURN QUERY
SELECT NULL::UUID, 0 WHERE FALSE;
WHEN 'all' THEN
RETURN QUERY
SELECT o.id, 0 FROM morbac.orgs o;
ELSE ELSE
RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root', p_scope; RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all', p_scope;
END CASE; END CASE;
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.get_org_scope(UUID, TEXT, INTEGER) IS COMMENT ON FUNCTION morbac.get_org_scope(UUID, TEXT, INTEGER) IS
'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root. Optional p_max_depth limits traversal depth.'; 'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all. Optional p_max_depth limits traversal depth.';
CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id UUID) CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER) RETURNS TABLE(role_id UUID, depth INTEGER)
@@ -345,6 +356,19 @@ STABLE
SECURITY DEFINER SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
-- Org target vocabulary: all (every org, unattributed included),
-- unattributed (no-org objects only), or a specific org via the tree scopes.
-- Tree scopes never match an unattributed object.
IF p_scope = 'all' THEN
RETURN TRUE;
END IF;
IF p_scope = 'unattributed' THEN
RETURN p_target_org_id IS NULL;
END IF;
IF p_target_org_id IS NULL THEN
RETURN FALSE;
END IF;
IF p_scope = 'self' THEN IF p_scope = 'self' THEN
RETURN p_target_org_id = p_rule_org_id; RETURN p_target_org_id = p_rule_org_id;
END IF; END IF;
@@ -356,4 +380,4 @@ END;
$$; $$;
COMMENT ON FUNCTION morbac.org_in_scope(UUID, UUID, TEXT) IS COMMENT ON FUNCTION morbac.org_in_scope(UUID, UUID, TEXT) IS
'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). SECURITY DEFINER to bypass RLS on morbac.orgs.'; 'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). Scope unattributed matches only a NULL target; other scopes never match NULL. SECURITY DEFINER to bypass RLS on morbac.orgs.';
+100 -22
View File
@@ -72,30 +72,56 @@ $$;
COMMENT ON FUNCTION morbac.current_target_user_id() IS COMMENT ON FUNCTION morbac.current_target_user_id() IS
'Returns target user ID filter from morbac.target_user_id session variable'; 'Returns target user ID filter from morbac.target_user_id session variable';
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' -- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' or '["uuid1", null]' or '[null]'.
CREATE OR REPLACE FUNCTION morbac.current_org_ids() -- A JSON null element names the no-org (unattributed) bucket, distinct from
RETURNS UUID[] -- the real org UUIDs which populate org_ids.
CREATE OR REPLACE FUNCTION morbac.current_org_filter(
OUT org_ids UUID[],
OUT include_unattributed BOOLEAN
)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
AS $$ AS $$
DECLARE DECLARE
v_raw TEXT; v_raw TEXT := current_setting('morbac.org_ids', TRUE);
v_json JSONB;
BEGIN BEGIN
v_raw := current_setting('morbac.org_ids', TRUE); org_ids := NULL;
include_unattributed := FALSE;
IF v_raw IS NULL OR v_raw = '' THEN IF v_raw IS NULL OR v_raw = '' THEN
RETURN NULL; RETURN;
END IF; END IF;
RETURN ARRAY(SELECT jsonb_array_elements_text(v_raw::jsonb)::UUID); v_json := v_raw::jsonb;
org_ids := ARRAY(
SELECT x::UUID
FROM jsonb_array_elements_text(v_json) x
WHERE x IS NOT NULL
);
include_unattributed := EXISTS (
SELECT 1 FROM jsonb_array_elements(v_json) e WHERE e = 'null'::jsonb
);
EXCEPTION EXCEPTION
WHEN OTHERS THEN WHEN OTHERS THEN
RETURN NULL; org_ids := NULL;
include_unattributed := FALSE;
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.current_org_filter() IS
'Parses morbac.org_ids (JSON array) once into real org UUIDs plus a flag for whether the no-org bucket (JSON null element) was requested.';
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
RETURNS UUID[]
LANGUAGE sql
STABLE
AS $$
SELECT org_ids FROM morbac.current_org_filter();
$$;
COMMENT ON FUNCTION morbac.current_org_ids() IS COMMENT ON FUNCTION morbac.current_org_ids() IS
'Returns org ID list from morbac.org_ids session variable (JSON array)'; 'Returns the real org UUIDs from morbac.org_ids (JSON null elements excluded). See current_org_filter() for the no-org bucket flag.';
CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID) CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID)
RETURNS TABLE(org_id UUID) RETURNS TABLE(org_id UUID)
@@ -117,13 +143,53 @@ $$;
COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
'Returns all org IDs the user has any direct role or active delegation in'; 'Returns all org IDs the user has any direct role or active delegation in';
-- rls_check has two forms, distinguished by arity so a NULL never carries two
-- meanings:
--
-- rls_check(activity, view) -- table has no org column
-- rls_check(activity, view, row_org_id[, row_user_id]) -- row-scoped by org
--
-- In the 3/4-arg form row_org_id is the record's org, and a NULL value means the
-- record is unattributed (no org) -- never "no org dimension". The 2-arg form
-- carries no org dimension and evaluates against the session org context.
DROP FUNCTION IF EXISTS morbac.rls_check(TEXT, TEXT, UUID, UUID);
CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT,
p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_user_id UUID;
v_org_id UUID;
BEGIN
v_user_id := morbac.current_user_id();
IF v_user_id IS NULL THEN
RETURN FALSE;
END IF;
v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END IF;
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT) IS
'RLS helper for tables without an org column. Uses the session org context (morbac.org_id), else org-independent rules. For row-scoped tables use the 3/4-arg form.';
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs. -- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
-- User scoping: morbac.target_user_id filters rows to a specific user. -- User scoping: morbac.target_user_id filters rows to a specific user.
-- Pass row columns to enable scoping: rls_check('read', 'docs', org_id, user_id)
CREATE OR REPLACE FUNCTION morbac.rls_check( CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT, p_activity TEXT,
p_view TEXT, p_view TEXT,
p_row_org_id UUID DEFAULT NULL, p_row_org_id UUID,
p_row_user_id UUID DEFAULT NULL p_row_user_id UUID DEFAULT NULL
) )
RETURNS BOOLEAN RETURNS BOOLEAN
@@ -131,10 +197,11 @@ LANGUAGE plpgsql
STABLE STABLE
AS $$ AS $$
DECLARE DECLARE
v_user_id UUID; v_user_id UUID;
v_org_id UUID; v_org_id UUID;
v_org_ids UUID[]; v_org_ids UUID[];
v_target_user_id UUID; v_include_unattr BOOLEAN;
v_target_user_id UUID;
BEGIN BEGIN
v_user_id := morbac.current_user_id(); v_user_id := morbac.current_user_id();
@@ -150,29 +217,40 @@ BEGIN
END IF; END IF;
END IF; END IF;
-- Single org selection: exact bucket only. An unattributed row (NULL org) is
-- DISTINCT from the pin and is filtered out (use the org_ids list for both).
v_org_id := morbac.current_org_id(); v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN IF v_org_id IS NOT NULL THEN
IF p_row_org_id IS NOT NULL AND p_row_org_id <> v_org_id THEN IF p_row_org_id IS DISTINCT FROM v_org_id THEN
RETURN FALSE; RETURN FALSE;
END IF; END IF;
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END IF; END IF;
v_org_ids := morbac.current_org_ids(); -- Org list selection: real orgs and/or the unattributed bucket (JSON null element).
SELECT f.org_ids, f.include_unattributed
INTO v_org_ids, v_include_unattr
FROM morbac.current_org_filter() f;
IF v_org_ids IS NOT NULL THEN IF v_org_ids IS NOT NULL OR v_include_unattr THEN
IF p_row_org_id IS NOT NULL AND NOT (p_row_org_id = ANY(v_org_ids)) THEN IF p_row_org_id IS NULL THEN
IF NOT v_include_unattr THEN
RETURN FALSE;
END IF;
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
END IF;
IF v_org_ids IS NULL OR NOT (p_row_org_id = ANY(v_org_ids)) THEN
RETURN FALSE; RETURN FALSE;
END IF; END IF;
-- p_row_org_id NULL: global row — is_allowed(NULL) checks global_rules only
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END IF; END IF;
-- No org context: use row's org (or NULL for global rows — global_rules only) -- No selection set: all authorized rows. The row's own org drives the decision
-- (unattributed row -> unattributed + org-independent rules).
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS
'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id for org scoping (single org, org list, or all orgs). Pass row user_id to filter by morbac.target_user_id session variable.'; 'RLS helper for row-scoped tables. p_row_org_id is the record org; NULL means unattributed. Select records via morbac.org_id (single org, unattributed excluded) or morbac.org_ids (JSON array; a null element adds the unattributed bucket). No selection = all authorized rows including unattributed. Pass row user_id to filter by morbac.target_user_id.';
+15 -11
View File
@@ -1,15 +1,19 @@
-- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality) -- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality)
-- --
-- scope controls which orgs this rule covers relative to org_id: -- scope controls which orgs this rule covers relative to org_id:
-- 'self' exact org only (default, current behavior) -- 'self' - exact org only (default, current behavior)
-- 'subtree' org + all descendants -- 'subtree' - org + all descendants
-- 'descendants' all descendants, excluding self -- 'descendants' - all descendants, excluding self
-- 'children' direct children only -- 'children' - direct children only
-- 'parent' direct parent only -- 'parent' - direct parent only
-- 'ancestors' all ancestors, excluding self -- 'ancestors' - all ancestors, excluding self
-- 'lineage' self + all ancestors -- 'lineage' - self + all ancestors
-- 'root' topmost ancestor only -- 'root' - topmost ancestor only
-- Evaluated at query time via org_in_scope() — new orgs are picked up automatically. -- '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 ( CREATE TABLE morbac.rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -28,7 +32,7 @@ CREATE TABLE morbac.rules (
metadata JSONB DEFAULT '{}'::jsonb, metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, activity, view, context_id, modality, scope), UNIQUE(org_id, role_id, activity, view, context_id, modality, scope),
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from), CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root')) CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'))
); );
CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id); CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id);
@@ -40,7 +44,7 @@ INCLUDE (role_id, context_id)
WHERE is_active = true; WHERE is_active = true;
COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
COMMENT ON COLUMN morbac.rules.scope IS 'Org scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root. Evaluated at query time new orgs are covered automatically.'; COMMENT ON COLUMN morbac.rules.scope IS 'Object scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root, unattributed, all. Selects which objects (by org) the rule reaches: a specific org via the tree scopes, unattributed for no-org objects only, or all for every org including unattributed. Evaluated at query time - new orgs are covered automatically.';
COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.'; COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.';
+2 -2
View File
@@ -7,7 +7,7 @@
-- - No targeted global_rules prohibitions -- - No targeted global_rules prohibitions
-- - All prohibitions are skipped in is_allowed_nocache() (see authorization.sql) -- - 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). -- can register or remove system principals (done in SQL at deploy time).
-- SELECT is gated by is_allowed() like all other system tables. -- 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 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 COMMENT ON COLUMN morbac.system_principals.user_id IS
'External user UUID of the service account'; 'External user UUID of the service account';
+5 -5
View File
@@ -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', USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.user_roles'))); 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; ALTER TABLE morbac.contexts ENABLE ROW LEVEL SECURITY;
CREATE POLICY contexts_select ON morbac.contexts FOR SELECT 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', USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
morbac.get_config('system_view.contexts'))); 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; ALTER TABLE morbac.activities ENABLE ROW LEVEL SECURITY;
CREATE POLICY activities_select ON morbac.activities FOR SELECT 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', USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
morbac.get_config('system_view.activities'))); 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; ALTER TABLE morbac.views ENABLE ROW LEVEL SECURITY;
CREATE POLICY views_select ON morbac.views FOR SELECT 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', USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.user_rules'))); 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; ALTER TABLE morbac.system_principals ENABLE ROW LEVEL SECURITY;
CREATE POLICY system_principals_select ON morbac.system_principals FOR SELECT CREATE POLICY system_principals_select ON morbac.system_principals FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read', USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.system_principals'))); 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; ALTER TABLE morbac.global_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY global_rules_select ON morbac.global_rules FOR SELECT CREATE POLICY global_rules_select ON morbac.global_rules FOR SELECT
+11 -3
View File
@@ -3,11 +3,14 @@
-- Grants or prohibits access for a specific user in an org, bypassing the role system. -- Grants or prohibits access for a specific user in an org, bypassing the role system.
-- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache(). -- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache().
-- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition. -- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition.
--
-- Org target: a specific org, or NULL for unattributed (no-org) objects.
-- For the same user across every org, use morbac.global_rules.
CREATE TABLE morbac.user_rules ( CREATE TABLE morbac.user_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, user_id UUID NOT NULL,
org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, org_id UUID REFERENCES morbac.orgs(id) ON DELETE CASCADE,
activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
@@ -21,13 +24,18 @@ CREATE TABLE morbac.user_rules (
UNIQUE(user_id, org_id, activity, view, context_id, modality) UNIQUE(user_id, org_id, activity, view, context_id, modality)
); );
-- NULLs are distinct in UNIQUE, so the unattributed target needs its own index
CREATE UNIQUE INDEX idx_user_rules_unattributed_unique
ON morbac.user_rules(user_id, activity, view, context_id, modality)
WHERE org_id IS NULL;
CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id); CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id);
CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view); CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view);
CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality); CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality);
CREATE INDEX idx_user_rules_lookup ON morbac.user_rules(user_id, org_id, activity, modality, view); 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.user_id IS 'User this rule applies to directly';
COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides'; COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides; NULL targets unattributed (no-org) objects';
COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.'; COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.';
+7 -4
View File
@@ -90,7 +90,8 @@ CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts(
p_view TEXT, p_view TEXT,
p_context_id UUID, p_context_id UUID,
p_modality morbac.modality, p_modality morbac.modality,
p_exclude_id UUID DEFAULT NULL p_exclude_id UUID DEFAULT NULL,
p_scope TEXT DEFAULT 'self'
) )
RETURNS TABLE( RETURNS TABLE(
conflicting_rule_id UUID, conflicting_rule_id UUID,
@@ -121,6 +122,7 @@ BEGIN
AND r.activity = p_activity AND r.activity = p_activity
AND r.view = p_view AND r.view = p_view
AND r.context_id = p_context_id AND r.context_id = p_context_id
AND r.scope = p_scope
AND r.modality != p_modality AND r.modality != p_modality
AND (p_exclude_id IS NULL OR r.id != p_exclude_id) AND (p_exclude_id IS NULL OR r.id != p_exclude_id)
AND ( AND (
@@ -132,8 +134,8 @@ BEGIN
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID) IS COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID, TEXT) IS
'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission).'; 'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission). Conflicts are scoped: only rules sharing the same scope compete, since different scopes target different object sets.';
-- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one -- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one
@@ -147,7 +149,8 @@ BEGIN
FOR v_conflict IN FOR v_conflict IN
SELECT * FROM morbac.detect_rule_conflicts( SELECT * FROM morbac.detect_rule_conflicts(
NEW.org_id, NEW.role_id, NEW.activity, NEW.view, NEW.context_id, NEW.modality, NEW.org_id, NEW.role_id, NEW.activity, NEW.view, NEW.context_id, NEW.modality,
CASE WHEN TG_OP = 'UPDATE' THEN NEW.id ELSE NULL END CASE WHEN TG_OP = 'UPDATE' THEN NEW.id ELSE NULL END,
NEW.scope
) )
LOOP LOOP
RAISE WARNING 'Rule conflict: % (conflicts with rule %)', RAISE WARNING 'Rule conflict: % (conflicts with rule %)',
+1 -1
View File
@@ -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.senior_view IS 'Senior view (more specific)';
COMMENT ON COLUMN morbac.view_hierarchy.junior_view IS 'Junior view (more general)'; 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. -- Override config values to rename; the new name must be seeded here too.
INSERT INTO morbac.views (name, description) VALUES INSERT INTO morbac.views (name, description) VALUES
('orgs', 'Organizations table'), ('orgs', 'Organizations table'),
+23 -23
View File
@@ -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: -- This file establishes the full company structure used across all test files:
-- --
-- GlobalTech HQ (root) -- GlobalTech HQ (root)
-- ├── Engineering Dept (child) -- +-- Engineering Dept (child)
-- └── Sales Dept (child) -- \-- Sales Dept (child)
-- --
-- Role hierarchy in GlobalTech (senior -> junior, i.e. senior inherits junior perms): -- Role hierarchy in GlobalTech (senior -> junior, i.e. senior inherits junior perms):
-- ceo -> director -> manager -> employee -> intern -- ceo -> director -> manager -> employee -> intern
@@ -15,18 +15,18 @@
-- tech_lead -> engineer -- tech_lead -> engineer
-- --
-- Users: -- Users:
-- Alice CEO at GlobalTech -- Alice - CEO at GlobalTech
-- Bob Director at GlobalTech -- Bob - Director at GlobalTech
-- Carol Manager at GlobalTech -- Carol - Manager at GlobalTech
-- Dave Employee at GlobalTech -- Dave - Employee at GlobalTech
-- Eve Intern at GlobalTech -- Eve - Intern at GlobalTech
-- Frank Contractor at GlobalTech -- Frank - Contractor at GlobalTech
-- Grace HR Manager at GlobalTech -- Grace - HR Manager at GlobalTech
-- Heidi Auditor at GlobalTech -- Heidi - Auditor at GlobalTech
-- Ivan Accountant at GlobalTech -- Ivan - Accountant at GlobalTech
-- Judy Engineer at Engineering + Sales Rep at Sales (multi-org) -- Judy - Engineer at Engineering + Sales Rep at Sales (multi-org)
-- Karl No role (unauthorized user) -- Karl - No role (unauthorized user)
-- Leo Employee at GlobalTech (used for delegation target) -- Leo - Employee at GlobalTech (used for delegation target)
-- ============================================================================= -- =============================================================================
-- Clean slate -- Clean slate
@@ -55,7 +55,7 @@ WHERE id IN (
'10000000-0000-0000-0000-000000000003' '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 FROM morbac.orgs o LEFT JOIN morbac.orgs p ON o.parent_id = p.id
ORDER BY o.parent_id NULLS FIRST, o.name; 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; ORDER BY sr.name;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- USER ROLE ASSIGNMENTS -- USER - ROLE ASSIGNMENTS
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: User-Role Assignments ===' \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-0002-0000-0000-000000000002', '10000000-0000-0000-0000-000000000002'),
('30000000-0000-0000-0000-000000000010', '20000000-0003-0000-0000-000000000002', '10000000-0000-0000-0000-000000000003'); ('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 SELECT ur.user_id, r.name AS role, o.name AS organization
FROM morbac.user_roles ur FROM morbac.user_roles ur
@@ -214,7 +214,7 @@ BEGIN RETURN TRUE; END;
$$; $$;
INSERT INTO morbac.contexts (name, description, evaluator) VALUES INSERT INTO morbac.contexts (name, description, evaluator) VALUES
('business_hours', 'During business hours (MonFri 917)', '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), ('after_hours', 'Outside business hours', 'morbac.ctx_after_hours'::regproc),
('end_of_quarter', 'End-of-quarter reporting window', 'morbac.ctx_end_of_quarter'::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 -- Disable auth cache so state changes between calls are always reflected
SELECT morbac.set_config('cache_ttl_seconds', '0'); 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) CREATE OR REPLACE FUNCTION morbac.t(label TEXT, actual BOOLEAN, expect BOOLEAN)
RETURNS TEXT LANGUAGE sql STABLE AS $$ RETURNS TEXT LANGUAGE sql STABLE AS $$
SELECT CASE WHEN actual IS NOT DISTINCT FROM expect SELECT CASE WHEN actual IS NOT DISTINCT FROM expect
@@ -284,7 +284,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$
END; 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) CREATE OR REPLACE FUNCTION morbac.t_null(label TEXT, actual TEXT)
RETURNS TEXT LANGUAGE sql STABLE AS $$ RETURNS TEXT LANGUAGE sql STABLE AS $$
SELECT CASE WHEN actual IS NULL SELECT CASE WHEN actual IS NULL
@@ -293,7 +293,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$
END; 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) CREATE OR REPLACE FUNCTION morbac.t_not_null(label TEXT, actual TEXT)
RETURNS TEXT LANGUAGE sql STABLE AS $$ RETURNS TEXT LANGUAGE sql STABLE AS $$
SELECT CASE WHEN actual IS NOT NULL SELECT CASE WHEN actual IS NOT NULL
@@ -302,7 +302,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$
END; 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) CREATE OR REPLACE FUNCTION morbac.t_eq(label TEXT, actual NUMERIC, expect NUMERIC)
RETURNS TEXT LANGUAGE sql STABLE AS $$ RETURNS TEXT LANGUAGE sql STABLE AS $$
SELECT CASE WHEN actual = expect SELECT CASE WHEN actual = expect
+40 -40
View File
@@ -5,25 +5,25 @@
-- No activity or view hierarchy is active yet (added in 02_hierarchies.sql). -- No activity or view hierarchy is active yet (added in 02_hierarchies.sql).
-- --
-- Users and their roles at GlobalTech HQ: -- Users and their roles at GlobalTech HQ:
-- Alice ceo (permission: inherits all via hierarchy) -- Alice - ceo (permission: inherits all via hierarchy)
-- Bob director (inherits manager -> employee -> intern) -- Bob - director (inherits manager -> employee -> intern)
-- Carol manager (inherits employee -> intern) -- Carol - manager (inherits employee -> intern)
-- Dave employee (inherits intern) -- Dave - employee (inherits intern)
-- Eve intern -- Eve - intern
-- Frank contractor (has prohibition on financial/hr data) -- Frank - contractor (has prohibition on financial/hr data)
-- Grace hr_manager -- Grace - hr_manager
-- Heidi auditor -- Heidi - auditor
-- Ivan accountant -- Ivan - accountant
-- Judy engineer@Engineering + sales_rep@Sales (multi-org) -- Judy - engineer@Engineering + sales_rep@Sales (multi-org)
-- Karl no role -- Karl - no role
-- Leo employee -- Leo - employee
-- --
-- Prerequisites: 00_setup.sql -- Prerequisites: 00_setup.sql
-- ============================================================================= -- =============================================================================
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '01 CORE AUTHORIZATION DECISIONS' \echo '01 - CORE AUTHORIZATION DECISIONS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -32,7 +32,7 @@
\echo '' \echo ''
\echo '--- 1. Basic permission grants ---' \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', SELECT morbac.t('Dave (employee) reads documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
@@ -40,7 +40,7 @@ SELECT morbac.t('Dave (employee) reads documents',
'read', 'documents' 'read', 'documents'
), TRUE); ), 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]', SELECT morbac.t('Dave (employee) writes documents [business_hours context=true]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
@@ -89,12 +89,12 @@ SELECT morbac.t('Ivan (accountant) writes financial_data',
), TRUE); ), TRUE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 2: Default deny no rule exists for the combination -- Section 2: Default deny - no rule exists for the combination
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 2. Default deny (no permission rule) ---' \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]', SELECT morbac.t('Dave (employee) deletes documents [no permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
@@ -102,7 +102,7 @@ SELECT morbac.t('Dave (employee) deletes documents [no permission]',
'delete', 'documents' 'delete', 'documents'
), FALSE); ), 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]', SELECT morbac.t('Eve (intern) reads documents [intern has no docs permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000005'::uuid, '30000000-0000-0000-0000-000000000005'::uuid,
@@ -110,7 +110,7 @@ SELECT morbac.t('Eve (intern) reads documents [intern has no docs permission]',
'read', 'documents' 'read', 'documents'
), FALSE); ), 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]', SELECT morbac.t('Dave (employee) reads financial_data [no permission before view hierarchy]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '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' 'read', 'financial_data'
), FALSE); ), 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]', SELECT morbac.t('Grace (hr_manager) reads audit_logs [no permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000007'::uuid, '30000000-0000-0000-0000-000000000007'::uuid,
@@ -126,7 +126,7 @@ SELECT morbac.t('Grace (hr_manager) reads audit_logs [no permission]',
'read', 'audit_logs' 'read', 'audit_logs'
), FALSE); ), 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]', SELECT morbac.t('Dave (employee) approves documents [no permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
@@ -135,12 +135,12 @@ SELECT morbac.t('Dave (employee) approves documents [no permission]',
), FALSE); ), FALSE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 3: Default deny user has no role at all -- Section 3: Default deny - user has no role at all
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 3. Default deny (user has no role) ---' \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', SELECT morbac.t('Karl (no role) reads documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid, '30000000-0000-0000-0000-000000000011'::uuid,
@@ -148,7 +148,7 @@ SELECT morbac.t('Karl (no role) reads documents',
'read', 'documents' 'read', 'documents'
), FALSE); ), FALSE);
-- Completely unknown user UUID EXPECT FALSE -- Completely unknown user UUID - EXPECT FALSE
SELECT morbac.t('Unknown user reads documents', SELECT morbac.t('Unknown user reads documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid,
@@ -162,7 +162,7 @@ SELECT morbac.t('Unknown user reads documents',
\echo '' \echo ''
\echo '--- 4. Prohibition overrides permission ---' \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]', SELECT morbac.t('Frank (contractor) reads documents [has permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000006'::uuid, '30000000-0000-0000-0000-000000000006'::uuid,
@@ -170,7 +170,7 @@ SELECT morbac.t('Frank (contractor) reads documents [has permission]',
'read', 'documents' 'read', 'documents'
), TRUE); ), TRUE);
-- Frank (contractor) reads financial_data PROHIBITED -- Frank (contractor) reads financial_data - PROHIBITED
SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]', SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000006'::uuid, '30000000-0000-0000-0000-000000000006'::uuid,
@@ -178,7 +178,7 @@ SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]',
'read', 'financial_data' 'read', 'financial_data'
), FALSE); ), FALSE);
-- Frank (contractor) reads hr_data prohibited -- Frank (contractor) reads hr_data - prohibited
SELECT morbac.t('Frank (contractor) reads hr_data [prohibited]', SELECT morbac.t('Frank (contractor) reads hr_data [prohibited]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000006'::uuid, '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'; 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 ''
\echo '--- 5. Context filtering ---' \echo '--- 5. Context filtering ---'
@@ -256,7 +256,7 @@ WHERE org_id = '10000000-0000-0000-0000-000000000001'
AND activity = 'export' AND view = 'documents'; 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 ''
\echo '--- 6. Wrong organization ---' \echo '--- 6. Wrong organization ---'
@@ -278,12 +278,12 @@ SELECT morbac.t('Judy (Engineering engineer) reads GlobalTech financial_data [no
), FALSE); ), 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 ''
\echo '--- 7. Multi-org user (Judy) ---' \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', SELECT morbac.t('Judy (engineer) reads Engineering documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000010'::uuid, '30000000-0000-0000-0000-000000000010'::uuid,
@@ -291,7 +291,7 @@ SELECT morbac.t('Judy (engineer) reads Engineering documents',
'read', 'documents' 'read', 'documents'
), TRUE); ), 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', SELECT morbac.t('Judy (sales_rep) writes Sales contracts',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000010'::uuid, '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 ''
\echo '--- 8. Role hierarchy inheritance ---' \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', SELECT morbac.t('Carol (manager, inherits employee) reads documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000003'::uuid, '30000000-0000-0000-0000-000000000003'::uuid,
@@ -321,7 +321,7 @@ SELECT morbac.t('Carol (manager, inherits employee) reads documents',
'read', 'documents' 'read', 'documents'
), TRUE); ), 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]', SELECT morbac.t('Carol (manager) approves documents [own permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000003'::uuid, '30000000-0000-0000-0000-000000000003'::uuid,
@@ -329,7 +329,7 @@ SELECT morbac.t('Carol (manager) approves documents [own permission]',
'approve', 'documents' 'approve', 'documents'
), TRUE); ), 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', SELECT morbac.t('Bob (director, inherits manager+employee) reads documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000002'::uuid, '30000000-0000-0000-0000-000000000002'::uuid,
@@ -337,7 +337,7 @@ SELECT morbac.t('Bob (director, inherits manager+employee) reads documents',
'read', 'documents' 'read', 'documents'
), TRUE); ), TRUE);
-- Bob (director) inherits manager can approve documents -- Bob (director) inherits manager - can approve documents
SELECT morbac.t('Bob (director, inherits manager) approves documents', SELECT morbac.t('Bob (director, inherits manager) approves documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000002'::uuid, '30000000-0000-0000-0000-000000000002'::uuid,
@@ -345,7 +345,7 @@ SELECT morbac.t('Bob (director, inherits manager) approves documents',
'approve', 'documents' 'approve', 'documents'
), TRUE); ), 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', SELECT morbac.t('Alice (CEO, inherits all) reads documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000001'::uuid, '30000000-0000-0000-0000-000000000001'::uuid,
@@ -367,7 +367,7 @@ SELECT morbac.t('Alice (CEO, inherits all) deletes documents',
'delete', 'documents' 'delete', 'documents'
), TRUE); ), 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]', SELECT morbac.t('Eve (intern) approves documents [intern has no approve permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000005'::uuid, '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) -- Equal priorities: prohibition wins (modality tiebreaker)
UPDATE morbac.rules SET priority = 5 WHERE id = 'e0000000-0000-0000-0000-000000000001'; 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( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::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' '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( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid, '10000000-0000-0000-0000-000000000001'::uuid,
+38 -38
View File
@@ -2,10 +2,10 @@
-- Hierarchy Tests -- Hierarchy Tests
-- ============================================================================= -- =============================================================================
-- Tests all four hierarchy types: -- Tests all four hierarchy types:
-- 1. Role hierarchy senior roles inherit permissions of junior roles (transitive) -- 1. Role hierarchy - senior roles inherit permissions of junior roles (transitive)
-- 2. Activity hierarchy requesting a senior activity also matches junior-activity rules -- 2. Activity hierarchy - requesting a senior activity also matches junior-activity rules
-- 3. View hierarchy requesting a senior view also matches junior-view rules -- 3. View hierarchy - requesting a senior view also matches junior-view rules
-- 4. Org hierarchy get_org_ancestors / get_org_descendants traversal -- 4. Org hierarchy - get_org_ancestors / get_org_descendants traversal
-- --
-- Hierarchy semantics in this system: -- Hierarchy semantics in this system:
-- Activity: (senior='write', junior='read') means get_effective_activities('write') -- Activity: (senior='write', junior='read') means get_effective_activities('write')
@@ -19,50 +19,50 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '02 HIERARCHIES' \echo '02 - HIERARCHIES'
\echo '================================================================' \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 ''
\echo '--- 1. Role hierarchy introspection ---' \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 morbac.t_eq('Eve has 1 effective role (intern only)',
(SELECT COUNT(*) FROM morbac.get_effective_roles( (SELECT COUNT(*) FROM morbac.get_effective_roles(
'30000000-0000-0000-0000-000000000005'::uuid, '30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid '10000000-0000-0000-0000-000000000001'::uuid
))::bigint, 1); ))::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 morbac.t_eq('Dave has 2 effective roles (employee, intern)',
(SELECT COUNT(*) FROM morbac.get_effective_roles( (SELECT COUNT(*) FROM morbac.get_effective_roles(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid '10000000-0000-0000-0000-000000000001'::uuid
))::bigint, 2); ))::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 morbac.t_eq('Carol has 3 effective roles (manager, employee, intern)',
(SELECT COUNT(*) FROM morbac.get_effective_roles( (SELECT COUNT(*) FROM morbac.get_effective_roles(
'30000000-0000-0000-0000-000000000003'::uuid, '30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid '10000000-0000-0000-0000-000000000001'::uuid
))::bigint, 3); ))::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 morbac.t_eq('Alice has 5 effective roles (ceo through intern)',
(SELECT COUNT(*) FROM morbac.get_effective_roles( (SELECT COUNT(*) FROM morbac.get_effective_roles(
'30000000-0000-0000-0000-000000000001'::uuid, '30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid '10000000-0000-0000-0000-000000000001'::uuid
))::bigint, 5); ))::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 morbac.t_eq('get_inherited_roles(manager) returns 3 roles (manager, employee, intern)',
(SELECT COUNT(*) FROM morbac.get_inherited_roles( (SELECT COUNT(*) FROM morbac.get_inherited_roles(
'20000000-0001-0000-0000-000000000003'::uuid '20000000-0001-0000-0000-000000000003'::uuid
))::bigint, 3); ))::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 morbac.t_eq('get_inherited_roles(ceo) returns 5 roles (ceo through intern)',
(SELECT COUNT(*) FROM morbac.get_inherited_roles( (SELECT COUNT(*) FROM morbac.get_inherited_roles(
'20000000-0001-0000-0000-000000000001'::uuid '20000000-0001-0000-0000-000000000001'::uuid
@@ -83,10 +83,10 @@ SELECT morbac.t('intern role appears in manager inherited roles',
), TRUE); ), TRUE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 2: Role hierarchy authorization via inheritance (transitive) -- Section 2: Role hierarchy - authorization via inheritance (transitive)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \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 -- Carol (manager) has employee permission (read documents) via 1-level inheritance
SELECT morbac.t('Carol (manager, 1-level inherit) reads documents', 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' 'approve', 'documents'
), TRUE); ), 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]', SELECT morbac.t('Eve (intern) approves documents [inheritance only goes up in seniority]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000005'::uuid, '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. -- Rule: employee has 'read documents' permission.
-- With hierarchy (write->read), requesting 'write' also matches the 'read' rule. -- 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', SELECT morbac.t('Dave (employee, has read perm) writes docs via activity hierarchy',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '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' 'write', 'documents'
), TRUE); ), 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', SELECT morbac.t('Dave (employee, has read perm) deletes docs via delete->write->read hierarchy',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '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' 'delete', 'documents'
), TRUE); ), 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', SELECT morbac.t('Dave (employee, has read perm) exports docs via export->read hierarchy',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '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' 'export', 'documents'
), TRUE); ), 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', SELECT morbac.t('Eve (intern, has read perm) writes public_data via activity hierarchy',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000005'::uuid, '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' 'write', 'public_data'
), TRUE); ), 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]', SELECT morbac.t('Dave (employee) audits documents [audit not in hierarchy, no permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '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} -- requesting 'financial_data' -> get_effective_views('financial_data') = {financial_data, documents}
-- the 'documents' rule matches -> access granted -- 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', SELECT morbac.t('Dave (employee, has read documents) reads financial_data via view hierarchy',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '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' 'read', 'financial_data'
), TRUE); ), 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', SELECT morbac.t('Dave (employee, has read documents) reads hr_data via view hierarchy',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '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' 'read', 'hr_data'
), TRUE); ), TRUE);
-- Frank (contractor) reads documents permitted -- Frank (contractor) reads documents - permitted
SELECT morbac.t('Frank (contractor) reads documents [has permission]', SELECT morbac.t('Frank (contractor) reads documents [has permission]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000006'::uuid, '30000000-0000-0000-0000-000000000006'::uuid,
@@ -409,8 +409,8 @@ SELECT morbac.t('Frank (contractor) reads documents [has permission]',
'read', 'documents' 'read', 'documents'
), TRUE); ), TRUE);
-- Frank (contractor) reads financial_data matches contractor's 'read documents' rule via view hierarchy -- Frank (contractor) reads financial_data - matches contractor's 'read documents' rule via view hierarchy
-- BUT contractor has a PROHIBITION on financial_data prohibition wins -- BUT contractor has a PROHIBITION on financial_data - prohibition wins
SELECT morbac.t('Frank (contractor) reads financial_data [prohibition overrides view-hierarchy match]', SELECT morbac.t('Frank (contractor) reads financial_data [prohibition overrides view-hierarchy match]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000006'::uuid, '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 '10000000-0000-0000-0000-000000000003'::uuid
))::bigint, 2); ))::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 morbac.t_eq('GlobalTech HQ (root) has 1 ancestor (itself only)',
(SELECT COUNT(*) FROM morbac.get_org_ancestors( (SELECT COUNT(*) FROM morbac.get_org_ancestors(
'10000000-0000-0000-0000-000000000001'::uuid '10000000-0000-0000-0000-000000000001'::uuid
@@ -478,17 +478,17 @@ SELECT morbac.t('Dave (GlobalTech employee) reads Engineering docs [no cross-org
), FALSE); ), FALSE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 6: get_org_scope named scope helper -- Section 6: get_org_scope - named scope helper
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Org tree used in tests: -- Org tree used in tests:
-- GlobalTech HQ (root) id: 10000000-0000-0000-0000-000000000001 -- GlobalTech HQ (root) id: 10000000-0000-0000-0000-000000000001
-- ├── Engineering Dept id: 10000000-0000-0000-0000-000000000002 -- +-- Engineering Dept id: 10000000-0000-0000-0000-000000000002
-- └── Sales Dept id: 10000000-0000-0000-0000-000000000003 -- \-- Sales Dept id: 10000000-0000-0000-0000-000000000003
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 6. get_org_scope ---' \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 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); (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 WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 0
), TRUE); ), 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 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); (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 WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 1
), TRUE); ), 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 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); (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 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); (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 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); (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 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); (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 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); (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 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); (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 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); (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 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); (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 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); (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 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); (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 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); (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' WHERE org_id = '10000000-0000-0000-0000-000000000001'
), TRUE); ), TRUE);
-- p_max_depth depth limiting -- p_max_depth - depth limiting
SELECT morbac.t_eq('scope subtree max_depth=0 returns only self', 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); (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree', 0))::bigint, 1);
+8 -8
View File
@@ -20,11 +20,11 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '03 DELEGATION' \echo '03 - DELEGATION'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 1: Baseline Leo (employee) before any delegation -- Section 1: Baseline - Leo (employee) before any delegation
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 1. Baseline: Leo before delegation ---' \echo '--- 1. Baseline: Leo before delegation ---'
@@ -37,7 +37,7 @@ SELECT morbac.t('Leo (employee) reads documents before delegation',
'read', 'documents' 'read', 'documents'
), TRUE); ), 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]', SELECT morbac.t('Leo (employee) approves documents before delegation [no manager perm]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000012'::uuid, '30000000-0000-0000-0000-000000000012'::uuid,
@@ -66,7 +66,7 @@ SELECT morbac.t('Leo (employee) does not have approve permission before delegati
), FALSE); ), FALSE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 2: Active delegation Carol delegates manager role to Leo -- Section 2: Active delegation - Carol delegates manager role to Leo
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 2. Active delegation: Carol -> Leo (manager role, 1 day) ---' \echo '--- 2. Active delegation: Carol -> Leo (manager role, 1 day) ---'
@@ -83,7 +83,7 @@ VALUES (
now() + interval '1 day' 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', SELECT morbac.t('Leo (delegated manager) approves documents',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000012'::uuid, '30000000-0000-0000-0000-000000000012'::uuid,
@@ -217,7 +217,7 @@ VALUES (
TRUE -- revoked 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; -- Note: employee already has write financial_data via view hierarchy;
-- so we verify the delegation is actually revoked in the DB. -- so we verify the delegation is actually revoked in the DB.
SELECT morbac.t('Revoked accountant delegation has revoked=TRUE in 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) (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until)
VALUES ( VALUES (
'de000001-0000-0000-0000-000000000005', '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 '30000000-0000-0000-0000-000000000005', -- Eve
'20000000-0001-0000-0000-000000000003', -- manager role '20000000-0001-0000-0000-000000000003', -- manager role
'10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001',
@@ -330,7 +330,7 @@ SELECT morbac.t('Invalid delegation not in Eve comprehensive roles',
\echo '' \echo ''
\echo '--- 8. get_comprehensive_roles source reporting ---' \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', SELECT morbac.t('Alice CEO role has source=direct in comprehensive roles',
EXISTS( EXISTS(
SELECT 1 FROM morbac.get_comprehensive_roles( SELECT 1 FROM morbac.get_comprehensive_roles(
+30 -30
View File
@@ -2,10 +2,10 @@
-- Constraint Tests -- Constraint Tests
-- ============================================================================= -- =============================================================================
-- Tests business constraints: -- Tests business constraints:
-- 1. Separation of Duty (SoD) mutually exclusive roles -- 1. Separation of Duty (SoD) - mutually exclusive roles
-- 2. Negative role assignments explicit blocking of a role -- 2. Negative role assignments - explicit blocking of a role
-- 3. Role cardinality constraints min/max users per role -- 3. Role cardinality constraints - min/max users per role
-- 4. Rule conflict detection modality conflicts on same tuple -- 4. Rule conflict detection - modality conflicts on same tuple
-- --
-- Scenario: -- Scenario:
-- - auditor and accountant are mutually exclusive (no one can hold both) -- - auditor and accountant are mutually exclusive (no one can hold both)
@@ -19,11 +19,11 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '04 CONSTRAINTS' \echo '04 - CONSTRAINTS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 1: Separation of Duty define conflict -- Section 1: Separation of Duty - define conflict
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 1. Separation of Duty setup ---' \echo '--- 1. Separation of Duty setup ---'
@@ -49,7 +49,7 @@ SELECT morbac.t_eq('SoD conflict between auditor and accountant created',
\echo '' \echo ''
\echo '--- 2. SoD violation detection ---' \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', SELECT morbac.t('Assigning accountant to Heidi (auditor) violates SoD',
morbac.check_sod_violation( morbac.check_sod_violation(
'30000000-0000-0000-0000-000000000008'::uuid, -- Heidi '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 '10000000-0000-0000-0000-000000000001'::uuid
), TRUE); ), 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]', SELECT morbac.t('Assigning auditor to Ivan (accountant) violates SoD [symmetric]',
morbac.check_sod_violation( morbac.check_sod_violation(
'30000000-0000-0000-0000-000000000009'::uuid, -- Ivan '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 '10000000-0000-0000-0000-000000000001'::uuid
), TRUE); ), TRUE);
-- Dave (employee) check if assigning accountant would violate SoD -- Dave (employee) - check if assigning accountant would violate SoD
-- Dave is not an auditor no conflict -- Dave is not an auditor - no conflict
SELECT morbac.t('Assigning accountant to Dave (not an auditor) does not violate SoD', SELECT morbac.t('Assigning accountant to Dave (not an auditor) does not violate SoD',
morbac.check_sod_violation( morbac.check_sod_violation(
'30000000-0000-0000-0000-000000000004'::uuid, -- Dave '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 '10000000-0000-0000-0000-000000000001'::uuid
), FALSE); ), 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', SELECT morbac.t('Assigning manager to Heidi (auditor) does not violate SoD',
morbac.check_sod_violation( morbac.check_sod_violation(
'30000000-0000-0000-0000-000000000008'::uuid, -- Heidi '30000000-0000-0000-0000-000000000008'::uuid, -- Heidi
@@ -126,7 +126,7 @@ VALUES (
'Frank is a contractor and must not gain employee-level access' '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) -- employee role must not appear (negated by negative assignment)
SELECT morbac.t('Frank: employee role excluded by negative assignment', SELECT morbac.t('Frank: employee role excluded by negative assignment',
NOT EXISTS( NOT EXISTS(
@@ -148,7 +148,7 @@ SELECT morbac.t('Frank: contractor role still present after employee negated',
), TRUE); ), TRUE);
-- Negative assignment on a role the user never had is harmless -- 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) INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason)
VALUES ( VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl '30000000-0000-0000-0000-000000000011', -- Karl
@@ -181,7 +181,7 @@ WHERE user_id = '30000000-0000-0000-0000-000000000006'
\echo '' \echo ''
\echo '--- 4. Role cardinality constraints ---' \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) INSERT INTO morbac.role_cardinality (role_id, min_users, max_users, description)
VALUES ( VALUES (
'20000000-0001-0000-0000-000000000010', -- compliance_officer '20000000-0001-0000-0000-000000000010', -- compliance_officer
@@ -189,8 +189,8 @@ VALUES (
'Compliance officer role: at least 1, at most 2' 'Compliance officer role: at least 1, at most 2'
); );
-- Currently 0 users have compliance_officer adding one should be fine (0 < max=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', SELECT morbac.t_null('Adding first compliance_officer (0 users, max=2) - no violation',
morbac.check_cardinality_violation( morbac.check_cardinality_violation(
'20000000-0001-0000-0000-000000000010'::uuid, '20000000-0001-0000-0000-000000000010'::uuid,
TRUE -- adding 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-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'); ('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 -- 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', SELECT morbac.t_not_null('Adding 3rd compliance_officer (2 users, max=2) - violation returned',
morbac.check_cardinality_violation( morbac.check_cardinality_violation(
'20000000-0001-0000-0000-000000000010'::uuid, '20000000-0001-0000-0000-000000000010'::uuid,
TRUE -- adding TRUE -- adding
)); ));
-- Removing one 2 users, min=1 removing leaves 1 which is min=1, should be fine -- 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)', SELECT morbac.t_null('Removing from 2 compliance_officers (min=1) - no violation (still above min)',
morbac.check_cardinality_violation( morbac.check_cardinality_violation(
'20000000-0001-0000-0000-000000000010'::uuid, '20000000-0001-0000-0000-000000000010'::uuid,
FALSE -- removing FALSE -- removing
@@ -221,21 +221,21 @@ WHERE user_id = '30000000-0000-0000-0000-000000000005'
AND role_id = '20000000-0001-0000-0000-000000000010'; AND role_id = '20000000-0001-0000-0000-000000000010';
-- 1 user remaining = min. Removing the last one would violate min=1 -- 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( morbac.check_cardinality_violation(
'20000000-0001-0000-0000-000000000010'::uuid, '20000000-0001-0000-0000-000000000010'::uuid,
FALSE -- removing FALSE -- removing
)); ));
-- Adding again after being at 1 1 user, max=2 ok -- 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', SELECT morbac.t_null('Adding when at 1 compliance_officer (max=2) - no violation',
morbac.check_cardinality_violation( morbac.check_cardinality_violation(
'20000000-0001-0000-0000-000000000010'::uuid, '20000000-0001-0000-0000-000000000010'::uuid,
TRUE -- adding TRUE -- adding
)); ));
-- Role with no cardinality constraint no violation for any operation -- Role with no cardinality constraint - no violation for any operation
SELECT morbac.t_null('Checking cardinality for employee role (no constraint) no violation', SELECT morbac.t_null('Checking cardinality for employee role (no constraint) - no violation',
morbac.check_cardinality_violation( morbac.check_cardinality_violation(
'20000000-0001-0000-0000-000000000004'::uuid, -- employee '20000000-0001-0000-0000-000000000004'::uuid, -- employee
TRUE TRUE
@@ -263,8 +263,8 @@ VALUES (
'permission' 'permission'
); );
-- No conflict yet only a permission exists -- No conflict yet - only a permission exists
SELECT morbac.t_eq('detect_rule_conflicts: permission alone no conflicts', SELECT morbac.t_eq('detect_rule_conflicts: permission alone - no conflicts',
(SELECT COUNT(*) FROM morbac.detect_rule_conflicts( (SELECT COUNT(*) FROM morbac.detect_rule_conflicts(
'10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000004', '20000000-0001-0000-0000-000000000004',
@@ -309,7 +309,7 @@ SELECT morbac.t('detect_rule_conflicts: conflicting rule is the permission',
WHERE conflicting_modality = 'permission' WHERE conflicting_modality = 'permission'
), TRUE); ), 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) INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality)
VALUES ( VALUES (
'f0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003',
@@ -331,7 +331,7 @@ SELECT morbac.t_eq('detect_rule_conflicts: obligation conflicts with existing pr
))::bigint, ))::bigint,
1); 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) INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality)
VALUES ( VALUES (
'f0000000-0000-0000-0000-000000000004', 'f0000000-0000-0000-0000-000000000004',
@@ -354,7 +354,7 @@ SELECT morbac.t_eq('detect_rule_conflicts: recommendation conflicts with prohibi
2); 2);
-- No conflict between permission and recommendation (they coexist meaningfully) -- 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( (SELECT COUNT(*) FROM morbac.detect_rule_conflicts(
'10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000004', '20000000-0001-0000-0000-000000000004',
+14 -14
View File
@@ -9,7 +9,7 @@
-- 3. Expired rule (valid_until in the past) -> denied -- 3. Expired rule (valid_until in the past) -> denied
-- 4. Future rule (valid_from in the future) -> denied -- 4. Future rule (valid_from in the future) -> denied
-- 5. Active time window (valid_from past, valid_until future) -> allowed -- 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 -- 7. Expired prohibition: no longer blocks access after it expires
-- 8. Temporal rules interact correctly with role hierarchy -- 8. Temporal rules interact correctly with role hierarchy
-- --
@@ -21,7 +21,7 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '05 TEMPORAL CONSTRAINTS' \echo '05 - TEMPORAL CONSTRAINTS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -37,35 +37,35 @@ INSERT INTO morbac.views (name, description) VALUES
\echo '--- 1. is_rule_valid() helper ---' \echo '--- 1. is_rule_valid() helper ---'
-- No bounds: always valid -- 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); morbac.is_rule_valid(NULL::timestamptz, NULL::timestamptz), TRUE);
-- Past valid_from, no valid_until: currently active -- 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); morbac.is_rule_valid('2000-01-01'::timestamptz, NULL), TRUE);
-- Future valid_from: not yet active -- 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); morbac.is_rule_valid('2099-01-01'::timestamptz, NULL), FALSE);
-- Past valid_until: expired -- 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); morbac.is_rule_valid(NULL, '2000-01-01'::timestamptz), FALSE);
-- Future valid_until, no valid_from: currently active -- 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); morbac.is_rule_valid(NULL, '2099-01-01'::timestamptz), TRUE);
-- Active window: past start, future end -- 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); morbac.is_rule_valid('2000-01-01'::timestamptz, '2099-01-01'::timestamptz), TRUE);
-- Fully past window (both start and end in the past) -- 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); morbac.is_rule_valid('2000-01-01'::timestamptz, '2001-01-01'::timestamptz), FALSE);
-- Fully future window (both start and end in the future) -- 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); 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'; 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 ''
\echo '--- 3. Expired rule (valid_until in the past) ---' \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); ), FALSE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 4: Future rule valid_from in the future -- Section 4: Future rule - valid_from in the future
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 4. Future rule (valid_from in the future) ---' \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); ), TRUE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 7: Temporal prohibition expired prohibition no longer blocks -- Section 7: Temporal prohibition - expired prohibition no longer blocks
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 7. Expired prohibition no longer blocks ---' \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' 'approve', 'temp_view'
), TRUE); ), 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]', SELECT morbac.t('Alice (CEO, inherits manager) approves temp_view [temporal rule, active]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000001'::uuid, '30000000-0000-0000-0000-000000000001'::uuid,
+5 -5
View File
@@ -22,7 +22,7 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '06 CROSS-ORGANIZATIONAL RULES' \echo '06 - CROSS-ORGANIZATIONAL RULES'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -53,7 +53,7 @@ SELECT morbac.t('Nina has eng_auditor role at Engineering',
), TRUE); ), 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 ''
\echo '--- 1. No cross-org rule: access denied by default ---' \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); ), 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 ''
\echo '--- 2. Cross-org permission ---' \echo '--- 2. Cross-org permission ---'
@@ -123,7 +123,7 @@ SELECT morbac.t('Judy (Sales sales_rep) reads GlobalTech documents [no rule for
\echo '' \echo ''
\echo '--- 3. Role must be held in source org ---' \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]', SELECT morbac.t('Karl (no role) reads GlobalTech reports via cross-org rule [no role in source]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid, '30000000-0000-0000-0000-000000000011'::uuid,
@@ -140,7 +140,7 @@ SELECT morbac.t('Karl (no role) reads GlobalTech documents [no access anywhere]'
), FALSE); ), 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 ''
\echo '--- 4. Cross-org prohibition ---' \echo '--- 4. Cross-org prohibition ---'
+3 -3
View File
@@ -19,7 +19,7 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '07 AUDIT LOGGING' \echo '07 - AUDIT LOGGING'
\echo '================================================================' \echo '================================================================'
-- Clear any existing audit log entries to start fresh -- 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 ''
\echo '--- 5. Query audit log by record_id ---' \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 morbac.t_eq('Audit log has 3 entries for test rule record (INSERT + UPDATE + DELETE)',
(SELECT COUNT(*) FROM morbac.audit_log (SELECT COUNT(*) FROM morbac.audit_log
WHERE table_name = 'rules' WHERE table_name = 'rules'
@@ -245,7 +245,7 @@ BEGIN
END; 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) INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES ( VALUES (
'30000000-0000-0000-0000-000000000011', '30000000-0000-0000-0000-000000000011',
+4 -4
View File
@@ -10,7 +10,7 @@
-- 2. Grant permissions via regular rules, verify access -- 2. Grant permissions via regular rules, verify access
-- 3. Prohibition overrides permission (standard engine behavior) -- 3. Prohibition overrides permission (standard engine behavior)
-- 4. Role hierarchy applies: senior role inherits permissions -- 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 -- 6. RLS on morbac tables: session user cannot read/write without rules
-- 7. Rules are org-scoped -- 7. Rules are org-scoped
-- --
@@ -19,7 +19,7 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '08 SYSTEM ACCESS' \echo '08 - SYSTEM ACCESS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -218,7 +218,7 @@ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO morbac_rls_tester;
SET SESSION AUTHORIZATION 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.user_id = '30000000-0000-0000-0000-000000000007';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; 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 role_id = '20000000-0001-0000-0000-000000000005'
AND org_id = '10000000-0000-0000-0000-000000000001'; 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.user_id = '30000000-0000-0000-0000-000000000004';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
+11 -11
View File
@@ -3,14 +3,14 @@
-- ============================================================================= -- =============================================================================
-- Tests miscellaneous utility functions and advanced features: -- Tests miscellaneous utility functions and advanced features:
-- --
-- 1. pending_obligations returns obligation rules for a user -- 1. pending_obligations - returns obligation rules for a user
-- 2. pending_recommendations returns recommendation rules for a user -- 2. pending_recommendations - returns recommendation rules for a user
-- 3. Obligations/recommendations do NOT affect is_allowed() -- 3. Obligations/recommendations do NOT affect is_allowed()
-- 3b. Conflict resolution: prohibition voids obligation; prohibition/obligation voids recommendation -- 3b. Conflict resolution: prohibition voids obligation; prohibition/obligation voids recommendation
-- 4. user_has_role checks if user holds a named role -- 4. user_has_role - checks if user holds a named role
-- 5. user_roles_in_org lists all roles for user in org -- 5. user_roles_in_org - lists all roles for user in org
-- 6. eval_context evaluates context predicates directly -- 6. eval_context - evaluates context predicates directly
-- 7. Derived roles computed via evaluator function -- 7. Derived roles - computed via evaluator function
-- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping -- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping
-- --
-- Prerequisites: 00_setup.sql -> 08_system_access.sql -- Prerequisites: 00_setup.sql -> 08_system_access.sql
@@ -18,7 +18,7 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '09 UTILITIES, DERIVED ROLES, RLS' \echo '09 - UTILITIES, DERIVED ROLES, RLS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -139,7 +139,7 @@ SELECT morbac.t('Dave has recommendation for read audit_logs but no permission [
), FALSE); ), FALSE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 3b: Conflict resolution prohibition voids obligation/recommendation -- Section 3b: Conflict resolution - prohibition voids obligation/recommendation
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 3b. Conflict resolution: prohibition voids obligation and recommendation ---' \echo '--- 3b. Conflict resolution: prohibition voids obligation and recommendation ---'
@@ -330,13 +330,13 @@ SELECT morbac.t('eval_context(end_of_quarter) = TRUE',
\echo '' \echo ''
\echo '--- 7. Derived roles ---' \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) INSERT INTO morbac.roles (id, org_id, name, description)
VALUES ( VALUES (
'20000000-0001-0000-0000-000000000011', '20000000-0001-0000-0000-000000000011',
'10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001',
'senior_employee', '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 -- 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' AND source = 'derived'
), TRUE); ), 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', SELECT morbac.t('Eve has no derived roles in comprehensive roles',
NOT EXISTS( NOT EXISTS(
SELECT 1 FROM morbac.get_comprehensive_roles( SELECT 1 FROM morbac.get_comprehensive_roles(
+5 -5
View File
@@ -2,8 +2,8 @@
-- Activity-View Binding Tests -- Activity-View Binding Tests
-- ============================================================================= -- =============================================================================
-- Tests opt-in activity-to-view restrictions: -- Tests opt-in activity-to-view restrictions:
-- 1. No bindings defined any view is allowed -- 1. No bindings defined - any view is allowed
-- 2. Binding defined listed view is allowed, unlisted view is blocked -- 2. Binding defined - listed view is allowed, unlisted view is blocked
-- 3. Blocking applies to cross_org_rules as well -- 3. Blocking applies to cross_org_rules as well
-- 4. Removing all bindings lifts the restriction -- 4. Removing all bindings lifts the restriction
-- --
@@ -12,11 +12,11 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '10 ACTIVITY-VIEW BINDINGS' \echo '10 - ACTIVITY-VIEW BINDINGS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 1: No bindings unconstrained -- Section 1: No bindings - unconstrained
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 1. No bindings: any view is allowed ---' \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'; 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 ''
\echo '--- 2. Binding defined: allowed view works, unlisted view blocked ---' \echo '--- 2. Binding defined: allowed view works, unlisted view blocked ---'
+13 -13
View File
@@ -3,18 +3,18 @@
-- ============================================================================= -- =============================================================================
-- Tests rules.scope and cross_org_rules.source_org_id = NULL: -- Tests rules.scope and cross_org_rules.source_org_id = NULL:
-- --
-- 1. scope='self' (default) exact org only, unchanged behavior -- 1. scope='self' (default) - exact org only, unchanged behavior
-- 2. scope='subtree' rule at root covers self + Engineering + Sales -- 2. scope='subtree' - rule at root covers self + Engineering + Sales
-- 3. scope='descendants' covers Engineering + Sales but NOT GlobalTech itself -- 3. scope='descendants' - covers Engineering + Sales but NOT GlobalTech itself
-- 4. scope='children' covers direct children only -- 4. scope='children' - covers direct children only
-- 5. New org added after rule creation picked up automatically (cache invalidation) -- 5. New org added after rule creation - picked up automatically (cache invalidation)
-- --
-- Prerequisites: 00_setup.sql -> 10_activity_view_bindings.sql -- Prerequisites: 00_setup.sql -> 10_activity_view_bindings.sql
-- ============================================================================= -- =============================================================================
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '11 SCOPE RULES AND GLOBAL CROSS-ORG RULES' \echo '11 - SCOPE RULES AND GLOBAL CROSS-ORG RULES'
\echo '================================================================' \echo '================================================================'
-- Setup: create a dedicated role for scope tests (avoid polluting existing rules) -- Setup: create a dedicated role for scope tests (avoid polluting existing rules)
@@ -23,7 +23,7 @@ VALUES (
'20000000-0001-0000-0000-000000000012', '20000000-0001-0000-0000-000000000012',
'10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001',
'analyst', 'analyst',
'Data analyst scope tests' 'Data analyst - scope tests'
); );
-- Assign Karl (previously no role) as analyst at GlobalTech -- 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 ''
\echo '--- 1. scope=self (default) ---' \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'; 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 ''
\echo '--- 2. scope=subtree ---' \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'; 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 ''
\echo '--- 3. scope=descendants ---' \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'; 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 ''
\echo '--- 4. scope=children ---' \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'; 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 ''
\echo '--- 5. Dynamic scope: new org covered automatically ---' \echo '--- 5. Dynamic scope: new org covered automatically ---'
@@ -225,7 +225,7 @@ VALUES (
'10000000-0000-0000-0000-000000000001' '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]', SELECT morbac.t('Karl (analyst) reads documents in Legal Dept [new org, covered by subtree scope]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid, '30000000-0000-0000-0000-000000000011'::uuid,
+8 -8
View File
@@ -17,11 +17,11 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '12 USER RULES' \echo '12 - USER RULES'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 1: No user rule Karl (no role) is denied -- Section 1: No user rule - Karl (no role) is denied
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 1. No user rule: access denied ---' \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); ), 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 ''
\echo '--- 2. Direct user permission ---' \echo '--- 2. Direct user permission ---'
@@ -78,7 +78,7 @@ SELECT morbac.t('Karl reads GlobalTech financial_data [documents rule covers it
'read', 'financial_data' 'read', 'financial_data'
), TRUE); ), 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]', SELECT morbac.t('Karl reads GlobalTech contracts [no user rule, no hierarchy coverage]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid, '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' 'read', 'public_data'
), FALSE); ), 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]', SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibition]',
morbac.is_allowed_nocache( morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid, '30000000-0000-0000-0000-000000000004'::uuid,
@@ -150,7 +150,7 @@ SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibiti
), TRUE); ), TRUE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 5: Priority higher-priority user permission overrides prohibition -- Section 5: Priority - higher-priority user permission overrides prohibition
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 5. Priority: user permission overrides user prohibition ---' \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 '30000000-0000-0000-0000-000000000004'::uuid
), TRUE); ), 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'; SET morbac.target_user_id = '30000000-0000-0000-0000-000000000004';
SELECT morbac.t('rls_check passes when row user_id matches target_user_id', 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 '30000000-0000-0000-0000-000000000001'::uuid
), FALSE); ), 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)', SELECT morbac.t('rls_check passes when no row user_id passed (filter skipped)',
morbac.rls_check( morbac.rls_check(
'read', 'documents', 'read', 'documents',
+36 -27
View File
@@ -1,25 +1,27 @@
-- ============================================================================= -- =============================================================================
-- rls_check Tests -- rls_check Tests
-- ============================================================================= -- =============================================================================
-- Tests morbac.rls_check() with all session-org combinations, focusing on -- Tests morbac.rls_check() with all session-org combinations. A NULL row org
-- the two cases fixed to support global rows (p_row_org_id IS NULL): -- is an unattributed object: an org filter (single pin, or org_ids without a
-- null marker) excludes it; it is reachable via no filter or a null marker.
-- --
-- 1. No user_id set: always FALSE -- 1. No user_id set: always FALSE
-- 2. Single org context -- 2. Single org context
-- a. org-scoped row, matching org -- a. org-scoped row, matching org
-- b. org-scoped row, different org (blocked) -- b. org-scoped row, different org (blocked)
-- c. global row (NULL org_id): uses session org -- c. NULL row: filtered out under an org pin (orphan not requested)
-- 3. org_ids filter -- 3. org_ids filter
-- a. org-scoped row in list -- a. org-scoped row in list
-- b. org-scoped row not in list (blocked) -- b. org-scoped row not in list (blocked)
-- c. global row + global permission [was FALSE, now TRUE] -- c. NULL row, list without null marker: filtered out even with a grant
-- d. global row + global prohibition [was FALSE, now correctly FALSE] -- c2. NULL row, list with null marker + global permission: allowed
-- e. global row + no rule [was FALSE, still FALSE] -- d. NULL row, list with null marker + global prohibition: blocked
-- e. NULL row, list with null marker + no rule: blocked
-- 4. No org context -- 4. No org context
-- a. org-scoped row: uses row's org -- a. org-scoped row: uses row's org
-- b. global row + global permission [was FALSE, now TRUE] -- b. NULL row + global permission [orphan + global rules -> TRUE]
-- c. global row + global prohibition [was FALSE, now correctly FALSE] -- c. NULL row + global prohibition [blocked]
-- d. global row + no rule [was FALSE, still FALSE] -- d. NULL row + no rule [blocked]
-- --
-- User state carried from previous tests: -- User state carried from previous tests:
-- Karl (30000000-0000-0000-0000-000000000011): -- Karl (30000000-0000-0000-0000-000000000011):
@@ -35,7 +37,7 @@
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 1: No user_id set always FALSE -- Section 1: No user_id set - always FALSE
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 1. No user_id: always FALSE ---' \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.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ 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)', SELECT morbac.t('rls_check single org, org row matches session org (Karl/documents)',
morbac.rls_check('read', 'documents', morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid), '10000000-0000-0000-0000-000000000001'::uuid),
TRUE); 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)', SELECT morbac.t('rls_check single org, org row from different org (blocked)',
morbac.rls_check('read', 'documents', morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000002'::uuid), '10000000-0000-0000-0000-000000000002'::uuid),
FALSE); FALSE);
-- 2c: global row — uses session org, so Karl's user_rule on GlobalTech applies -- 2c: NULL row - filtered out under a single org pin (orphan not requested)
SELECT morbac.t('rls_check single org, global row (NULL org_id): uses session org', SELECT morbac.t('rls_check single org, NULL row filtered out under org pin',
morbac.rls_check('read', 'documents', NULL), morbac.rls_check('read', 'documents', NULL),
TRUE); FALSE);
-- 2c (no permission): Karl has no rule for contracts in GlobalTech -- 2c (contracts): still filtered out regardless of permission
SELECT morbac.t('rls_check single org, global row (NULL org_id): no permission for contracts', SELECT morbac.t('rls_check single org, NULL row filtered out (contracts)',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
FALSE); FALSE);
@@ -96,19 +98,19 @@ RESET morbac.org_id;
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001"]'; -- [GlobalTech HQ] 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)', SELECT morbac.t('rls_check org_ids, org row in list (Karl/documents/GlobalTech)',
morbac.rls_check('read', 'documents', morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid), '10000000-0000-0000-0000-000000000001'::uuid),
TRUE); 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)', SELECT morbac.t('rls_check org_ids, org row not in list (blocked)',
morbac.rls_check('read', 'documents', morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000002'::uuid), '10000000-0000-0000-0000-000000000002'::uuid),
FALSE); FALSE);
-- 3c: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only -- 3c: NULL row, list WITHOUT null marker - filtered out even with a global grant
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES ( VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl '30000000-0000-0000-0000-000000000011', -- Karl
@@ -117,7 +119,14 @@ VALUES (
'permission' 'permission'
); );
SELECT morbac.t('rls_check org_ids, global row + global permission [new: was FALSE]', SELECT morbac.t('rls_check org_ids without null marker, NULL row filtered out despite grant',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
-- 3c2: NULL row, list WITH null marker + global permission - allowed
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001", null]';
SELECT morbac.t('rls_check org_ids with null marker, NULL row + global permission',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
TRUE); TRUE);
@@ -125,7 +134,7 @@ DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011' WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts'; AND activity = 'read' AND view = 'contracts';
-- 3d: global row + global prohibition -- 3d: NULL row, list with null marker + global prohibition
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES ( VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl '30000000-0000-0000-0000-000000000011', -- Karl
@@ -134,7 +143,7 @@ VALUES (
'prohibition' 'prohibition'
); );
SELECT morbac.t('rls_check org_ids, global row + global prohibition', SELECT morbac.t('rls_check org_ids with null marker, NULL row + global prohibition',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
FALSE); FALSE);
@@ -142,8 +151,8 @@ DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011' WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts'; AND activity = 'read' AND view = 'contracts';
-- 3e: global row + no rule -- 3e: NULL row, list with null marker + no rule
SELECT morbac.t('rls_check org_ids, global row + no rule', SELECT morbac.t('rls_check org_ids with null marker, NULL row + no rule',
morbac.rls_check('read', 'contracts', NULL), morbac.rls_check('read', 'contracts', NULL),
FALSE); FALSE);
@@ -158,7 +167,7 @@ RESET morbac.org_ids;
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl 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)', SELECT morbac.t('rls_check no org context, org row: uses row org (Karl/documents/GlobalTech)',
morbac.rls_check('read', 'documents', morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid), '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), '10000000-0000-0000-0000-000000000002'::uuid),
FALSE); 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) INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES ( VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl '30000000-0000-0000-0000-000000000011', -- Karl
+225
View File
@@ -0,0 +1,225 @@
-- =============================================================================
-- Unattributed (no-org) rule Tests
-- =============================================================================
-- Tests scope = 'unattributed': rules authored by an org that govern objects
-- with no org (org_id IS NULL), evaluated via is_allowed(user, NULL, ...).
--
-- Key properties:
-- - role-bound: the user must hold the rule's role in the declaring org
-- - partitioned: unattributed rules never reach real-org objects, and
-- org-scoped rules never reach no-org objects
-- - composes with prohibition precedence, revocation, delegation, multi-org
-- - has_permission() capability probe surfaces the grant
--
-- Fixtures created here (isolated from the GlobalTech scenario):
-- AttribCorp (org) role triage user Nomad
-- IntakeCorp (org) role intake user Nomad (multi-org over the same pool)
--
-- Prerequisites: 00_setup.sql -> 15_rls_check.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '16 -- UNATTRIBUTED'
\echo '================================================================'
RESET morbac.user_id;
RESET morbac.org_id;
RESET morbac.org_ids;
INSERT INTO morbac.orgs (id, name) VALUES
('40000000-0000-0000-0000-000000000001','AttribCorp'),
('40000000-0000-0000-0000-000000000002','IntakeCorp');
INSERT INTO morbac.roles (id, org_id, name) VALUES
('40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001','triage'),
('40000000-0000-0000-0000-0000000000a2','40000000-0000-0000-0000-000000000002','intake');
-- Nomad: triage in AttribCorp; Scout: delegatee
\set NOMAD '''40000000-0000-0000-0000-0000000000f1'''
\set SCOUT '''40000000-0000-0000-0000-0000000000f2'''
\set STRANGER '''40000000-0000-0000-0000-0000000000f9'''
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:NOMAD,'40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001');
\set CTX '(SELECT id FROM morbac.contexts WHERE name = ''always'')'
-- unattributed permission for triage
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'permission','unattributed');
\echo ''
\echo '--- 1. Authorization + role binding ---'
SELECT morbac.t('unattributed grant -> orphan object allowed',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), TRUE);
SELECT morbac.t('stranger without role -> orphan denied',
morbac.is_allowed_nocache(:STRANGER, NULL, 'read','documents'), FALSE);
\echo ''
\echo '--- 2. Partition: unattributed does not reach real-org objects ---'
SELECT morbac.t('unattributed rule does NOT grant AttribCorp object',
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), FALSE);
-- add a self permission; now the org object is allowed, orphan still allowed
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'permission','self');
SELECT morbac.t('self rule grants AttribCorp object',
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), TRUE);
SELECT morbac.t('orphan still allowed alongside self rule',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), TRUE);
\echo ''
\echo '--- 3. Partition: org-scoped does not reach no-org objects ---'
-- remove the unattributed rule; self remains
DELETE FROM morbac.rules
WHERE org_id = '40000000-0000-0000-0000-000000000001'
AND role_id = '40000000-0000-0000-0000-0000000000a1'
AND scope = 'unattributed';
SELECT morbac.t('self rule does NOT reach orphan object',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
SELECT morbac.t('AttribCorp object still allowed by self rule',
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), TRUE);
-- restore unattributed permission for the remaining tests
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'permission','unattributed');
\echo ''
\echo '--- 4. Prohibition precedence on orphan objects ---'
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope, priority)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
'read','documents', :CTX,'prohibition','unattributed', 10);
SELECT morbac.t('unattributed prohibition (prio 10) beats permission (prio 0)',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
DELETE FROM morbac.rules
WHERE org_id = '40000000-0000-0000-0000-000000000001'
AND role_id = '40000000-0000-0000-0000-0000000000a1'
AND scope = 'unattributed' AND modality = 'prohibition';
\echo ''
\echo '--- 5. Revocation ---'
DELETE FROM morbac.user_roles WHERE user_id = :NOMAD;
SELECT morbac.t('revoke role -> orphan access removed',
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:NOMAD,'40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001');
\echo ''
\echo '--- 6. Delegation propagates orphan access ---'
INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_until)
VALUES (:NOMAD, :SCOUT, '40000000-0000-0000-0000-0000000000a1',
'40000000-0000-0000-0000-000000000001', now() + interval '1 day');
SELECT morbac.t('delegatee gains orphan access via delegated role',
morbac.is_allowed_nocache(:SCOUT, NULL, 'read','documents'), TRUE);
\echo ''
\echo '--- 7. Multi-org: independent authority over the same pool ---'
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:NOMAD,'40000000-0000-0000-0000-0000000000a2','40000000-0000-0000-0000-000000000002');
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000002','40000000-0000-0000-0000-0000000000a2',
'write','documents', :CTX,'permission','unattributed');
SELECT morbac.t('IntakeCorp role independently grants orphan write',
morbac.is_allowed_nocache(:NOMAD, NULL, 'write','documents'), TRUE);
\echo ''
\echo '--- 8. has_permission capability probe ---'
SELECT morbac.t('has_permission TRUE via orphan grant',
morbac.has_permission(:NOMAD, 'read','documents'), TRUE);
SELECT morbac.t('has_permission FALSE for ungranted activity/view',
morbac.has_permission(:STRANGER, 'read','documents'), FALSE);
\echo ''
\echo '--- 9. Org target triad: specific / unattributed / all ---'
-- role-based 'all': every org, unattributed included
INSERT INTO morbac.roles (id, org_id, name) VALUES
('40000000-0000-0000-0000-0000000000a3','40000000-0000-0000-0000-000000000001','overseer');
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
(:SCOUT,'40000000-0000-0000-0000-0000000000a3','40000000-0000-0000-0000-000000000001');
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a3',
'approve','documents', :CTX,'permission','all');
SELECT morbac.t('scope all reaches a specific org',
morbac.is_allowed_nocache(:SCOUT, '40000000-0000-0000-0000-000000000002','approve','documents'), TRUE);
SELECT morbac.t('scope all reaches unattributed objects',
morbac.is_allowed_nocache(:SCOUT, NULL,'approve','documents'), TRUE);
-- roleless user_rule targeting unattributed (org_id NULL)
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (:STRANGER, NULL, 'read','reports', :CTX,'permission');
SELECT morbac.t('user_rule with no org grants unattributed objects',
morbac.is_allowed_nocache(:STRANGER, NULL,'read','reports'), TRUE);
SELECT morbac.t('user_rule with no org does NOT reach a real org',
morbac.is_allowed_nocache(:STRANGER, '40000000-0000-0000-0000-000000000001','read','reports'), FALSE);
-- roleless user_rule targeting a specific org stays partitioned
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (:STRANGER, '40000000-0000-0000-0000-000000000002', 'read','documents', :CTX,'permission');
SELECT morbac.t('user_rule with an org grants that org',
morbac.is_allowed_nocache(:STRANGER, '40000000-0000-0000-0000-000000000002','read','documents'), TRUE);
SELECT morbac.t('user_rule with an org does NOT reach unattributed',
morbac.is_allowed_nocache(:STRANGER, NULL,'read','documents'), FALSE);
\echo ''
\echo '--- 10. rls_check filter matrix ---'
SELECT set_config('morbac.user_id', :NOMAD, false);
RESET morbac.org_id;
RESET morbac.org_ids;
SELECT morbac.t('no filter: orphan row visible',
morbac.rls_check('read','documents', NULL), TRUE);
SET morbac.org_id = '40000000-0000-0000-0000-000000000001';
SELECT morbac.t('single org pin: orphan row filtered out',
morbac.rls_check('read','documents', NULL), FALSE);
RESET morbac.org_id;
SET morbac.org_ids = '[null]';
SELECT morbac.t('org_ids [null]: orphan row visible',
morbac.rls_check('read','documents', NULL), TRUE);
SELECT morbac.t('org_ids [null]: real-org row filtered out',
morbac.rls_check('read','documents', '40000000-0000-0000-0000-000000000001'::uuid), FALSE);
SET morbac.org_ids = '["40000000-0000-0000-0000-000000000001", null]';
SELECT morbac.t('org_ids [AttribCorp, null]: orphan row visible',
morbac.rls_check('read','documents', NULL), TRUE);
SELECT morbac.t('org_ids [AttribCorp, null]: AttribCorp row visible',
morbac.rls_check('read','documents', '40000000-0000-0000-0000-000000000001'::uuid), TRUE);
RESET morbac.user_id;
RESET morbac.org_ids;
\echo ''
\echo '=== Unattributed Tests Completed ==='