Compare commits

...

14 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
marc 6dd1026c5a build: signed PGXN release tooling + Drone pipeline
Build the distribution from src/ (control, versioned SQL, META, MIT LICENSE,
PGXS Makefile, install README), checksum it, and sign an Ed25519 JWT manifest
(audience pgmorbac-release) with the verification public key attached to the
release. `npm run release` does it locally; the Drone pipeline does it on a
v* tag once the repo is Trusted with the pgmorbac_release_key + gitea_token
secrets.
2026-07-22 09:22:26 +02:00
marc 3ee1e426a8 chore: rehome to crudy/pgmorbac, align version to 0.1.0
Public home is now git.villains.fr/crudy/pgmorbac with docs at
pgmorbac.villains.fr. Point META.json, README and CHANGELOG links there,
drop the tracked .DS_Store, and renumber the first public release to 0.1.0
to match the published @crudy/pgmorbac package, the embedded framework SQL
and the download page. License stays MIT.
2026-07-22 09:16:59 +02:00
marc 14592b91ae test(hierarchies): cover cache invalidation on hierarchy removal 2026-07-22 09:16:59 +02:00
marc 499df68bf5 fix(rls): not handling global permissions 2026-04-07 01:42:47 +02:00
marc 97a13bfeb3 fix(views): add missing and remove unnecessary 2026-04-07 01:07:20 +02:00
marc 4eefc0897f fix(service_principal): add first rules setup 2026-04-06 16:37:53 +02:00
marc e653b38b88 fix(authorization): system principal regretion 2026-04-06 16:16:29 +02:00
marc 0e3b90af22 feat(system): introduce global rules and system principals for easier system account handling 2026-04-04 09:23:46 +02:00
marc f64cd73159 feat(rules): add rules for users 2026-04-01 23:18:01 +02:00
marc 5ef8bae1b4 feat(all): add scope handling in rules 2026-04-01 23:07:20 +02:00
marc 8273d56d4e misc(docs): cleanup some rules/policy words 2026-03-29 22:21:20 +02:00
52 changed files with 4118 additions and 1608 deletions
Vendored
BIN
View File
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
---
kind: pipeline
type: docker
name: release
# Cuts the signed PGXN distribution and publishes it as a Gitea release.
# Triggered by version tags (v<major>.<minor>.<patch>).
#
# Repo must be Trusted in Drone. Required secrets:
# pgmorbac_release_key Ed25519 private key PEM (content, not a path)
# gitea_token Gitea API token with write access to crudy/pgmorbac
trigger:
event:
- tag
ref:
- refs/tags/v*
steps:
- name: build-and-release
image: node:24-alpine
environment:
PGMORBAC_RELEASE_KEY_PEM:
from_secret: pgmorbac_release_key
GITEA_TOKEN:
from_secret: gitea_token
GITEA_URL: https://git.villains.fr
GITEA_REPO: crudy/pgmorbac
commands:
- apk add --no-cache zip bash
- npm install --no-audit --no-fund
- umask 077 && printf '%s\n' "$PGMORBAC_RELEASE_KEY_PEM" > /tmp/release-key.pem
- PGMORBAC_RELEASE_KEY=/tmp/release-key.pem node scripts/build-dist.mjs
- node scripts/gitea-release.mjs
+5 -1
View File
@@ -15,4 +15,8 @@ Thumbs.db
*.swo *.swo
*~ *~
.vscode/ .vscode/
.idea/ .idea/
# Release tooling
node_modules/
dist/
package-lock.json
+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).
## [1.0.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
[1.0.0]: https://github.com/MarcVillain/pgmorbac/releases/tag/v1.0.0 [1.0.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v1.0.0
+6 -5
View File
@@ -1,7 +1,7 @@
{ {
"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": "1.0.0", "version": "1.0.0",
"maintainer": [ "maintainer": [
"Marc VILLAIN <marc.villain@epita.fr>" "Marc VILLAIN <marc.villain@epita.fr>"
@@ -22,19 +22,20 @@
} }
}, },
"resources": { "resources": {
"homepage": "https://github.com/MarcVillain/pgmorbac", "homepage": "https://pgmorbac.villains.fr",
"bugtracker": { "bugtracker": {
"web": "https://github.com/MarcVillain/pgmorbac/issues" "web": "https://git.villains.fr/crudy/pgmorbac/issues"
}, },
"repository": { "repository": {
"url": "https://github.com/MarcVillain/pgmorbac.git", "url": "https://git.villains.fr/crudy/pgmorbac.git",
"web": "https://git.villains.fr/crudy/pgmorbac",
"type": "git" "type": "git"
} }
}, },
"generated_by": "pgmorbac contributors", "generated_by": "pgmorbac contributors",
"meta-spec": { "meta-spec": {
"version": "1.0.0", "version": "1.0.0",
"url": "https://pgxn.org/spec/" "url": "https://pgxn.org/meta/spec.txt"
}, },
"tags": [ "tags": [
"access control", "access control",
+5 -5
View File
@@ -7,7 +7,7 @@ PROJECT_FILENAME = pgmorbac
PROJECT_VERSION = $(shell ./tools/get_version.sh $(PROJECT_FILENAME).control) PROJECT_VERSION = $(shell ./tools/get_version.sh $(PROJECT_FILENAME).control)
# Docker configuration # Docker configuration
DOCKER_CONTAINER ?= postgres DOCKER_CONTAINER ?= pgmorbac_postgres_test
DOCKER_PORT ?= 5432 DOCKER_PORT ?= 5432
# For development, work on source files in src/ # For development, work on source files in src/
@@ -43,8 +43,8 @@ install: build
@cp $(OUTPUT_DEV_FILENAME) $(OUTPUT_RELEASE_FILENAME) @cp $(OUTPUT_DEV_FILENAME) $(OUTPUT_RELEASE_FILENAME)
@./tools/install.sh $(PROJECT_FILENAME) $(PROJECT_VERSION) @./tools/install.sh $(PROJECT_FILENAME) $(PROJECT_VERSION)
.PHONY: check .PHONY: test
check: install test: install
@echo "Running tests..." @echo "Running tests..."
@dropdb morbac_test 2>/dev/null || true @dropdb morbac_test 2>/dev/null || true
@createdb morbac_test @createdb morbac_test
@@ -89,8 +89,8 @@ docker-uninstall:
@./tools/docker_uninstall.sh $(DOCKER_CONTAINER) $(PROJECT_FILENAME) @./tools/docker_uninstall.sh $(DOCKER_CONTAINER) $(PROJECT_FILENAME)
# Build and run tests in Docker container # Build and run tests in Docker container
.PHONY: docker-check .PHONY: docker-test
docker-check: docker-install docker-test: docker-install
@echo "Running tests in Docker..." @echo "Running tests in Docker..."
@./tools/docker_test.sh $(DOCKER_CONTAINER) morbac_test postgres @./tools/docker_test.sh $(DOCKER_CONTAINER) morbac_test postgres
+44 -6
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
@@ -33,7 +34,7 @@ A PostgreSQL extension implementing the Multi-OrBAC access control model - enabl
```bash ```bash
# Clone repository # Clone repository
git clone https://github.com/MarcVillain/pgmorbac.git git clone https://git.villains.fr/crudy/pgmorbac.git
cd pgmorbac cd pgmorbac
# Build and install extension # Build and install extension
@@ -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
@@ -218,7 +257,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
```bash ```bash
git clone https://github.com/MarcVillain/pgmorbac.git git clone https://git.villains.fr/crudy/pgmorbac.git
cd pgmorbac cd pgmorbac
# Edit source files in src/ # Edit source files in src/
@@ -238,9 +277,8 @@ git push --tags
## Support ## Support
- Read the [documentation](docs/DOCUMENTATION.md) - Read the [documentation](https://pgmorbac.villains.fr)
- Report bugs via [GitHub Issues](https://github.com/MarcVillain/pgmorbac/issues) - Report bugs via [Gitea Issues](https://git.villains.fr/crudy/pgmorbac/issues)
- Ask questions in [Discussions](https://github.com/MarcVillain/pgmorbac/discussions)
- Security issues: see [SECURITY.md](SECURITY.md) - Security issues: see [SECURITY.md](SECURITY.md)
## Comparison with Traditional RBAC ## Comparison with Traditional RBAC
-378
View File
@@ -1,378 +0,0 @@
# Administration Guide
This guide explains how to set up organization-scoped administrators without relying on database superadmins.
## Concept
Multi-OrBAC allows you to delegate administrative capabilities to specific roles within each organization. This means:
- Organization administrators can manage users, roles, and policies within their own organization
- No need for database-level superadmin access for day-to-day administration
- Fine-grained control over what each admin role can do
- Admins cannot affect other organizations
## Administration Rules
The `morbac.admin_rules` table defines administrative capabilities for specific roles:
**Key columns:**
- `org_id`, `role_id`: Role receiving capabilities
- `admin_activity`: Action type (e.g., 'manage', 'assign_role')
- `admin_target`: Target type (e.g., 'policies', 'roles', specific role name, '*' for wildcard)
- `modality`: Permission or prohibition
- `context_id`: Optional conditional evaluation
## Common Admin Patterns
### 1. Organization Administrator
Full admin within their organization:
```sql
-- Create admin role
INSERT INTO morbac.roles (org_id, name, description)
SELECT id, 'org_admin', 'Organization administrator'
FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant ability to manage policies
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'manage',
'policies',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Grant ability to manage roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'manage',
'roles',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Grant ability to assign ALL roles (wildcard)
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
'*',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Assign someone as org admin
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
SELECT
'alice-uuid'::uuid,
r.id,
o.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
```
### 2. HR Manager (User/Role Assignment Only)
Can assign users to roles but cannot modify policies:
```sql
-- Create HR manager role
INSERT INTO morbac.roles (org_id, name, description)
SELECT id, 'hr_manager', 'HR manager - can assign users to roles'
FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant ability to assign specific roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
target_role,
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
CROSS JOIN (VALUES ('employee'), ('manager'), ('contractor')) AS roles(target_role)
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
-- Prohibit assigning admin roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
'org_admin',
'prohibition',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
```
### 3. Security Manager (Policy Management Only)
Can define policies but cannot assign users:
```sql
-- Create security manager role
INSERT INTO morbac.roles (org_id, name, description)
SELECT id, 'security_manager', 'Security manager - can manage policies'
FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant policy management
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'manage',
'policies',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'security_manager';
```
## Using Admin Functions
### Check Permissions
```sql
-- Check if Alice can manage policies
SELECT morbac.can_manage_policies('alice-uuid'::uuid, org_id);
-- Check if Alice can manage roles
SELECT morbac.can_manage_roles('alice-uuid'::uuid, org_id);
-- Check if Alice can assign a specific role
SELECT morbac.can_manage_user_role('alice-uuid'::uuid, org_id, employee_role_id);
-- General admin check
SELECT morbac.is_admin_allowed('alice-uuid'::uuid, org_id, 'assign_role', 'manager');
```
### Assign/Revoke Roles Safely
```sql
-- Alice (HR manager) assigns Bob to employee role
SELECT morbac.admin_assign_role(
'alice-uuid'::uuid, -- Admin user
'bob-uuid'::uuid, -- Target user
employee_role_id, -- Role to assign
org_id -- Organization
);
-- Alice revokes Bob's employee role
SELECT morbac.admin_revoke_role(
'alice-uuid'::uuid,
'bob-uuid'::uuid,
employee_role_id,
org_id
);
```
These functions automatically:
- Check if Alice has permission to manage the role
- Validate SoD constraints
- Validate cardinality constraints
- Raise exceptions if constraints are violated
## Application Integration
### REST API Example
Your application can expose admin endpoints that use these functions:
```sql
-- Endpoint: POST /api/orgs/:orgId/users/:userId/roles/:roleId
-- Handler checks:
CREATE OR REPLACE FUNCTION app.assign_role_endpoint(
p_requesting_user_id UUID,
p_org_id UUID,
p_target_user_id UUID,
p_role_id UUID
)
RETURNS JSON
LANGUAGE plpgsql
AS $$
DECLARE
v_result JSON;
BEGIN
BEGIN
PERFORM morbac.admin_assign_role(
p_requesting_user_id,
p_target_user_id,
p_role_id,
p_org_id
);
v_result := json_build_object(
'success', true,
'message', 'Role assigned successfully'
);
EXCEPTION WHEN OTHERS THEN
v_result := json_build_object(
'success', false,
'error', SQLERRM
);
END;
RETURN v_result;
END;
$$;
```
### Row-Level Security for Admin Tables
Protect admin configuration with RLS:
```sql
-- Only org admins can see admin rules
ALTER TABLE morbac.admin_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY admin_rules_access ON morbac.admin_rules
FOR SELECT
USING (
org_id = morbac.current_org_id()
AND (
-- User is org admin
morbac.can_manage_policies(morbac.current_user_id(), org_id)
OR morbac.can_manage_roles(morbac.current_user_id(), org_id)
)
);
-- Only org admins can modify admin rules
CREATE POLICY admin_rules_modify ON morbac.admin_rules
FOR ALL
USING (
org_id = morbac.current_org_id()
AND morbac.can_manage_policies(morbac.current_user_id(), org_id)
);
```
## Context-Based Admin Rules
You can limit admin actions to specific contexts (e.g., business hours):
```sql
-- Create context for business hours
CREATE OR REPLACE FUNCTION morbac.context_business_hours()
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN EXTRACT(DOW FROM CURRENT_DATE) BETWEEN 1 AND 5
AND EXTRACT(HOUR FROM CURRENT_TIME) BETWEEN 9 AND 17;
END;
$$;
INSERT INTO morbac.contexts (name, description, evaluator) VALUES
('business_hours', 'Monday-Friday 9am-5pm', 'morbac.context_business_hours');
-- HR can only assign roles during business hours
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
'*',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'business_hours')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
```
## Best Practices
1. **Principle of Least Privilege**: Grant only necessary admin capabilities to each role
2. **Separation of Duties**: Separate policy management from user assignment
3. **Audit Trail**: Log all admin actions (consider triggers on user_roles, rules tables)
4. **Context-Based**: Use contexts to limit when admin actions can occur
5. **Multiple Admins**: Use role cardinality to ensure multiple admins exist
6. **Prohibitions**: Use prohibition admin rules to explicitly deny certain actions
## Example: Complete Setup
```sql
-- 1. Create organization
INSERT INTO morbac.orgs (name) VALUES ('Acme Corp');
-- 2. Create roles
INSERT INTO morbac.roles (org_id, name)
SELECT id, name FROM morbac.orgs,
(VALUES ('org_admin'), ('hr_manager'), ('employee'), ('manager')) AS roles(name)
WHERE morbac.orgs.name = 'Acme Corp';
-- 3. Set up org_admin with full permissions
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT o.id, r.id, activity, target, 'permission', c.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
CROSS JOIN (VALUES
('manage', 'policies'),
('manage', 'roles'),
('assign_role', '*')
) AS perms(activity, target)
CROSS JOIN morbac.contexts c
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin' AND c.name = 'always';
-- 4. Set up hr_manager with limited permissions
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT o.id, r.id, 'assign_role', role_name, 'permission', c.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
CROSS JOIN (VALUES ('employee'), ('manager')) AS assignable(role_name)
CROSS JOIN morbac.contexts c
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager' AND c.name = 'always';
-- 5. Assign Alice as org_admin
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
SELECT 'alice-uuid'::uuid, r.id, o.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- 6. Now Alice can assign Bob as hr_manager
SELECT morbac.admin_assign_role(
'alice-uuid'::uuid,
'bob-uuid'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'hr_manager' AND org_id =
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')),
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')
);
-- 7. Now Bob can assign users to employee/manager roles
SELECT morbac.admin_assign_role(
'bob-uuid'::uuid,
'charlie-uuid'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id =
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')),
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')
);
```
## Summary
Multi-OrBAC provides complete delegation of administrative capabilities:
- **No superadmins needed**: for day-to-day operations
- **Organization-scoped**: admins only affect their own org
- **Fine-grained**: control exactly what each admin role can do
- **Safe**: automatic constraint validation (SoD, cardinality)
- **Auditable**: all actions go through tracked functions
- **Context-aware**: limit when admin actions can occur
+373 -126
View File
@@ -23,7 +23,7 @@ pgmorbac is built on these principles:
2. **Schema Isolation**: All objects in `morbac` schema 2. **Schema Isolation**: All objects in `morbac` schema
3. **Default Deny**: No permission = access denied 3. **Default Deny**: No permission = access denied
4. **Prohibition Precedence**: Prohibitions override permissions at equal or unset priority; a permission with strictly higher priority wins 4. **Prohibition Precedence**: Prohibitions override permissions at equal or unset priority; a permission with strictly higher priority wins
5. **Organization-Centric**: All policies scoped to organizations 5. **Organization-Centric**: All rules scoped to organizations
6. **Multi-Tenant Native**: Users and resources can span organizations 6. **Multi-Tenant Native**: Users and resources can span organizations
### Authorization Flow ### Authorization Flow
@@ -39,10 +39,13 @@ flowchart TD
D --> G D --> G
E --> G E --> G
F --> G F --> G
G --> H[Find Highest-Priority Prohibition] G --> SP{System Principal?}
G --> I[Find Highest-Priority Permission] SP -->|Yes| I[Find Highest-Priority Permission]
H --> J{Compare Priorities} SP -->|No| H[Find Highest-Priority Prohibition]
I --> J H --> H2[+ Global Prohibitions step 3.5]
I --> I2[+ Global Permissions step 6.5]
H2 --> J{Compare Priorities}
I2 --> J
J -->|Permission priority > Prohibition priority| K[ALLOW] J -->|Permission priority > Prohibition priority| K[ALLOW]
J -->|Prohibition exists, no higher-priority permission| L[DENY] J -->|Prohibition exists, no higher-priority permission| L[DENY]
J -->|No prohibition, permission found| K J -->|No prohibition, permission found| K
@@ -88,6 +91,11 @@ erDiagram
views ||--o{ activity_view_bindings : "allowed_for" views ||--o{ activity_view_bindings : "allowed_for"
contexts ||--o{ rules : "condition" contexts ||--o{ rules : "condition"
contexts ||--o{ global_rules : "condition"
activities ||--o{ global_rules : "action"
views ||--o{ global_rules : "target"
system_principals ||--o{ global_rules : "ruleset"
roles ||--o{ delegations : "delegated" roles ||--o{ delegations : "delegated"
roles ||--o{ negative_role_assignments : "prohibited" roles ||--o{ negative_role_assignments : "prohibited"
@@ -115,9 +123,8 @@ 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 policy rules linking org, role, activity, view, context, and modality. Indexed on `(org_id, role_id, activity, view, modality)` for fast lookups. **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.policy**: Policy DSL using names instead of UUIDs. Insert here, then call `compile_policy()` to generate rules.
### Advanced Feature Tables ### Advanced Feature Tables
@@ -178,28 +185,40 @@ Dynamically computed roles via custom functions.
**morbac.cross_org_rules** **morbac.cross_org_rules**
Inter-organizational access policies. Inter-organizational access rules.
**Key columns:** **Key columns:**
- `source_org_id`: Organization where role is held - `source_org_id`: Organization where the user must hold the role
- `target_org_id`: Organization where access is granted - `target_org_id`: Organization where access is granted
- `role_id`, `activity`, `view`: Policy specification - `role_id`, `activity`, `view`: Rule specification
- `context_id`: Contextual condition - `context_id`: Contextual condition
- `modality`: Permission or prohibition - `modality`: Permission or prohibition
**Behavior:** Allows roles in source organization to access resources in target organization. **Behavior:** Allows roles in a source organization to access resources in a target organization.
**morbac.admin_rules** **morbac.system_principals**
Administration meta-policies for delegated management. 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:**
- `org_id`, `role_id`: Role receiving admin capabilities - `user_id`: UUID of the service account
- `admin_activity`: Admin action (e.g., `create_rule`, `assign_role`, `delete_rule`) - `description`: Human-readable label
- `admin_target`: What can be administered (e.g., `rules`, `roles`, `users`)
- `context_id`, `modality`: Context condition and permission/prohibition
**Behavior:** Enables organization-scoped administrators without database superuser privileges. **Behavior:** System principals bypass all prohibition evaluation. Only the DB owner can insert or remove entries (no RLS write policies).
**morbac.global_rules**
System-wide rules with no org or role binding.
**Key columns:**
- `user_id`: NULL = all users; non-NULL = specific user
- `activity`: NULL = any activity; non-NULL = specific activity (hierarchy applies)
- `view`: NULL = any view; non-NULL = specific view (hierarchy applies)
- `context_id`: Contextual condition
- `modality`: Permission or prohibition
- `priority`: Optional; same resolution semantics as `morbac.rules`
**Behavior:** Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in `is_allowed_nocache()`. NULL on `activity` or `view` matches any value - no hierarchy setup required for broad rules.
**morbac.activity_view_bindings** **morbac.activity_view_bindings**
@@ -322,118 +341,180 @@ FROM morbac.contexts WHERE name = 'business_hours';
- Return FALSE on errors for safe defaults - Return FALSE on errors for safe defaults
- Test thoroughly as contexts affect security - Test thoroughly as contexts affect security
### Policy DSL ### Inserting rules
The Policy DSL provides a simplified way to declare policies using human-readable names instead of UUIDs, making policy management more intuitive. Insert directly into `morbac.rules`. Resolve names to UUIDs with a JOIN:
**DSL Compilation Flow:**
```mermaid
flowchart LR
A[Policy DSL] --> B[compile_policy]
B --> C{Resolve Names}
C --> D[Create Activities/Views]
C --> E[Lookup Orgs/Roles]
D --> F[Generate Rules]
E --> F
F --> G[morbac.rules]
```
**Example: Simple Policy Setup**
```sql ```sql
-- Use names instead of UUIDs INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality) VALUES SELECT o.id, r.id, 'read', 'documents', c.id, 'permission'
('Acme Corp', 'employee', 'read', 'documents', 'permission'); FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'employee'
-- Compile into actual rules JOIN morbac.contexts c ON c.name = 'always'
SELECT * FROM morbac.compile_policy(); WHERE o.name = 'Acme Corp';
``` ```
**Compiler Behavior:** For bulk inserts use a VALUES list joined to the lookup tables:
- Creates missing activities/views automatically ```sql
- Resolves names to UUIDs for orgs/roles/contexts INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
- Creates entries in `morbac.rules` table SELECT o.id, r.id, v.activity, v.view, c.id, v.modality::morbac.modality
- Reports errors for missing orgs/roles/contexts FROM (VALUES
- Safe to run multiple times (idempotent) ('Acme Corp', 'employee', 'read', 'documents', 'always', 'permission'),
- Returns detailed results for each policy ('Acme Corp', 'employee', 'write', 'documents', 'business_hours', 'permission'),
('Acme Corp', 'contractor', 'read', 'financial_data','always', 'prohibition')
**When to Use Policy DSL vs Direct Rules:** ) AS v(org_name, role_name, activity, view, context_name, modality)
JOIN morbac.orgs o ON o.name = v.org_name
- **Use Policy DSL**: Initial setup, bulk imports, human-readable policy files JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
- **Use Direct Rules**: Dynamic policies, application-generated rules, when you already have UUIDs JOIN morbac.contexts c ON c.name = v.context_name;
```
## Advanced Features ## Advanced Features
### Organization Rule Scope ### Organization Rule Scope
Rules in pgmorbac are always scoped to a single organization (`org_id`). To apply a rule across multiple organizations in a hierarchy, use `get_org_scope(org_id, scope)`, which returns a set of `(org_id, depth)` rows for the named scope relative to the given org. 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 | Returns | | Scope | Covers |
|---|---| |---|---|
| `'self'` | The org itself only | | `'self'` | The rule's org only (default) |
| `'children'` | Direct children only (depth = 1) | | `'children'` | Direct children of the rule's org |
| `'descendants'` | All descendants, excluding self | | `'descendants'` | All descendants, excluding the rule's org itself |
| `'subtree'` | Self + all descendants | | `'subtree'` | The rule's org + all descendants |
| `'parent'` | Direct parent only | | `'parent'` | Direct parent of the rule's org |
| `'ancestors'` | All ancestors, excluding self | | `'ancestors'` | All ancestors, excluding the rule's org itself |
| `'lineage'` | Self + all ancestors | | `'lineage'` | The rule's org + all ancestors |
| `'root'` | Topmost ancestor only | | `'root'` | Topmost ancestor of the rule's org |
| `'unattributed'` | Objects with **no** org (`org_id IS NULL`) only |
| `'all'` | Every organization, unattributed objects included |
An optional third argument `p_max_depth` limits how many levels are traversed. **Example: Analyst reads reports across the whole company**
**Example: Entire subtree (org + all subsidiaries)** One rule at the root org covers the entire hierarchy, including orgs created in the future:
A holding company grants its auditor role read access across all subsidiaries:
```sql ```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT org_id, :role_id, 'read', 'financials', 'permission' SELECT o.id, r.id, 'read', 'reports', c.id, 'permission', 'subtree'
FROM morbac.get_org_scope(:holding_org_id, 'subtree'); FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'analyst'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'Acme Corp';
``` ```
**Example: Direct children only** **Example: Regional manager applies only to direct divisions**
A regional manager role applies only to first-level divisions, not deeper sub-divisions:
```sql ```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT org_id, :role_id, 'manage', 'teams', 'permission' SELECT o.id, r.id, 'update', 'teams', c.id, 'permission', 'children'
FROM morbac.get_org_scope(:region_org_id, 'children'); FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'regional_manager'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'EMEA Region';
``` ```
**Example: Two levels deep** **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.
A policy that covers a org, its direct children, and their children: **`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 ```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT org_id, :role_id, 'read', 'documents', 'permission' SELECT o.id, r.id, 'read', 'documents', c.id, 'permission', 'unattributed'
FROM morbac.get_org_scope(:org_id, 'subtree', 2); 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';
``` ```
**Example: Root org only** 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.
A compliance rule attached to the top-level org, regardless of where you start: Prohibitions, priorities, contexts, temporal validity, role hierarchy, delegation, derived roles, negative assignments and SoD all apply unchanged:
```sql ```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) -- block the same role during an embargo, outranking the permission
SELECT org_id, :role_id, 'audit', 'all_data', 'permission' INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope, priority)
FROM morbac.get_org_scope(:any_child_org_id, 'root'); 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';
``` ```
**Example: All ancestors (upward propagation)** Granting a single user access without a role uses `user_rules` with no org:
A report created in a child org becomes visible to all parent orgs:
```sql ```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
SELECT org_id, :role_id, 'read', 'reports', 'permission' SELECT '...user...'::uuid, NULL, 'read', 'documents', c.id, 'permission'
FROM morbac.get_org_scope(:child_org_id, 'ancestors'); FROM morbac.contexts c WHERE c.name = 'always';
``` ```
For cross-organization access between unrelated orgs, use `cross_org_rules` and `admin_rules`. #### 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
@@ -495,7 +576,7 @@ INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason)
VALUES (alice_id, admin_role_id, org_id, 'Under investigation'); VALUES (alice_id, admin_role_id, org_id, 'Under investigation');
-- Alice loses admin access even though role assignment remains -- Alice loses admin access even though role assignment remains
SELECT morbac.is_allowed(alice_id, org_id, 'manage', 'users'); -- FALSE SELECT morbac.is_allowed(alice_id, org_id, 'create', 'user_roles'); -- FALSE
``` ```
### Separation of Duty ### Separation of Duty
@@ -536,7 +617,7 @@ SELECT morbac.check_cardinality_violation(admin_role_id, FALSE); -- removing
### Rule Conflict Detection ### Rule Conflict Detection
When inserting or updating a rule, pgmorbac automatically warns if the new rule conflicts with an existing one due to modality precedence. Conflicts are non-blocking (the insert succeeds) but a `WARNING` is emitted so you can catch unintentional policy contradictions. When inserting or updating a rule, pgmorbac automatically warns if the new rule conflicts with an existing one due to modality precedence. Conflicts are non-blocking (the insert succeeds) but a `WARNING` is emitted so you can catch unintentional rule contradictions.
A conflict is flagged when two rules share the same `(org, role, activity, view, context)` tuple and their modalities create a dead rule: A conflict is flagged when two rules share the same `(org, role, activity, view, context)` tuple and their modalities create a dead rule:
@@ -587,11 +668,9 @@ VALUES (project_lead_role_id, 'morbac.eval_project_lead'::regproc);
### Cross-Organizational Rules ### Cross-Organizational Rules
Grant access across organization boundaries. Grant access across organization boundaries via `morbac.cross_org_rules`. The user must hold the specified role in `source_org_id` to gain access to `target_org_id` resources.
**Example: Corporate Auditor** **Example: Auditor from HQ accesses a subsidiary**
Global auditors can review subsidiary financial data:
```sql ```sql
INSERT INTO morbac.cross_org_rules ( INSERT INTO morbac.cross_org_rules (
@@ -601,28 +680,172 @@ INSERT INTO morbac.cross_org_rules (
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission' (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'
); );
-- Auditor from Global HQ can now access Subsidiary data
SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TRUE SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TRUE
``` ```
### Administration Rules **Scope vs. cross-org rules - when to use which:**
Delegate administrative capabilities to specific roles. | Need | Use |
|---|---|
| Role in org A covers org A's descendants | `rules.scope = 'subtree'` (or other scope) |
| Role in org A accesses a different org B | `cross_org_rules` with `source_org_id = A` |
**Example: HR Manager** ### Global Rules
HR can assign users to roles without being a superuser: 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`
| Column | Purpose |
|---|---|
| `user_id` | NULL = all users; UUID = specific user |
| `activity` | NULL = any activity; specific name = that activity (hierarchy applies) |
| `view` | NULL = any view; specific name = that view (hierarchy applies) |
| `modality` | `permission` or `prohibition` |
| `priority` | Optional; same semantics as `morbac.rules` |
**Evaluation order:** Global prohibitions are evaluated at step 3.5 (after local, cross-org, and user-level prohibitions). Global permissions at step 6.5 (after all other permission sources). Both contribute to the same priority accumulators used in step 7 resolution.
**Use case: system account that reads all resources**
```sql ```sql
-- Grant HR the ability to assign roles INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) VALUES (
VALUES (org_id, hr_manager_role_id, 'assign_role', 'roles', :system_account_uuid,
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); 'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
-- Check if HR user can assign roles 'permission'
SELECT morbac.is_admin_allowed(hr_user_id, org_id, 'assign_role', 'roles'); -- TRUE );
``` ```
`view = NULL` matches every view. Use `activity = NULL` too if the account needs all actions.
**Use case: protect service accounts from modification by anyone**
```sql
INSERT INTO morbac.views (name) VALUES ('service_accounts');
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
SELECT NULL, a.act, 'service_accounts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 100
FROM (VALUES ('delete'), ('update')) AS a(act);
```
Priority 100 ensures this prohibition overrides any role-based permission. To exempt a specific user, add a subject-specific permission with higher priority via `morbac.user_rules`.
### 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.
**Table:** `morbac.system_principals`
| Column | Purpose |
|---|---|
| `user_id` | UUID of the service account (external, from your auth system) |
| `description` | Human-readable label |
**What is protected (trigger level - fires for all users including superusers):**
| Table | Blocked operations |
|---|---|
| `user_roles` | INSERT, UPDATE, DELETE |
| `user_rules` | INSERT, UPDATE, DELETE |
| `negative_role_assignments` | INSERT, UPDATE, DELETE |
| `delegations` | INSERT, UPDATE involving the principal |
| `global_rules` | All operations where `user_id` matches a system principal |
**Authorization behavior:** Prohibition evaluation (steps 1-3.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter.
**Ruleset:** Define permissions for system principals via `global_rules` at deploy time. Those rows are immutable once inserted - no one can modify or delete them. Use `activity=NULL, view=NULL` to grant full access, or restrict to specific activities/views:
```sql
-- Register the service account (DB owner only)
INSERT INTO morbac.system_principals (user_id, description)
VALUES (:service_uuid, 'Backend API worker');
-- Grant full access (immutable after insert)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (:service_uuid, NULL, NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
-- Or restrict to specific operations
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (:service_uuid, 'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
```
**Access control on the registry itself:** `morbac.system_principals` has a SELECT-only RLS policy - a user needs `is_allowed(..., 'read', 'system_principals')` to list them. INSERT/UPDATE/DELETE have no RLS policy, so they are blocked for all non-superusers automatically. Only the database owner can register or remove system principals.
### Administration
Admin operations use the same `is_allowed()` engine as everything else - no separate code path.
**System table RLS**
`morbac.*` tables have RLS policies. `is_allowed()` and all its internal callees are `SECURITY DEFINER`, running as the extension owner and bypassing RLS. This breaks the recursion: RLS policies call `is_allowed()`, which queries morbac tables without re-triggering the policies.
The database owner (superuser) bypasses RLS by default - use that privilege only during bootstrap.
**System view names**
The extension seeds built-in activities (`create`, `read`, `update`, `delete`) and system view names (`orgs`, `roles`, `rules`, `user_roles`, `contexts`, `activities`, `views`, `delegations`, `cross_org_rules`, `user_rules`, `global_rules`, `system_principals`) at install time.
These names are config-driven. Override with `morbac.set_config()` to use your own naming conventions - the new name must then exist in `morbac.views` and your rules must reference it:
```sql
-- Rename 'rules' to 'policies' in your system
SELECT morbac.set_config('system_view.rules', 'policies');
INSERT INTO morbac.views (name, description) VALUES ('policies', 'Authorization policies');
-- Your rules must now grant 'create'/'delete'/... on 'policies', not 'rules'
```
**Granting access to system tables**
Insert rules like any other rule:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, v.activity, v.view, c.id, 'permission'
FROM (VALUES
('manager', 'create', 'rules'),
('manager', 'delete', 'rules'),
('hr_manager', 'create', 'user_roles'),
('hr_manager', 'delete', 'user_roles')
) AS v(role_name, activity, view)
JOIN morbac.orgs o ON o.name = 'Acme Corp'
JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
JOIN morbac.contexts c ON c.name = 'always';
```
**Bootstrapping the first organization**
As the database owner (bypasses RLS):
```sql
INSERT INTO morbac.orgs (name) VALUES ('Acme Corp');
INSERT INTO morbac.roles (org_id, name)
SELECT id, 'superuser' FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant full access to all system views
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, a.activity, v.view, c.id, 'permission'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'superuser'
JOIN morbac.contexts c ON c.name = 'always'
CROSS JOIN unnest(ARRAY['create','read','update','delete']) AS a(activity)
CROSS JOIN unnest(ARRAY['orgs','roles','rules','user_roles','contexts','activities','views','delegations','cross_org_rules']) AS v(view)
WHERE o.name = 'Acme Corp';
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
SELECT 'your-user-uuid'::uuid, r.id, o.id
FROM morbac.orgs o JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'superuser';
```
After this, that user can manage the system through the application without database owner access.
### Temporal Constraints ### Temporal Constraints
Rules can have time-based validity periods. Rules can have time-based validity periods.
@@ -673,7 +896,6 @@ SELECT morbac.enable_audit('user_roles');
-- Enable auditing on multiple tables -- Enable auditing on multiple tables
SELECT morbac.enable_audit('rules'); SELECT morbac.enable_audit('rules');
SELECT morbac.enable_audit('delegations'); SELECT morbac.enable_audit('delegations');
SELECT morbac.enable_audit('admin_rules');
``` ```
**Disabling audit logging:** **Disabling audit logging:**
@@ -721,7 +943,7 @@ WHERE table_name = 'rules'
``` ```
**Best practices:** **Best practices:**
- Enable auditing on security-critical tables: `user_roles`, `rules`, `delegations`, `admin_rules`, `sod_conflicts` - Enable auditing on security-critical tables: `user_roles`, `rules`, `delegations`, `sod_conflicts`
- Create indexes on frequently queried columns: `CREATE INDEX ON morbac.audit_log(table_name, timestamp DESC)` - Create indexes on frequently queried columns: `CREATE INDEX ON morbac.audit_log(table_name, timestamp DESC)`
- Implement retention policies to archive old audit logs - Implement retention policies to archive old audit logs
- Use JSONB operators to query `old_data` and `new_data` efficiently - Use JSONB operators to query `old_data` and `new_data` efficiently
@@ -731,10 +953,17 @@ WHERE table_name = 'rules'
### Authorization Functions ### Authorization Functions
**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Checks prohibitions first, then permissions, defaults to deny. **`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).
@@ -747,13 +976,19 @@ 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
SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2); SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2);
``` ```
**`org_in_scope(target_org_id, rule_org_id, scope)`**: Returns TRUE if `target_org_id` falls within `get_org_scope(rule_org_id, scope)`. Used internally by the authorization engine to evaluate scoped rules. Short-circuits for `'self'` scope.
**`get_inherited_roles(role_id)`**: Returns all junior roles (transitive). **`get_inherited_roles(role_id)`**: Returns all junior roles (transitive).
**`get_effective_activities(activity)`**: Returns activity plus all child activities. **`get_effective_activities(activity)`**: Returns activity plus all child activities.
@@ -770,31 +1005,42 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2);
### Administration Functions ### Administration Functions
**`is_admin_allowed(user_id, org_id, admin_activity, admin_target)`**: Check if user has administrative permission for the given activity/target combination. **`assign_role(target_user_id, role_id, org_id)`**: Assigns a role with SoD and cardinality validation. Authorization is enforced by RLS on `morbac.user_roles`.
**`revoke_role(target_user_id, role_id, org_id)`**: Revokes a role with cardinality validation. Authorization is enforced by RLS on `morbac.user_roles`.
**`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.
### Policy DSL Functions
**`compile_policy()`**: Compile policy DSL into rules. Returns TABLE with success status and messages. Idempotent.
### RLS Helper Functions ### RLS Helper Functions
**`current_user_id()`**: Get user ID from `request.header.x-user-id` (PostgREST) or `current_setting('morbac.user_id')`. **`current_user_id()`**: Get user ID from `request.header.x-user-id` (PostgREST) or `current_setting('morbac.user_id')`.
**`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).
@@ -839,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
@@ -1022,7 +1269,7 @@ SELECT EXISTS(
| **Delegation** | Manual implementation | Native temporal delegation | | **Delegation** | Manual implementation | Native temporal delegation |
| **SoD** | Application logic | Database-enforced constraints | | **SoD** | Application logic | Database-enforced constraints |
| **Cross-tenant** | Not supported | Native cross-org rules | | **Cross-tenant** | Not supported | Native cross-org rules |
| **Audit** | Application layer | Meta-policies (admin rules) | | **Audit** | Application layer | Native audit log with field-level change tracking |
--- ---
+14
View File
@@ -0,0 +1,14 @@
{
"name": "pgmorbac-release-tools",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Release tooling for the pgmorbac PostgreSQL extension (build + sign + publish the PGXN distribution).",
"scripts": {
"dist": "node scripts/build-dist.mjs",
"release": "node scripts/build-dist.mjs && node scripts/gitea-release.mjs"
},
"devDependencies": {
"jose": "^5"
}
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// Builds the PGXN-compatible pgmorbac source distribution from src/:
// dist/pgmorbac-<version>.zip extension files + META.json
// dist/pgmorbac-<version>.zip.sha256 checksum (sha256sum -c format)
// dist/pgmorbac-<version>.manifest.jwt Ed25519-signed release manifest
// dist/pgmorbac-<version>.pub.pem the verification public key
// PGMORBAC_RELEASE_KEY must point to the Ed25519 private key (PKCS8 PEM).
// The build self-verifies before exiting.
import { createHash, createPublicKey } from 'crypto';
import { execFileSync } from 'child_process';
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
const AUDIENCE = 'pgmorbac-release';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
function fail(msg) {
console.error(`[pgmorbac-dist] ${msg}`);
process.exit(1);
}
function controlVersion() {
const m = readFileSync(join(root, 'pgmorbac.control'), 'utf8').match(/default_version\s*=\s*'([^']+)'/);
if (!m) fail('pgmorbac.control has no default_version');
return m[1];
}
const version = controlVersion();
const meta = JSON.parse(readFileSync(join(root, 'META.json'), 'utf8'));
const baseScript = `pgmorbac--${version}.sql`;
if (meta.version !== version || meta.provides?.pgmorbac?.file !== baseScript) {
fail(`META.json version/provides out of sync with control ${version}`);
}
const keyPath = process.env.PGMORBAC_RELEASE_KEY;
if (!keyPath) fail('PGMORBAC_RELEASE_KEY is not set (path to the Ed25519 private key PEM)');
if (!existsSync(keyPath)) fail(`PGMORBAC_RELEASE_KEY points to a missing file: ${keyPath}`);
const privatePem = readFileSync(keyPath, 'utf8');
const distDir = join(root, 'dist');
const stageName = `pgmorbac-${version}`;
const stageDir = join(distDir, stageName);
rmSync(distDir, { recursive: true, force: true });
mkdirSync(stageDir, { recursive: true });
execFileSync(join(root, 'tools', 'build.sh'), ['src', join(stageDir, baseScript)], { cwd: root, stdio: 'inherit' });
copyFileSync(join(root, 'pgmorbac.control'), join(stageDir, 'pgmorbac.control'));
copyFileSync(join(root, 'META.json'), join(stageDir, 'META.json'));
copyFileSync(join(root, 'LICENSE'), join(stageDir, 'LICENSE'));
const upgradesDir = join(root, 'src', 'upgrades');
if (existsSync(upgradesDir)) {
for (const f of readdirSync(upgradesDir).filter(n => n.endsWith('.sql'))) {
copyFileSync(join(upgradesDir, f), join(stageDir, f));
}
}
writeFileSync(join(stageDir, 'Makefile'), `EXTENSION = pgmorbac
DATA = $(wildcard pgmorbac--*.sql)
PG_CONFIG ?= pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
`);
writeFileSync(join(stageDir, 'README-INSTALL.md'), `# Installing pgmorbac ${version}
Full documentation: https://pgmorbac.villains.fr
## As a PostgreSQL extension (recommended)
make install # uses pg_config / PGXS
psql -d mydb -c 'CREATE EXTENSION pgmorbac'
## Plain SQL (no filesystem access to the server)
psql -v ON_ERROR_STOP=1 -d mydb -f ${baseScript}
## Verifying this distribution
sha256sum -c pgmorbac-${version}.zip.sha256
The signed manifest (pgmorbac-${version}.manifest.jwt) is an Ed25519 JWT
(audience \`${AUDIENCE}\`) carrying the artifact sha256, verifiable against
pgmorbac-${version}.pub.pem published on the same release.
`);
const zipName = `${stageName}.zip`;
try {
execFileSync('zip', ['-r', '-X', '-q', zipName, stageName], { cwd: distDir });
} catch (err) {
fail(`zip failed (is the zip CLI installed?): ${err instanceof Error ? err.message : err}`);
}
const zipBytes = readFileSync(join(distDir, zipName));
const sha256 = createHash('sha256').update(zipBytes).digest('hex');
writeFileSync(join(distDir, `${zipName}.sha256`), `${sha256} ${zipName}\n`);
const manifest = { name: 'pgmorbac', version, artifacts: [{ name: zipName, sha256 }] };
const signingKey = await importPKCS8(privatePem.trim(), 'EdDSA');
const token = await new SignJWT({ manifest })
.setProtectedHeader({ alg: 'EdDSA', typ: 'JWT' })
.setIssuedAt()
.setAudience(AUDIENCE)
.sign(signingKey);
writeFileSync(join(distDir, `${stageName}.manifest.jwt`), `${token}\n`);
const publicPem = createPublicKey(privatePem).export({ type: 'spki', format: 'pem' }).toString();
writeFileSync(join(distDir, `${stageName}.pub.pem`), publicPem);
const verifyKey = await importSPKI(publicPem, 'EdDSA');
const { payload } = await jwtVerify(token, verifyKey, { algorithms: ['EdDSA'], audience: AUDIENCE });
const rehash = createHash('sha256').update(readFileSync(join(distDir, zipName))).digest('hex');
if (payload.manifest.artifacts[0].sha256 !== rehash || payload.manifest.version !== version) {
fail('self-verification failed: manifest does not match the built artifact');
}
console.log(`[pgmorbac-dist] Built ${zipName}`);
console.log(`[pgmorbac-dist] sha256 ${sha256}`);
console.log('[pgmorbac-dist] Manifest signed and self-verified');
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Creates (or reuses) release v<version> on Gitea and attaches the dist assets.
// Run after build-dist.mjs. Requires GITEA_URL, GITEA_REPO, GITEA_TOKEN.
import { readFileSync, existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const version = readFileSync(join(root, 'pgmorbac.control'), 'utf8').match(/default_version\s*=\s*'([^']+)'/)?.[1];
if (!version) {
console.error('[gitea-release] could not read default_version from pgmorbac.control');
process.exit(1);
}
const base = process.env.GITEA_URL;
const repo = process.env.GITEA_REPO;
const token = process.env.GITEA_TOKEN;
if (!base || !repo || !token) {
console.error('[gitea-release] GITEA_URL, GITEA_REPO and GITEA_TOKEN are required');
process.exit(1);
}
const distDir = join(root, 'dist');
const files = [
`pgmorbac-${version}.zip`,
`pgmorbac-${version}.zip.sha256`,
`pgmorbac-${version}.manifest.jwt`,
`pgmorbac-${version}.pub.pem`,
];
for (const f of files) {
if (!existsSync(join(distDir, f))) {
console.error(`[gitea-release] Missing artifact ${f} - run build-dist.mjs first`);
process.exit(1);
}
}
const api = (path) => `${base}/api/v1/repos/${repo}${path}`;
const headers = { Authorization: `token ${token}` };
async function ensureRelease() {
const create = await fetch(api('/releases'), {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: `v${version}`,
name: `pgmorbac ${version}`,
body: `PGXN-compatible SQL distribution. Verify with sha256sum -c and the signed manifest (public key attached; see https://pgmorbac.villains.fr/download).`,
draft: false,
prerelease: false,
}),
});
if (create.ok) return (await create.json()).id;
if (create.status !== 409) {
console.error(`[gitea-release] Release creation failed: ${create.status} ${await create.text()}`);
process.exit(1);
}
const existing = await fetch(api(`/releases/tags/v${version}`), { headers });
if (!existing.ok) {
console.error(`[gitea-release] Release exists but lookup failed: ${existing.status}`);
process.exit(1);
}
return (await existing.json()).id;
}
const releaseId = await ensureRelease();
for (const f of files) {
const form = new FormData();
form.set('attachment', new Blob([readFileSync(join(distDir, f))]), f);
const res = await fetch(api(`/releases/${releaseId}/assets?name=${encodeURIComponent(f)}`), {
method: 'POST', headers, body: form,
});
if (!res.ok) {
const text = await res.text();
if (res.status === 409 || text.includes('already exist')) {
console.log(`[gitea-release] ${f} already attached - skipping`);
continue;
}
console.error(`[gitea-release] Upload of ${f} failed: ${res.status} ${text}`);
process.exit(1);
}
console.log(`[gitea-release] Attached ${f}`);
}
console.log(`[gitea-release] Release v${version} ready on ${repo}`);
+7
View File
@@ -23,3 +23,10 @@ CREATE INDEX idx_activity_hierarchy_junior ON morbac.activity_hierarchy(junior_a
COMMENT ON TABLE morbac.activity_hierarchy IS 'Activity hierarchy - senior activities imply junior activities'; COMMENT ON TABLE morbac.activity_hierarchy IS 'Activity hierarchy - senior activities imply junior activities';
COMMENT ON COLUMN morbac.activity_hierarchy.senior_activity IS 'Senior activity (implies junior)'; COMMENT ON COLUMN morbac.activity_hierarchy.senior_activity IS 'Senior activity (implies junior)';
COMMENT ON COLUMN morbac.activity_hierarchy.junior_activity IS 'Junior activity (implied by senior)'; COMMENT ON COLUMN morbac.activity_hierarchy.junior_activity IS 'Junior activity (implied by senior)';
INSERT INTO morbac.activities (name, description) VALUES
('create', 'Create new entities'),
('read', 'Read or list entities'),
('update', 'Modify existing entities'),
('delete', 'Remove entities')
ON CONFLICT (name) DO NOTHING;
+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;
-148
View File
@@ -1,148 +0,0 @@
CREATE OR REPLACE FUNCTION morbac.can_manage_user_role(
p_admin_user_id UUID,
p_org_id UUID,
p_target_role_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_role_name TEXT;
BEGIN
SELECT name INTO v_role_name
FROM morbac.roles
WHERE id = p_target_role_id AND org_id = p_org_id;
IF v_role_name IS NULL THEN
RETURN FALSE;
END IF;
RETURN morbac.is_admin_allowed(
p_admin_user_id,
p_org_id,
'assign_role',
v_role_name
);
END;
$$;
COMMENT ON FUNCTION morbac.can_manage_user_role(UUID, UUID, UUID) IS
'Check if user can assign/revoke a specific role in organization';
CREATE OR REPLACE FUNCTION morbac.can_manage_roles(
p_user_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN morbac.is_admin_allowed(
p_user_id,
p_org_id,
'manage',
'roles'
);
END;
$$;
COMMENT ON FUNCTION morbac.can_manage_roles(UUID, UUID) IS
'Check if user can create/modify/delete roles in organization';
CREATE OR REPLACE FUNCTION morbac.can_manage_policies(
p_user_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN morbac.is_admin_allowed(
p_user_id,
p_org_id,
'manage',
'policies'
);
END;
$$;
COMMENT ON FUNCTION morbac.can_manage_policies(UUID, UUID) IS
'Check if user can manage policies in organization';
CREATE OR REPLACE FUNCTION morbac.admin_assign_role(
p_admin_user_id UUID,
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT morbac.can_manage_user_role(p_admin_user_id, p_org_id, p_role_id) THEN
RAISE EXCEPTION 'User % does not have permission to assign role % in org %',
p_admin_user_id, p_role_id, p_org_id;
END IF;
IF morbac.check_sod_violation(p_target_user_id, p_role_id, p_org_id) THEN
RAISE EXCEPTION 'Role assignment would violate Separation of Duty constraints';
END IF;
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (p_target_user_id, p_role_id, p_org_id)
ON CONFLICT (user_id, role_id, org_id) DO NOTHING;
DECLARE
v_cardinality_error TEXT;
BEGIN
v_cardinality_error := morbac.check_cardinality_violation(p_role_id);
IF v_cardinality_error IS NOT NULL THEN
RAISE EXCEPTION 'Role assignment violates cardinality constraint: %', v_cardinality_error;
END IF;
END;
RETURN TRUE;
END;
$$;
COMMENT ON FUNCTION morbac.admin_assign_role(UUID, UUID, UUID, UUID) IS
'Assign role to user with admin permission check and constraint validation';
CREATE OR REPLACE FUNCTION morbac.admin_revoke_role(
p_admin_user_id UUID,
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT morbac.can_manage_user_role(p_admin_user_id, p_org_id, p_role_id) THEN
RAISE EXCEPTION 'User % does not have permission to revoke role % in org %',
p_admin_user_id, p_role_id, p_org_id;
END IF;
DELETE FROM morbac.user_roles
WHERE user_id = p_target_user_id
AND role_id = p_role_id
AND org_id = p_org_id;
DECLARE
v_cardinality_error TEXT;
BEGIN
v_cardinality_error := morbac.check_cardinality_violation(p_role_id, FALSE);
IF v_cardinality_error IS NOT NULL THEN
RAISE WARNING 'Role revocation may violate cardinality constraint: %', v_cardinality_error;
END IF;
END;
RETURN TRUE;
END;
$$;
COMMENT ON FUNCTION morbac.admin_revoke_role(UUID, UUID, UUID, UUID) IS
'Revoke role from user with admin permission check';
-20
View File
@@ -1,20 +0,0 @@
-- Meta-policies defining who can create/modify policies (AdministrationPermission)
CREATE TABLE morbac.admin_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
admin_activity TEXT NOT NULL,
admin_target TEXT NOT NULL,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, admin_activity, admin_target, context_id, modality)
);
CREATE INDEX idx_admin_rules_org_role ON morbac.admin_rules(org_id, role_id);
COMMENT ON TABLE morbac.admin_rules IS 'Administration rules - meta-policies for policy management';
COMMENT ON COLUMN morbac.admin_rules.admin_activity IS 'Admin action: create_rule, modify_rule, delete_rule, assign_role, etc.';
COMMENT ON COLUMN morbac.admin_rules.admin_target IS 'What can be administered: rules, roles, users, orgs, etc.';
+81
View File
@@ -59,6 +59,7 @@ COMMENT ON FUNCTION morbac.cleanup_auth_cache() IS
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change() CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_org_id UUID; v_org_id UUID;
@@ -99,11 +100,72 @@ CREATE TRIGGER trg_invalidate_cache_cross_org
AFTER INSERT OR UPDATE OR DELETE ON morbac.cross_org_rules AFTER INSERT OR UPDATE OR DELETE ON morbac.cross_org_rules
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change();
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_user_rule_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id UUID;
v_org_id UUID;
BEGIN
IF TG_OP = 'DELETE' THEN
v_user_id := OLD.user_id;
v_org_id := OLD.org_id;
ELSE
v_user_id := NEW.user_id;
v_org_id := NEW.org_id;
END IF;
DELETE FROM morbac.auth_cache WHERE user_id = v_user_id AND org_id = v_org_id;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_invalidate_cache_user_rules
AFTER INSERT OR UPDATE OR DELETE ON morbac.user_rules
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_user_rule_change();
-- Global rules have no org scope - any change invalidates the entire cache
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_global_rule_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
DELETE FROM morbac.auth_cache;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_invalidate_cache_global_rules
AFTER INSERT OR UPDATE OR DELETE ON morbac.global_rules
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_global_rule_change();
-- system_principals changes affect prohibition bypass - invalidate per user
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_system_principal_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
DELETE FROM morbac.auth_cache WHERE user_id = v_user_id;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_invalidate_cache_system_principals
AFTER INSERT OR UPDATE OR DELETE ON morbac.system_principals
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_system_principal_change();
-- Refresh materialized hierarchy views when hierarchies change -- Refresh materialized hierarchy views when hierarchies change
CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change() CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
PERFORM morbac.refresh_hierarchy_cache(); PERFORM morbac.refresh_hierarchy_cache();
@@ -123,3 +185,22 @@ FOR EACH STATEMENT EXECUTE FUNCTION morbac.refresh_on_hierarchy_change();
CREATE TRIGGER trg_refresh_view_hierarchy CREATE TRIGGER trg_refresh_view_hierarchy
AFTER INSERT OR UPDATE OR DELETE ON morbac.view_hierarchy AFTER INSERT OR UPDATE OR DELETE ON morbac.view_hierarchy
FOR EACH STATEMENT EXECUTE FUNCTION morbac.refresh_on_hierarchy_change(); FOR EACH STATEMENT EXECUTE FUNCTION morbac.refresh_on_hierarchy_change();
-- Invalidate entire cache when org hierarchy changes.
-- Scoped rules (scope != 'self') depend on the org tree, so any org change
-- may affect which orgs a rule covers.
CREATE OR REPLACE FUNCTION morbac.invalidate_all_cache()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
DELETE FROM morbac.auth_cache;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_invalidate_cache_orgs
AFTER INSERT OR UPDATE OR DELETE ON morbac.orgs
FOR EACH STATEMENT EXECUTE FUNCTION morbac.invalidate_all_cache();
+192 -53
View File
@@ -10,6 +10,18 @@
-- - Each rule has an optional integer priority (NULL = 0, lowest) -- - Each rule has an optional integer priority (NULL = 0, lowest)
-- - 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:
-- - rules.scope selects which objects a rule covers: a specific org
-- (self/subtree/descendants/...), 'unattributed', or 'all'.
-- Evaluated at query time via org_in_scope() - new orgs are covered automatically.
-- - cross_org_rules.source_org_id is always required: user must hold the role there.
-- - user_rules target a specific user directly (no role required); 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,
@@ -20,59 +32,109 @@ CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_rule RECORD; v_rule RECORD;
v_max_prohibition_priority INTEGER := NULL; v_max_prohibition_priority INTEGER := NULL;
v_max_permission_priority INTEGER := NULL; v_max_permission_priority INTEGER := NULL;
v_is_system_principal BOOLEAN;
BEGIN BEGIN
-- STEP 1: Local prohibitions — find the highest-priority applicable one v_is_system_principal := EXISTS (
FOR v_rule IN SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id
SELECT r.context_id, COALESCE(r.priority, 0) AS prio );
FROM morbac.rules r
WHERE r.org_id = p_org_id
AND r.modality = 'prohibition'
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
v_max_prohibition_priority := v_rule.prio;
EXIT;
END IF;
END LOOP;
-- STEP 2: Cross-org prohibitions — update max if a higher priority is found -- STEPS 1-3.5: Prohibitions - skipped entirely for system principals
FOR v_rule IN IF NOT v_is_system_principal THEN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr -- STEP 1: Local prohibitions - find the highest-priority applicable one
WHERE cr.target_org_id = p_org_id FOR v_rule IN
AND cr.modality = 'prohibition' SELECT r.context_id, COALESCE(r.priority, 0) AS prio
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) FROM morbac.rules r
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope)
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) AND r.modality = 'prohibition'
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
ORDER BY COALESCE(cr.priority, 0) DESC AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
LOOP AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
IF morbac.eval_context(v_rule.context_id) THEN AND morbac.is_rule_valid(r.valid_from, r.valid_until)
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN ORDER BY COALESCE(r.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
v_max_prohibition_priority := v_rule.prio; v_max_prohibition_priority := v_rule.prio;
EXIT;
END IF; END IF;
EXIT; END LOOP;
END IF;
END LOOP;
-- STEP 3: Local permissions — find the highest-priority applicable one -- STEP 2: Cross-org prohibitions - update max if a higher priority is found
FOR v_rule IN
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
FROM morbac.cross_org_rules cr
WHERE cr.target_org_id = p_org_id
AND cr.modality = 'prohibition'
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 3: User-level prohibitions - direct user rules, update max if higher
FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'prohibition'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 3.5: Global prohibitions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'prohibition'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
END IF; -- v_is_system_principal
-- 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
WHERE r.org_id = p_org_id WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope)
AND r.modality = 'permission' AND r.modality = 'permission'
AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)) AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id))
AND morbac.is_rule_valid(r.valid_from, r.valid_until) AND morbac.is_rule_valid(r.valid_from, r.valid_until)
ORDER BY COALESCE(r.priority, 0) DESC ORDER BY COALESCE(r.priority, 0) DESC
LOOP LOOP
@@ -82,7 +144,7 @@ BEGIN
END IF; END IF;
END LOOP; END LOOP;
-- STEP 4: 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
@@ -90,7 +152,7 @@ BEGIN
AND cr.modality = 'permission' AND cr.modality = 'permission'
AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id))
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
ORDER BY COALESCE(cr.priority, 0) DESC ORDER BY COALESCE(cr.priority, 0) DESC
LOOP LOOP
@@ -102,7 +164,46 @@ BEGIN
END IF; END IF;
END LOOP; END LOOP;
-- STEP 5: Priority resolution -- STEP 6: User-level permissions - direct user rules, update max if higher
FOR v_rule IN
SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio
FROM morbac.user_rules ur
WHERE ur.user_id = p_user_id
AND ur.org_id IS NOT DISTINCT FROM p_org_id
AND ur.modality = 'permission'
AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view))
AND morbac.is_rule_valid(ur.valid_from, ur.valid_until)
ORDER BY COALESCE(ur.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 6.5: Global permissions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'permission'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 7: Priority resolution
IF v_max_prohibition_priority IS NULL THEN IF v_max_prohibition_priority IS NULL THEN
RETURN v_max_permission_priority IS NOT NULL; RETURN v_max_permission_priority IS NOT NULL;
END IF; END IF;
@@ -129,6 +230,7 @@ CREATE OR REPLACE FUNCTION morbac.is_allowed(
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
VOLATILE VOLATILE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_cached_result BOOLEAN; v_cached_result BOOLEAN;
@@ -149,19 +251,29 @@ BEGIN
v_computed_result := morbac.is_allowed_nocache(p_user_id, p_org_id, p_activity, p_view); v_computed_result := morbac.is_allowed_nocache(p_user_id, p_org_id, p_activity, p_view);
INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at) -- NULL org_id (global-rules-only path) cannot be stored in auth_cache (org_id NOT NULL PK).
VALUES ( -- Global rule changes flush the entire cache anyway, so skipping is safe.
p_user_id, IF p_org_id IS NULL THEN
p_org_id, RETURN v_computed_result;
p_activity, END IF;
p_view,
v_computed_result, BEGIN
CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer) INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at)
) VALUES (
ON CONFLICT (user_id, org_id, activity, view) DO UPDATE p_user_id,
SET allowed = v_computed_result, p_org_id,
computed_at = CURRENT_TIMESTAMP, p_activity,
expires_at = CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer); p_view,
v_computed_result,
CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer)
)
ON CONFLICT (user_id, org_id, activity, view) DO UPDATE
SET allowed = v_computed_result,
computed_at = CURRENT_TIMESTAMP,
expires_at = CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer);
EXCEPTION WHEN read_only_sql_transaction THEN
NULL;
END;
RETURN v_computed_result; RETURN v_computed_result;
END; END;
@@ -169,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().';
+19 -3
View File
@@ -8,14 +8,30 @@ CREATE TABLE morbac.config (
COMMENT ON TABLE morbac.config IS 'Extension configuration - edit values to customize behavior'; COMMENT ON TABLE morbac.config IS 'Extension configuration - edit values to customize behavior';
INSERT INTO morbac.config (key, value, description) VALUES INSERT INTO morbac.config (key, value, description) VALUES
('cache_ttl_seconds', '300', 'Authorization cache time-to-live in seconds (default: 5 minutes)'), ('cache_ttl_seconds', '300', 'Authorization cache time-to-live in seconds (default: 5 minutes)'),
('hierarchy_max_depth', '10', 'Maximum depth for hierarchy traversal to prevent infinite loops'), ('hierarchy_max_depth', '10', 'Maximum depth for hierarchy traversal to prevent infinite loops'),
('enable_audit_by_default', 'false', 'Whether to enable audit logging by default on installation'); ('enable_audit_by_default', 'false', 'Whether to enable audit logging by default on installation'),
-- System view names used in RLS policies on morbac tables.
-- Override with morbac.set_config() to use your own naming conventions.
-- The configured name must exist in morbac.views and your rules must reference it.
('system_view.orgs', 'orgs', 'View name for morbac.orgs table access control'),
('system_view.roles', 'roles', 'View name for morbac.roles table access control'),
('system_view.rules', 'rules', 'View name for morbac.rules table access control'),
('system_view.user_roles', 'user_roles', 'View name for morbac.user_roles table access control'),
('system_view.contexts', 'contexts', 'View name for morbac.contexts table access control'),
('system_view.activities', 'activities', 'View name for morbac.activities table access control'),
('system_view.views', 'views', 'View name for morbac.views table access control'),
('system_view.delegations', 'delegations', 'View name for morbac.delegations table access control'),
('system_view.cross_org_rules', 'cross_org_rules', 'View name for morbac.cross_org_rules table access control'),
('system_view.user_rules', 'user_rules', 'View name for morbac.user_rules table access control'),
('system_view.global_rules', 'global_rules', 'View name for morbac.global_rules table access control'),
('system_view.system_principals', 'system_principals', 'View name for morbac.system_principals table access control');
CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT) CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT)
RETURNS TEXT RETURNS TEXT
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_value TEXT; v_value TEXT;
+1
View File
@@ -38,6 +38,7 @@ CREATE OR REPLACE FUNCTION morbac.eval_context(p_context_id UUID)
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_evaluator REGPROC; v_evaluator REGPROC;
+6 -3
View File
@@ -1,4 +1,7 @@
-- Rules that grant access across organization boundaries -- Rules that grant access across organization boundaries.
--
-- source_org_id: the org where the user must hold role_id.
-- target_org_id: the org where the resource resides.
CREATE TABLE morbac.cross_org_rules ( CREATE TABLE morbac.cross_org_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -24,5 +27,5 @@ CREATE INDEX idx_cross_org_rules_target ON morbac.cross_org_rules(target_org_id)
CREATE INDEX idx_cross_org_rules_temporal ON morbac.cross_org_rules(valid_from, valid_until); CREATE INDEX idx_cross_org_rules_temporal ON morbac.cross_org_rules(valid_from, valid_until);
COMMENT ON TABLE morbac.cross_org_rules IS 'Inter-organizational rules for cross-org access'; COMMENT ON TABLE morbac.cross_org_rules IS 'Inter-organizational rules for cross-org access';
COMMENT ON COLUMN morbac.cross_org_rules.source_org_id IS 'Organization where user has role'; COMMENT ON COLUMN morbac.cross_org_rules.source_org_id IS 'Org where user holds the role';
COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Organization where resource resides'; COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Org where the resource resides';
+57
View File
@@ -0,0 +1,57 @@
-- Global rules: Rule(user_id, activity, view, context, modality)
--
-- No org_id or role_id - applies system-wide regardless of org membership or roles.
-- user_id NULL = every user; non-NULL = specific user only.
-- activity NULL = any activity; view NULL = any view.
--
-- Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in is_allowed_nocache().
-- When activity/view are set, hierarchy resolution applies normally.
CREATE TABLE morbac.global_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
activity TEXT REFERENCES morbac.activities(name) ON DELETE CASCADE,
view TEXT REFERENCES morbac.views(name) ON DELETE CASCADE,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL,
priority INTEGER,
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_active BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}'::jsonb,
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
UNIQUE(user_id, activity, view, context_id, modality)
);
CREATE INDEX idx_global_rules_user_id ON morbac.global_rules(user_id);
CREATE INDEX idx_global_rules_activity_view ON morbac.global_rules(activity, view);
CREATE INDEX idx_global_rules_modality ON morbac.global_rules(modality);
CREATE INDEX idx_global_rules_fast_lookup ON morbac.global_rules(modality)
INCLUDE (user_id, activity, view, context_id)
WHERE is_active = true;
COMMENT ON TABLE morbac.global_rules IS
'System-wide rules with no org or role binding. user_id NULL = all users; activity/view NULL = any activity/view.';
COMMENT ON COLUMN morbac.global_rules.user_id IS
'NULL = all users; non-NULL = this specific user only';
COMMENT ON COLUMN morbac.global_rules.activity IS
'NULL = any activity; non-NULL = specific activity (hierarchy applies)';
COMMENT ON COLUMN morbac.global_rules.view IS
'NULL = any view; non-NULL = specific view (hierarchy applies)';
CREATE OR REPLACE FUNCTION morbac.global_rules_set_is_active()
RETURNS TRIGGER AS $$
BEGIN
NEW.is_active := (
(NEW.valid_from IS NULL OR NEW.valid_from <= CURRENT_TIMESTAMP)
AND (NEW.valid_until IS NULL OR NEW.valid_until > CURRENT_TIMESTAMP)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_global_rules_set_is_active ON morbac.global_rules;
CREATE TRIGGER trg_global_rules_set_is_active
BEFORE INSERT OR UPDATE ON morbac.global_rules
FOR EACH ROW EXECUTE FUNCTION morbac.global_rules_set_is_active();
+74 -10
View File
@@ -1,9 +1,15 @@
-- Transitive closure and effective role/activity/view lookup functions -- Transitive closure and effective role/activity/view lookup functions
--
-- All functions that query morbac tables are SECURITY DEFINER so they run
-- as the extension owner and bypass RLS on morbac tables. This prevents
-- infinite recursion when RLS policies call is_allowed(), which in turn
-- calls these functions.
CREATE OR REPLACE FUNCTION morbac.get_org_ancestors(p_org_id UUID) CREATE OR REPLACE FUNCTION morbac.get_org_ancestors(p_org_id UUID)
RETURNS TABLE(org_id UUID, depth INTEGER) RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -26,6 +32,7 @@ CREATE OR REPLACE FUNCTION morbac.get_org_descendants(p_org_id UUID)
RETURNS TABLE(org_id UUID, depth INTEGER) RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -46,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(
@@ -64,6 +74,7 @@ CREATE OR REPLACE FUNCTION morbac.get_org_scope(
RETURNS TABLE(org_id UUID, depth INTEGER) RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
CASE p_scope CASE p_scope
@@ -118,19 +129,28 @@ 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)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -157,6 +177,7 @@ CREATE OR REPLACE FUNCTION morbac.get_inherited_roles(p_role_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER) RETURNS TABLE(role_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -180,6 +201,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_activities(p_activity TEXT)
RETURNS TABLE(activity TEXT, depth INTEGER) RETURNS TABLE(activity TEXT, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -203,6 +225,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_views(p_view TEXT)
RETURNS TABLE(view TEXT, depth INTEGER) RETURNS TABLE(view TEXT, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -226,6 +249,7 @@ CREATE OR REPLACE FUNCTION morbac.get_comprehensive_roles(p_user_id UUID, p_org_
RETURNS TABLE(role_id UUID, source TEXT, depth INTEGER) RETURNS TABLE(role_id UUID, source TEXT, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -303,6 +327,7 @@ CREATE OR REPLACE FUNCTION morbac.eval_derived_role(
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_result BOOLEAN; v_result BOOLEAN;
@@ -317,3 +342,42 @@ $$;
COMMENT ON FUNCTION morbac.eval_derived_role(REGPROC, UUID, UUID) IS COMMENT ON FUNCTION morbac.eval_derived_role(REGPROC, UUID, UUID) IS
'Evaluates a derived role condition function'; 'Evaluates a derived role condition function';
-- Check if p_target_org_id falls within the scope of p_rule_org_id.
-- Used by the authorization engine to evaluate scoped rules at query time.
CREATE OR REPLACE FUNCTION morbac.org_in_scope(
p_target_org_id UUID,
p_rule_org_id UUID,
p_scope TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
AS $$
BEGIN
-- Org target vocabulary: all (every org, unattributed included),
-- unattributed (no-org objects only), or a specific org via the tree scopes.
-- Tree scopes never match an unattributed object.
IF p_scope = 'all' THEN
RETURN TRUE;
END IF;
IF p_scope = 'unattributed' THEN
RETURN p_target_org_id IS NULL;
END IF;
IF p_target_org_id IS NULL THEN
RETURN FALSE;
END IF;
IF p_scope = 'self' THEN
RETURN p_target_org_id = p_rule_org_id;
END IF;
RETURN EXISTS(
SELECT 1 FROM morbac.get_org_scope(p_rule_org_id, p_scope)
WHERE org_id = p_target_org_id
);
END;
$$;
COMMENT ON FUNCTION morbac.org_in_scope(UUID, UUID, TEXT) IS
'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). Scope unattributed matches only a NULL target; other scopes never match NULL. SECURITY DEFINER to bypass RLS on morbac.orgs.';
-142
View File
@@ -1,142 +0,0 @@
-- Simplified policy declaration using names instead of UUIDs
CREATE TABLE morbac.policy (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_name TEXT NOT NULL,
role_name TEXT NOT NULL,
activity TEXT NOT NULL,
view TEXT NOT NULL,
modality morbac.modality NOT NULL,
context_name TEXT NOT NULL DEFAULT 'always',
priority INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
compiled BOOLEAN NOT NULL DEFAULT FALSE
);
-- NULL priority treated as -1 so two NULL-priority rows for the same tuple conflict
CREATE UNIQUE INDEX idx_policy_unique
ON morbac.policy(org_name, role_name, activity, view, modality, context_name, COALESCE(priority, -1));
CREATE INDEX idx_policy_not_compiled ON morbac.policy(compiled) WHERE NOT compiled;
COMMENT ON TABLE morbac.policy IS 'Policy DSL - simplified policy declaration using names';
COMMENT ON COLUMN morbac.policy.compiled IS 'Whether this policy entry has been compiled into rules';
COMMENT ON COLUMN morbac.policy.priority IS 'Optional rule priority (higher wins over lower; NULL = 0)';
CREATE OR REPLACE FUNCTION morbac.is_admin_allowed(
p_user_id UUID,
p_org_id UUID,
p_admin_activity TEXT,
p_admin_target TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_rule RECORD;
BEGIN
FOR v_rule IN
SELECT ar.context_id
FROM morbac.admin_rules ar
WHERE ar.org_id = p_org_id
AND ar.admin_activity = p_admin_activity
AND ar.admin_target = p_admin_target
AND ar.modality = 'prohibition'
AND ar.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
)
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
RETURN FALSE;
END IF;
END LOOP;
FOR v_rule IN
SELECT ar.context_id
FROM morbac.admin_rules ar
WHERE ar.org_id = p_org_id
AND ar.admin_activity = p_admin_activity
AND ar.admin_target = p_admin_target
AND ar.modality = 'permission'
AND ar.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
)
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
RETURN TRUE;
END IF;
END LOOP;
RETURN FALSE;
END;
$$;
COMMENT ON FUNCTION morbac.is_admin_allowed(UUID, UUID, TEXT, TEXT) IS
'Checks administration permissions for policy management operations';
-- Translates policy DSL entries into concrete rules by resolving names to IDs.
-- Idempotent - safe to run multiple times.
CREATE OR REPLACE FUNCTION morbac.compile_policy()
RETURNS TABLE(
compiled_count INTEGER,
error_count INTEGER,
errors TEXT[]
)
LANGUAGE plpgsql
AS $$
DECLARE
v_policy RECORD;
v_org_id UUID;
v_role_id UUID;
v_context_id UUID;
v_compiled INTEGER := 0;
v_errors TEXT[] := ARRAY[]::TEXT[];
v_error_count INTEGER := 0;
BEGIN
FOR v_policy IN
SELECT * FROM morbac.policy WHERE NOT compiled
LOOP
BEGIN
SELECT id INTO STRICT v_org_id
FROM morbac.orgs
WHERE name = v_policy.org_name;
SELECT id INTO STRICT v_role_id
FROM morbac.roles
WHERE org_id = v_org_id AND name = v_policy.role_name;
SELECT id INTO STRICT v_context_id
FROM morbac.contexts
WHERE name = v_policy.context_name;
INSERT INTO morbac.activities (name)
VALUES (v_policy.activity)
ON CONFLICT (name) DO NOTHING;
INSERT INTO morbac.views (name)
VALUES (v_policy.view)
ON CONFLICT (name) DO NOTHING;
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, priority)
VALUES (v_org_id, v_role_id, v_policy.activity, v_policy.view, v_context_id, v_policy.modality, v_policy.priority)
ON CONFLICT (org_id, role_id, activity, view, context_id, modality) DO NOTHING;
UPDATE morbac.policy SET compiled = TRUE WHERE id = v_policy.id;
v_compiled := v_compiled + 1;
EXCEPTION WHEN OTHERS THEN
v_error_count := v_error_count + 1;
v_errors := array_append(v_errors,
format('Policy %s: %s', v_policy.id, SQLERRM));
END;
END LOOP;
RETURN QUERY SELECT v_compiled, v_error_count, v_errors;
END;
$$;
COMMENT ON FUNCTION morbac.compile_policy() IS
'Compiles policy DSL entries into concrete rules - idempotent and safe to run multiple times';
+137 -25
View File
@@ -48,35 +48,86 @@ $$;
COMMENT ON FUNCTION morbac.current_org_id() IS COMMENT ON FUNCTION morbac.current_org_id() IS
'Returns current organization ID from morbac.org_id session variable'; 'Returns current organization ID from morbac.org_id session variable';
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' CREATE OR REPLACE FUNCTION morbac.current_target_user_id()
CREATE OR REPLACE FUNCTION morbac.current_org_ids() RETURNS UUID
RETURNS UUID[]
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
AS $$ AS $$
DECLARE DECLARE
v_raw TEXT; v_user_id TEXT;
BEGIN BEGIN
v_raw := current_setting('morbac.org_ids', TRUE); v_user_id := current_setting('morbac.target_user_id', TRUE);
IF v_raw IS NULL OR v_raw = '' THEN IF v_user_id IS NULL OR v_user_id = '' THEN
RETURN NULL; RETURN NULL;
END IF; END IF;
RETURN ARRAY(SELECT jsonb_array_elements_text(v_raw::jsonb)::UUID); RETURN v_user_id::UUID;
EXCEPTION EXCEPTION
WHEN OTHERS THEN WHEN OTHERS THEN
RETURN NULL; RETURN NULL;
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.current_target_user_id() IS
'Returns target user ID filter from morbac.target_user_id session variable';
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' or '["uuid1", null]' or '[null]'.
-- A JSON null element names the no-org (unattributed) bucket, distinct from
-- the real org UUIDs which populate org_ids.
CREATE OR REPLACE FUNCTION morbac.current_org_filter(
OUT org_ids UUID[],
OUT include_unattributed BOOLEAN
)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_raw TEXT := current_setting('morbac.org_ids', TRUE);
v_json JSONB;
BEGIN
org_ids := NULL;
include_unattributed := FALSE;
IF v_raw IS NULL OR v_raw = '' THEN
RETURN;
END IF;
v_json := v_raw::jsonb;
org_ids := ARRAY(
SELECT x::UUID
FROM jsonb_array_elements_text(v_json) x
WHERE x IS NOT NULL
);
include_unattributed := EXISTS (
SELECT 1 FROM jsonb_array_elements(v_json) e WHERE e = 'null'::jsonb
);
EXCEPTION
WHEN OTHERS THEN
org_ids := NULL;
include_unattributed := FALSE;
END;
$$;
COMMENT ON FUNCTION morbac.current_org_filter() IS
'Parses morbac.org_ids (JSON array) once into real org UUIDs plus a flag for whether the no-org bucket (JSON null element) was requested.';
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
RETURNS UUID[]
LANGUAGE sql
STABLE
AS $$
SELECT org_ids FROM morbac.current_org_filter();
$$;
COMMENT ON FUNCTION morbac.current_org_ids() IS 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)
LANGUAGE sql LANGUAGE sql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
SELECT DISTINCT ur.org_id SELECT DISTINCT ur.org_id
FROM morbac.user_roles ur FROM morbac.user_roles ur
@@ -92,12 +143,21 @@ $$;
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';
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs. -- rls_check has two forms, distinguished by arity so a NULL never carries two
-- Pass the row org_id column to enable scoping: rls_check('read', 'docs', org_id) -- 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( 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
) )
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -106,7 +166,42 @@ AS $$
DECLARE DECLARE
v_user_id UUID; v_user_id UUID;
v_org_id UUID; v_org_id UUID;
v_org_ids UUID[]; BEGIN
v_user_id := morbac.current_user_id();
IF v_user_id IS NULL THEN
RETURN FALSE;
END IF;
v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END IF;
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
END;
$$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT) IS
'RLS helper for tables without an org column. Uses the session org context (morbac.org_id), else org-independent rules. For row-scoped tables use the 3/4-arg form.';
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
-- User scoping: morbac.target_user_id filters rows to a specific user.
CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT,
p_view TEXT,
p_row_org_id UUID,
p_row_user_id UUID DEFAULT NULL
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_user_id UUID;
v_org_id UUID;
v_org_ids 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();
@@ -114,31 +209,48 @@ BEGIN
RETURN FALSE; RETURN FALSE;
END IF; END IF;
-- User filter: if morbac.target_user_id is set, only rows matching that user pass
IF p_row_user_id IS NOT NULL THEN
v_target_user_id := morbac.current_target_user_id();
IF v_target_user_id IS NOT NULL AND p_row_user_id <> v_target_user_id THEN
RETURN FALSE;
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 NULL OR 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;
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;
IF p_row_org_id IS NOT NULL THEN -- No selection set: all authorized rows. The row's own org drives the decision
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); -- (unattributed row -> unattributed + org-independent rules).
END IF; RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
RETURN FALSE;
END; END;
$$; $$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, 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 to enable org scoping (single org, org list, or all orgs).'; '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.';
+63
View File
@@ -0,0 +1,63 @@
-- assign_role / revoke_role: convenience wrappers that enforce SoD and
-- cardinality constraints. Access control is handled by RLS on morbac.user_roles.
CREATE OR REPLACE FUNCTION morbac.assign_role(
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
IF morbac.check_sod_violation(p_target_user_id, p_role_id, p_org_id) THEN
RAISE EXCEPTION 'Role assignment would violate Separation of Duty constraints';
END IF;
-- INSERT triggers RLS policy on morbac.user_roles (create on user_roles view)
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (p_target_user_id, p_role_id, p_org_id)
ON CONFLICT (user_id, role_id, org_id) DO NOTHING;
DECLARE
v_error TEXT;
BEGIN
v_error := morbac.check_cardinality_violation(p_role_id);
IF v_error IS NOT NULL THEN
RAISE EXCEPTION 'Role assignment violates cardinality constraint: %', v_error;
END IF;
END;
END;
$$;
COMMENT ON FUNCTION morbac.assign_role(UUID, UUID, UUID) IS
'Assign role with SoD and cardinality validation. RLS on morbac.user_roles enforces authorization.';
CREATE OR REPLACE FUNCTION morbac.revoke_role(
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
-- DELETE triggers RLS policy on morbac.user_roles (delete on user_roles view)
DELETE FROM morbac.user_roles
WHERE user_id = p_target_user_id
AND role_id = p_role_id
AND org_id = p_org_id;
DECLARE
v_error TEXT;
BEGIN
v_error := morbac.check_cardinality_violation(p_role_id, FALSE);
IF v_error IS NOT NULL THEN
RAISE WARNING 'Role revocation may violate cardinality constraint: %', v_error;
END IF;
END;
END;
$$;
COMMENT ON FUNCTION morbac.revoke_role(UUID, UUID, UUID) IS
'Revoke role with cardinality validation. RLS on morbac.user_roles enforces authorization.';
+20 -2
View File
@@ -1,4 +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:
-- 'self' - exact org only (default, current behavior)
-- 'subtree' - org + all descendants
-- 'descendants' - all descendants, excluding self
-- 'children' - direct children only
-- 'parent' - direct parent only
-- 'ancestors' - all ancestors, excluding self
-- 'lineage' - self + all ancestors
-- 'root' - topmost ancestor only
-- 'unattributed' - no-org objects only (org_id column above is the declaring authority)
-- 'all' - every org, unattributed included
-- Evaluated at query time via org_in_scope() - new orgs are picked up automatically.
--
-- Org target vocabulary: a specific org (the tree scopes), 'unattributed', or 'all'.
CREATE TABLE morbac.rules ( CREATE TABLE morbac.rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -8,14 +23,16 @@ CREATE TABLE morbac.rules (
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,
modality morbac.modality NOT NULL, modality morbac.modality NOT NULL,
scope TEXT NOT NULL DEFAULT 'self',
priority INTEGER, priority INTEGER,
valid_from TIMESTAMPTZ, valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ, valid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_active BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}'::jsonb, metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, activity, view, context_id, modality), 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', '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);
@@ -27,6 +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 '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.';
+106
View File
@@ -0,0 +1,106 @@
-- System principals: backend service accounts defined at deploy time.
--
-- Registered user_ids are fully protected at the trigger level:
-- - No role assignments, revocations, or negative assignments
-- - No user_rules (permissions or prohibitions)
-- - No delegations involving them
-- - No targeted global_rules prohibitions
-- - All prohibitions are skipped in is_allowed_nocache() (see authorization.sql)
--
-- This table has no INSERT/UPDATE/DELETE RLS policies - only the database owner
-- can register or remove system principals (done in SQL at deploy time).
-- SELECT is gated by is_allowed() like all other system tables.
CREATE TABLE morbac.system_principals (
user_id UUID PRIMARY KEY,
description TEXT
);
COMMENT ON TABLE morbac.system_principals IS
'Registry of backend service accounts. Immutable at the trigger level - no policy can touch them.';
COMMENT ON COLUMN morbac.system_principals.user_id IS
'External user UUID of the service account';
-- Shared guard for tables with a single user_id column
CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
IF EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = v_user_id) THEN
RAISE EXCEPTION 'operation blocked: % is a system principal', v_user_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_system_principal_user_roles
BEFORE INSERT OR UPDATE OR DELETE ON morbac.user_roles
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal();
CREATE TRIGGER trg_system_principal_user_rules
BEFORE INSERT OR UPDATE OR DELETE ON morbac.user_rules
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal();
CREATE TRIGGER trg_system_principal_negative_roles
BEFORE INSERT OR UPDATE OR DELETE ON morbac.negative_role_assignments
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal();
-- Delegations have two user_id columns
CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal_delegation()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM morbac.system_principals
WHERE user_id IN (NEW.delegator_id, NEW.delegatee_id)
) THEN
RAISE EXCEPTION 'operation blocked: delegations cannot involve system principals';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_system_principal_delegations
BEFORE INSERT OR UPDATE ON morbac.delegations
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal_delegation();
-- Global rules: all operations on rows belonging to a system principal are blocked.
-- This protects both the permission rules defined for them and prevents prohibitions
-- from being added against them. Define their rules at deploy time as the DB owner.
CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal_global_rule()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
IF v_user_id IS NOT NULL
AND EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = v_user_id)
THEN
-- allow initial deploy-time insert when no rules exist yet for this principal
IF TG_OP = 'INSERT'
AND NOT EXISTS (SELECT 1 FROM morbac.global_rules WHERE user_id = v_user_id)
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'operation blocked: global rules for system principal % are immutable', v_user_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_system_principal_global_rules
BEFORE INSERT OR UPDATE OR DELETE ON morbac.global_rules
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal_global_rule();
+226
View File
@@ -0,0 +1,226 @@
-- RLS policies for morbac system tables.
--
-- is_allowed() and its internal callees are SECURITY DEFINER, so they run as
-- the extension owner and bypass RLS. This prevents infinite recursion when
-- these policies fire.
--
-- View names used in rules are config-driven (system_view.*).
-- Override with morbac.set_config('system_view.orgs', 'my_orgs') etc.
-- The new name must exist in morbac.views and your rules must reference it.
--
-- RLS is disabled for the superuser/extension owner by default (PostgreSQL
-- behavior). Bootstrap the first organization and superuser role as the
-- database owner before enabling this in production.
-- morbac.orgs
-- Row's org context is the org itself.
ALTER TABLE morbac.orgs ENABLE ROW LEVEL SECURITY;
CREATE POLICY orgs_select ON morbac.orgs FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), id, 'read',
morbac.get_config('system_view.orgs')));
CREATE POLICY orgs_insert ON morbac.orgs FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create',
morbac.get_config('system_view.orgs')));
CREATE POLICY orgs_update ON morbac.orgs FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), id, 'update',
morbac.get_config('system_view.orgs')));
CREATE POLICY orgs_delete ON morbac.orgs FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), id, 'delete',
morbac.get_config('system_view.orgs')));
-- morbac.roles
ALTER TABLE morbac.roles ENABLE ROW LEVEL SECURITY;
CREATE POLICY roles_select ON morbac.roles FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read',
morbac.get_config('system_view.roles')));
CREATE POLICY roles_insert ON morbac.roles FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create',
morbac.get_config('system_view.roles')));
CREATE POLICY roles_update ON morbac.roles FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update',
morbac.get_config('system_view.roles')));
CREATE POLICY roles_delete ON morbac.roles FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.roles')));
-- morbac.rules
ALTER TABLE morbac.rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY rules_select ON morbac.rules FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read',
morbac.get_config('system_view.rules')));
CREATE POLICY rules_insert ON morbac.rules FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create',
morbac.get_config('system_view.rules')));
CREATE POLICY rules_update ON morbac.rules FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update',
morbac.get_config('system_view.rules')));
CREATE POLICY rules_delete ON morbac.rules FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.rules')));
-- morbac.user_roles
ALTER TABLE morbac.user_roles ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_roles_select ON morbac.user_roles FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read',
morbac.get_config('system_view.user_roles')));
CREATE POLICY user_roles_insert ON morbac.user_roles FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create',
morbac.get_config('system_view.user_roles')));
CREATE POLICY user_roles_delete ON morbac.user_roles FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.user_roles')));
-- morbac.contexts (global - use current session org for writes)
ALTER TABLE morbac.contexts ENABLE ROW LEVEL SECURITY;
CREATE POLICY contexts_select ON morbac.contexts FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.contexts')));
CREATE POLICY contexts_insert ON morbac.contexts FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create',
morbac.get_config('system_view.contexts')));
CREATE POLICY contexts_update ON morbac.contexts FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update',
morbac.get_config('system_view.contexts')));
CREATE POLICY contexts_delete ON morbac.contexts FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
morbac.get_config('system_view.contexts')));
-- morbac.activities (global - use current session org for writes)
ALTER TABLE morbac.activities ENABLE ROW LEVEL SECURITY;
CREATE POLICY activities_select ON morbac.activities FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.activities')));
CREATE POLICY activities_insert ON morbac.activities FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create',
morbac.get_config('system_view.activities')));
CREATE POLICY activities_update ON morbac.activities FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update',
morbac.get_config('system_view.activities')));
CREATE POLICY activities_delete ON morbac.activities FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
morbac.get_config('system_view.activities')));
-- morbac.views (global - use current session org for writes)
ALTER TABLE morbac.views ENABLE ROW LEVEL SECURITY;
CREATE POLICY views_select ON morbac.views FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.views')));
CREATE POLICY views_insert ON morbac.views FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create',
morbac.get_config('system_view.views')));
CREATE POLICY views_update ON morbac.views FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update',
morbac.get_config('system_view.views')));
CREATE POLICY views_delete ON morbac.views FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
morbac.get_config('system_view.views')));
-- morbac.delegations
ALTER TABLE morbac.delegations ENABLE ROW LEVEL SECURITY;
CREATE POLICY delegations_select ON morbac.delegations FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read',
morbac.get_config('system_view.delegations')));
CREATE POLICY delegations_insert ON morbac.delegations FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create',
morbac.get_config('system_view.delegations')));
CREATE POLICY delegations_update ON morbac.delegations FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update',
morbac.get_config('system_view.delegations')));
CREATE POLICY delegations_delete ON morbac.delegations FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.delegations')));
-- morbac.user_rules
ALTER TABLE morbac.user_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_rules_select ON morbac.user_rules FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read',
morbac.get_config('system_view.user_rules')));
CREATE POLICY user_rules_insert ON morbac.user_rules FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create',
morbac.get_config('system_view.user_rules')));
CREATE POLICY user_rules_update ON morbac.user_rules FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update',
morbac.get_config('system_view.user_rules')));
CREATE POLICY user_rules_delete ON morbac.user_rules FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.user_rules')));
-- morbac.system_principals (no org_id - SELECT only; INSERT/UPDATE/DELETE reserved for DB owner)
ALTER TABLE morbac.system_principals ENABLE ROW LEVEL SECURITY;
CREATE POLICY system_principals_select ON morbac.system_principals FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.system_principals')));
-- morbac.global_rules (no org_id - use current session org for write checks)
ALTER TABLE morbac.global_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY global_rules_select ON morbac.global_rules FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.global_rules')));
CREATE POLICY global_rules_insert ON morbac.global_rules FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create',
morbac.get_config('system_view.global_rules')));
CREATE POLICY global_rules_update ON morbac.global_rules FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update',
morbac.get_config('system_view.global_rules')));
CREATE POLICY global_rules_delete ON morbac.global_rules FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
morbac.get_config('system_view.global_rules')));
-- morbac.cross_org_rules
ALTER TABLE morbac.cross_org_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY cross_org_rules_select ON morbac.cross_org_rules FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'read',
morbac.get_config('system_view.cross_org_rules')));
CREATE POLICY cross_org_rules_insert ON morbac.cross_org_rules FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'create',
morbac.get_config('system_view.cross_org_rules')));
CREATE POLICY cross_org_rules_update ON morbac.cross_org_rules FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'update',
morbac.get_config('system_view.cross_org_rules')));
CREATE POLICY cross_org_rules_delete ON morbac.cross_org_rules FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'delete',
morbac.get_config('system_view.cross_org_rules')));
+41
View File
@@ -0,0 +1,41 @@
-- Direct user-level rules: Rule(user, org, activity, view, context, modality)
--
-- Grants or prohibits access for a specific user in an org, bypassing the role system.
-- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache().
-- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition.
--
-- Org target: a specific org, or NULL for unattributed (no-org) objects.
-- For the same user across every org, use morbac.global_rules.
CREATE TABLE morbac.user_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
org_id UUID REFERENCES morbac.orgs(id) ON DELETE CASCADE,
activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL,
priority INTEGER,
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB DEFAULT '{}'::jsonb,
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
UNIQUE(user_id, org_id, activity, view, context_id, modality)
);
-- NULLs are distinct in UNIQUE, so the unattributed target needs its own index
CREATE UNIQUE INDEX idx_user_rules_unattributed_unique
ON morbac.user_rules(user_id, activity, view, context_id, modality)
WHERE org_id IS NULL;
CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id);
CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view);
CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality);
CREATE INDEX idx_user_rules_lookup ON morbac.user_rules(user_id, org_id, activity, modality, view);
COMMENT ON TABLE morbac.user_rules IS 'Direct user-level rules - grant or prohibit access for a specific user, bypassing the role system';
COMMENT ON 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; NULL targets unattributed (no-org) objects';
COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.';
+7 -4
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 %)',
+14
View File
@@ -23,3 +23,17 @@ CREATE INDEX idx_view_hierarchy_junior ON morbac.view_hierarchy(junior_view);
COMMENT ON TABLE morbac.view_hierarchy IS 'View hierarchy - senior views inherit from junior views'; COMMENT ON TABLE morbac.view_hierarchy IS 'View hierarchy - senior views inherit from junior views';
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.
-- Override config values to rename; the new name must be seeded here too.
INSERT INTO morbac.views (name, description) VALUES
('orgs', 'Organizations table'),
('roles', 'Roles table'),
('rules', 'Authorization rules table'),
('user_roles', 'User-role assignments table'),
('user_rules', 'User-level authorization rules table'),
('global_rules', 'System-wide authorization rules table'),
('system_principals', 'System principals table'),
('delegations', 'Role delegations table'),
('cross_org_rules', 'Cross-organization rules table')
ON CONFLICT (name) DO NOTHING;
+69 -95
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
@@ -173,10 +173,11 @@ INSERT INTO morbac.activities (name, description) VALUES
('read', 'Read / view data'), ('read', 'Read / view data'),
('write', 'Create or modify data'), ('write', 'Create or modify data'),
('delete', 'Delete data'), ('delete', 'Delete data'),
('manage', 'Full management access'),
('approve', 'Approve requests or documents'), ('approve', 'Approve requests or documents'),
('export', 'Export data to external format'), ('export', 'Export data to external format'),
('audit', 'Audit trail review'), ('audit', 'Audit trail review')
('manage', 'Full administrative control'); ON CONFLICT (name) DO NOTHING;
INSERT INTO morbac.views (name, description) VALUES INSERT INTO morbac.views (name, description) VALUES
('documents', 'General company documents'), ('documents', 'General company documents'),
@@ -185,7 +186,8 @@ INSERT INTO morbac.views (name, description) VALUES
('hr_data', 'Human resources data'), ('hr_data', 'Human resources data'),
('contracts', 'Legal contracts'), ('contracts', 'Legal contracts'),
('audit_logs', 'System audit logs'), ('audit_logs', 'System audit logs'),
('public_data', 'Publicly accessible data'); ('public_data', 'Publicly accessible data')
ON CONFLICT (name) DO NOTHING;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- CONTEXTS -- CONTEXTS
@@ -212,84 +214,56 @@ 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);
SELECT name, description FROM morbac.contexts ORDER BY name; SELECT name, description FROM morbac.contexts ORDER BY name;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- POLICIES (via Policy DSL — resolved by compile_policy) -- RULES
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: Policies ===' \echo '=== Setup: Rules ==='
-- GlobalTech HQ policies INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES SELECT o.id, r.id, v.activity, v.view, c.id, v.modality::morbac.modality
-- Intern: read public data only FROM (VALUES
('GlobalTech HQ', 'intern', 'read', 'public_data', 'permission', 'always'), ('GlobalTech HQ', 'intern', 'read', 'public_data', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'read', 'documents', 'always', 'permission'),
-- Employee: read documents and reports; write documents only during business hours ('GlobalTech HQ', 'employee', 'write', 'documents', 'business_hours','permission'),
('GlobalTech HQ', 'employee', 'read', 'documents', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'reports', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'write', 'documents', 'permission', 'business_hours'), ('GlobalTech HQ', 'employee', 'read', 'public_data', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'read', 'reports', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'reports', 'always', 'obligation'),
('GlobalTech HQ', 'employee', 'read', 'public_data', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'reports', 'end_of_quarter','recommendation'),
('GlobalTech HQ', 'employee', 'read', 'public_data', 'always', 'recommendation'),
-- Obligation: employees must review reports weekly ('GlobalTech HQ', 'manager', 'approve','documents', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'read', 'reports', 'obligation', 'always'), ('GlobalTech HQ', 'manager', 'delete', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'read', 'financial_data', 'always', 'permission'),
-- Manager: approve and delete documents; read financial data ('GlobalTech HQ', 'hr_manager', 'read', 'hr_data', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'approve','documents', 'permission', 'always'), ('GlobalTech HQ', 'hr_manager', 'write', 'hr_data', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'delete', 'documents', 'permission', 'always'), ('GlobalTech HQ', 'hr_manager', 'delete', 'hr_data', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'read', 'financial_data', 'permission', 'always'), ('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'always', 'permission'),
('GlobalTech HQ', 'auditor', 'read', 'financial_data', 'always', 'permission'),
-- HR Manager: full access to HR data ('GlobalTech HQ', 'auditor', 'read', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'read', 'hr_data', 'permission', 'always'), ('GlobalTech HQ', 'accountant', 'read', 'financial_data', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'write', 'hr_data', 'permission', 'always'), ('GlobalTech HQ', 'accountant', 'write', 'financial_data', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'delete', 'hr_data', 'permission', 'always'), ('GlobalTech HQ', 'contractor', 'read', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'contractor', 'read', 'financial_data', 'always', 'prohibition'),
-- Auditor: read audit logs, financial data, and documents ('GlobalTech HQ', 'contractor', 'read', 'hr_data', 'always', 'prohibition'),
('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'permission', 'always'), ('GlobalTech HQ', 'compliance_officer', 'read', 'audit_logs', 'always', 'permission'),
('GlobalTech HQ', 'auditor', 'read', 'financial_data', 'permission', 'always'), ('GlobalTech HQ', 'compliance_officer', 'read', 'financial_data', 'always', 'permission'),
('GlobalTech HQ', 'auditor', 'read', 'documents', 'permission', 'always'), ('Engineering Dept', 'engineer', 'read', 'documents', 'always', 'permission'),
('Engineering Dept', 'engineer', 'write', 'documents', 'always', 'permission'),
-- Accountant: read and write financial data ('Engineering Dept', 'tech_lead', 'approve','documents', 'always', 'permission'),
('GlobalTech HQ', 'accountant', 'read', 'financial_data', 'permission', 'always'), ('Sales Dept', 'sales_rep', 'read', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'accountant', 'write', 'financial_data', 'permission', 'always'), ('Sales Dept', 'sales_rep', 'write', 'contracts', 'always', 'permission'),
('Sales Dept', 'sales_manager', 'read', 'reports', 'always', 'permission'),
-- Contractor: read documents; PROHIBITED from financial and HR data ('Sales Dept', 'sales_manager', 'approve','contracts', 'always', 'permission')
('GlobalTech HQ', 'contractor', 'read', 'documents', 'permission', 'always'), ) AS v(org_name, role_name, activity, view, context_name, modality)
('GlobalTech HQ', 'contractor', 'read', 'financial_data', 'prohibition', 'always'), JOIN morbac.orgs o ON o.name = v.org_name
('GlobalTech HQ', 'contractor', 'read', 'hr_data', 'prohibition', 'always'), JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
JOIN morbac.contexts c ON c.name = v.context_name;
-- Compliance Officer: read audit logs and financial data
('GlobalTech HQ', 'compliance_officer', 'read', 'audit_logs', 'permission', 'always'),
('GlobalTech HQ', 'compliance_officer', 'read', 'financial_data', 'permission', 'always'),
-- Recommendation: staff should review reports end of quarter
-- Note: voided by the obligation above (conflict resolution: obligation > recommendation)
('GlobalTech HQ', 'employee', 'read', 'reports', 'recommendation','end_of_quarter'),
-- Recommendation: staff should stay informed on public data (no conflicting obligation)
('GlobalTech HQ', 'employee', 'read', 'public_data', 'recommendation','always');
-- Engineering Dept policies
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES
('Engineering Dept', 'engineer', 'read', 'documents', 'permission', 'always'),
('Engineering Dept', 'engineer', 'write', 'documents', 'permission', 'always'),
('Engineering Dept', 'tech_lead', 'approve', 'documents', 'permission', 'always');
-- Sales Dept policies
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES
('Sales Dept', 'sales_rep', 'read', 'documents', 'permission', 'always'),
('Sales Dept', 'sales_rep', 'write', 'contracts', 'permission', 'always'),
('Sales Dept', 'sales_manager', 'read', 'reports', 'permission', 'always'),
('Sales Dept', 'sales_manager', 'approve', 'contracts', 'permission', 'always');
-- Compile all policies into rules
\echo ''
\echo '=== Setup: Compiling Policies ==='
SELECT * FROM morbac.compile_policy();
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Test infrastructure -- Test infrastructure
@@ -300,7 +274,7 @@ SELECT * FROM morbac.compile_policy();
-- 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
@@ -310,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
@@ -319,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
@@ -328,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
@@ -343,6 +317,6 @@ $$;
\echo ' Organizations : GlobalTech HQ, Engineering Dept, Sales Dept' \echo ' Organizations : GlobalTech HQ, Engineering Dept, Sales Dept'
\echo ' Roles : 14 across 3 orgs' \echo ' Roles : 14 across 3 orgs'
\echo ' Users : Alice, Bob, Carol, Dave, Eve, Frank, Grace, Heidi, Ivan, Judy, Karl, Leo' \echo ' Users : Alice, Bob, Carol, Dave, Eve, Frank, Grace, Heidi, Ivan, Judy, Karl, Leo'
\echo ' Activities : read, write, delete, approve, export, audit, manage' \echo ' Activities : read, write, delete, manage, approve, export, audit (+ built-ins: create, update)'
\echo ' Views : documents, reports, financial_data, hr_data, contracts, audit_logs, public_data' \echo ' Views : documents, reports, financial_data, hr_data, contracts, audit_logs, public_data'
\echo ' Contexts : always (true), business_hours (true), after_hours (false), end_of_quarter (true)' \echo ' Contexts : always (true), business_hours (true), after_hours (false), end_of_quarter (true)'
+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,
+154 -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,
@@ -156,6 +156,122 @@ SELECT morbac.t('Tech lead approves Engineering documents (own perm)',
'approve', 'documents' 'approve', 'documents'
), TRUE); ), TRUE);
-- ---------------------------------------------------------------------------
-- Section 2b: Role hierarchy cache refresh and closure maintenance
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2b. Role hierarchy cache refresh and closure maintenance ---'
-- Existing transitive closure is materialized: CEO reaches intern at depth 4.
SELECT morbac.t('mv_role_closure includes ceo -> intern at depth 4',
EXISTS(
SELECT 1
FROM morbac.mv_role_closure
WHERE senior_role_id = '20000000-0001-0000-0000-000000000001'::uuid
AND junior_role_id = '20000000-0001-0000-0000-000000000005'::uuid
AND depth = 4
), TRUE);
-- Add a temporary specialist role with a unique permission, then attach it
-- under employee so the hierarchy trigger must refresh mv_role_closure.
INSERT INTO morbac.roles (id, org_id, name, description) VALUES
('20000000-0001-0000-0000-000000000099', '10000000-0000-0000-0000-000000000001', 'temporary_specialist', 'Temporary role for role_hierarchy tests');
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
VALUES (
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000099',
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Dave (employee) initially cannot read contracts',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES
('20000000-0001-0000-0000-000000000004', '20000000-0001-0000-0000-000000000099');
SELECT morbac.t('mv_role_closure refreshes after role_hierarchy insert (employee -> temporary_specialist)',
EXISTS(
SELECT 1
FROM morbac.mv_role_closure
WHERE senior_role_id = '20000000-0001-0000-0000-000000000004'::uuid
AND junior_role_id = '20000000-0001-0000-0000-000000000099'::uuid
AND depth = 1
), TRUE);
SELECT morbac.t('Dave (employee) gains temporary_specialist permission via refreshed hierarchy',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
-- Re-enable cache briefly to verify hierarchy changes invalidate stale auth decisions.
SELECT morbac.set_config('cache_ttl_seconds', '3600');
DELETE FROM morbac.auth_cache;
SELECT morbac.t('Dave cached authorization sees inherited contracts access',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
SELECT morbac.t('Dave auth result is cached before hierarchy removal',
EXISTS(
SELECT 1
FROM morbac.auth_cache
WHERE user_id = '30000000-0000-0000-0000-000000000004'::uuid
AND org_id = '10000000-0000-0000-0000-000000000001'::uuid
AND activity = 'read'
AND view = 'contracts'
), TRUE);
DELETE FROM morbac.role_hierarchy
WHERE senior_role_id = '20000000-0001-0000-0000-000000000004'
AND junior_role_id = '20000000-0001-0000-0000-000000000099';
SELECT morbac.t('Hierarchy change invalidates cached auth decision for Dave/contracts',
NOT EXISTS(
SELECT 1
FROM morbac.auth_cache
WHERE user_id = '30000000-0000-0000-0000-000000000004'::uuid
AND org_id = '10000000-0000-0000-0000-000000000001'::uuid
AND activity = 'read'
AND view = 'contracts'
), TRUE);
SELECT morbac.t('Dave cached authorization recomputes to denied after hierarchy removal',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
DELETE FROM morbac.rules
WHERE org_id = '10000000-0000-0000-0000-000000000001'
AND role_id = '20000000-0001-0000-0000-000000000099'
AND activity = 'read'
AND view = 'contracts'
AND modality = 'permission';
DELETE FROM morbac.roles
WHERE id = '20000000-0001-0000-0000-000000000099';
DELETE FROM morbac.auth_cache
WHERE user_id = '30000000-0000-0000-0000-000000000004'
AND org_id = '10000000-0000-0000-0000-000000000001'
AND activity = 'read'
AND view = 'contracts';
SELECT morbac.set_config('cache_ttl_seconds', '0');
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 3: Activity hierarchy -- Section 3: Activity hierarchy
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -190,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,
@@ -198,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,
@@ -206,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,
@@ -214,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,
@@ -222,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,
@@ -269,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,
@@ -277,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,
@@ -285,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,
@@ -293,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,
@@ -320,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
@@ -362,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);
@@ -385,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);
@@ -404,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);
@@ -417,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);
@@ -430,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);
@@ -443,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);
@@ -456,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);
@@ -469,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);
@@ -488,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,
+12 -9
View File
@@ -22,7 +22,7 @@
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '06 CROSS-ORGANIZATIONAL RULES' \echo '06 - CROSS-ORGANIZATIONAL RULES'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -36,10 +36,13 @@ INSERT INTO morbac.roles (id, org_id, name, description) VALUES
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
('30000000-0000-0000-0000-000000000014', '20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002'); ('30000000-0000-0000-0000-000000000014', '20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002');
-- Policies for eng_auditor in Engineering -- Rules for eng_auditor in Engineering
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
VALUES ('Engineering Dept', 'eng_auditor', 'read', 'audit_logs', 'permission', 'always'); SELECT o.id, r.id, 'read', 'audit_logs', c.id, 'permission'
SELECT * FROM morbac.compile_policy(); FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'eng_auditor'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'Engineering Dept';
-- Verify nina's role was set up correctly -- Verify nina's role was set up correctly
SELECT morbac.t('Nina has eng_auditor role at Engineering', SELECT morbac.t('Nina has eng_auditor role at Engineering',
@@ -50,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 ---'
@@ -72,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 ---'
@@ -120,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,
@@ -137,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',
-342
View File
@@ -1,342 +0,0 @@
-- =============================================================================
-- Administration Rules Tests
-- =============================================================================
-- Tests the admin meta-policy system: who can manage policies, roles, and users.
--
-- is_admin_allowed(user_id, org_id, admin_activity, admin_target) is the main API.
-- Helper functions: can_manage_roles(), can_manage_policies(), can_manage_user_role().
--
-- Scenarios:
-- 1. Default deny: no admin rule = no admin access
-- 2. Grant admin permission to a role, verify the role holder can manage
-- 3. Admin prohibition blocks management even with permission
-- 4. Role hierarchy applies: senior role inherits admin permissions
-- 5. Admin helper functions
-- 6. can_manage_user_role() per-role management permission
-- 7. Admin rules are org-scoped
--
-- Company context:
-- Grace (hr_manager) can manage users (assign_role) for employee-level roles
-- Carol (manager) can create rules
-- Alice (CEO) inherits Carol's admin perms via hierarchy
--
-- Prerequisites: 00_setup.sql -> 07_audit.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '08 — ADMINISTRATION RULES'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: Default deny — no admin rule means no admin access
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Default deny: no admin rule ---'
-- Nobody has admin rules yet — all should be denied
SELECT morbac.t('Alice (CEO, no admin rule yet) creates rules [default deny]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
SELECT morbac.t('Grace (hr_manager, no admin rule) assigns roles [default deny]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'employee'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Grant admin permissions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Grant admin permissions ---'
-- Grant manager role: can create and delete rules
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000003', -- manager
'create_rule', 'rules',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'),
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000003', -- manager
'delete_rule', 'rules',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission');
-- Grant hr_manager role: can assign employee and intern roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000008', -- hr_manager
'assign_role', 'employee',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'),
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000008', -- hr_manager
'assign_role', 'intern',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission');
-- Grant director role: can manage roles and policies
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000002', -- director
'manage', 'roles',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'),
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000002', -- director
'manage', 'policies',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission');
-- ---------------------------------------------------------------------------
-- Section 3: Verify admin permissions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. Verify admin permissions ---'
-- Carol (manager) can create rules
SELECT morbac.t('Carol (manager) creates rules',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), TRUE);
-- Carol (manager) can delete rules
SELECT morbac.t('Carol (manager) deletes rules',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete_rule', 'rules'
), TRUE);
-- Grace (hr_manager) can assign employee role
SELECT morbac.t('Grace (hr_manager) assigns employee role',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'employee'
), TRUE);
-- Grace (hr_manager) can assign intern role
SELECT morbac.t('Grace (hr_manager) assigns intern role',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'intern'
), TRUE);
-- Grace (hr_manager) cannot assign manager role (no rule for that)
SELECT morbac.t('Grace (hr_manager) assigns manager role [no admin rule for manager]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'manager'
), FALSE);
-- Dave (employee) has no admin permissions
SELECT morbac.t('Dave (employee) creates rules [no admin rule for employee]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 4: Role hierarchy applies to admin permissions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Role hierarchy applies to admin permissions ---'
-- Bob (director) has own admin perm (manage roles/policies) and
-- inherits manager's admin perms (create_rule, delete_rule) via director->manager hierarchy
-- Bob (director) creates rules — inherited from manager via director->manager hierarchy
SELECT morbac.t('Bob (director, inherits manager admin) creates rules',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), TRUE);
-- Bob (director) manages roles — own admin perm
SELECT morbac.t('Bob (director) manages roles [own admin perm]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'manage', 'roles'
), TRUE);
-- Alice (CEO) inherits everything through CEO->director->manager chain
SELECT morbac.t('Alice (CEO, inherits director+manager) manages roles',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'manage', 'roles'
), TRUE);
SELECT morbac.t('Alice (CEO) manages policies',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'manage', 'policies'
), TRUE);
-- Eve (intern) inherits nothing useful for admin
SELECT morbac.t('Eve (intern) creates rules [no admin permission in hierarchy]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 5: Admin prohibition overrides permission
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Admin prohibition overrides permission ---'
-- Carol (manager) currently can create rules. Add a prohibition.
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES (
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000003', -- manager
'create_rule', 'rules',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
-- Prohibition overrides permission
SELECT morbac.t('Carol (manager, now prohibited) creates rules [prohibition wins]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
-- Carol can still delete rules (prohibition only applied to create_rule)
SELECT morbac.t('Carol (manager, prohibition only on create) deletes rules [still allowed]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete_rule', 'rules'
), TRUE);
-- Remove the prohibition for subsequent tests
DELETE FROM morbac.admin_rules
WHERE org_id = '10000000-0000-0000-0000-000000000001'
AND role_id = '20000000-0001-0000-0000-000000000003'
AND admin_activity = 'create_rule' AND admin_target = 'rules'
AND modality = 'prohibition';
-- Carol can again create rules after prohibition removed
SELECT morbac.t('Carol (manager) creates rules after prohibition removed',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 6: Admin helper functions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. Admin helper functions ---'
-- can_manage_roles: wraps is_admin_allowed('manage', 'roles')
SELECT morbac.t('Bob (director) can_manage_roles',
morbac.can_manage_roles(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), TRUE);
SELECT morbac.t('Dave (employee) can_manage_roles [denied]',
morbac.can_manage_roles(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), FALSE);
-- can_manage_policies: wraps is_admin_allowed('manage', 'policies')
SELECT morbac.t('Bob (director) can_manage_policies',
morbac.can_manage_policies(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), TRUE);
SELECT morbac.t('Carol (manager) can_manage_policies [no manage policies rule for manager]',
morbac.can_manage_policies(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 7: can_manage_user_role — per-role management check
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. can_manage_user_role ---'
-- Grace (hr_manager) has assign_role perm for 'employee'
-- can_manage_user_role checks is_admin_allowed('assign_role', role_name)
SELECT morbac.t('Grace (hr_manager) can manage employee role assignments',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000007'::uuid, -- Grace
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000004'::uuid -- employee role
), TRUE);
SELECT morbac.t('Grace (hr_manager) can manage intern role assignments',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000007'::uuid, -- Grace
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000005'::uuid -- intern role
), TRUE);
SELECT morbac.t('Grace (hr_manager) cannot manage manager role assignments [no rule]',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000007'::uuid, -- Grace
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000003'::uuid -- manager role
), FALSE);
SELECT morbac.t('Dave (employee) cannot manage any role assignments',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000004'::uuid, -- Dave
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000005'::uuid -- intern role
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 8: Admin rules are org-scoped
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Admin rules are org-scoped ---'
-- Carol (manager at GlobalTech) cannot manage Engineering rules
-- (her admin rule is for GlobalTech org only)
SELECT morbac.t('Carol (GlobalTech manager) creates Engineering rules [wrong org]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid, -- Engineering org
'create_rule', 'rules'
), FALSE);
-- Alice (CEO at GlobalTech) cannot manage Engineering rules via admin (same org scoping)
SELECT morbac.t('Alice (GlobalTech CEO) creates Engineering rules [org-scoped]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid, -- Engineering org
'manage', 'roles'
), FALSE);
\echo ''
\echo '=== Administration Rules Tests Completed ==='
+290
View File
@@ -0,0 +1,290 @@
-- =============================================================================
-- System Access Tests
-- =============================================================================
-- morbac system tables are protected by RLS.
-- is_allowed() and its internals are SECURITY DEFINER to avoid recursion.
-- System view names are config-driven (system_view.* keys, default: orgs/roles/rules/...).
--
-- Scenarios:
-- 1. Default deny: no rule = no access to system views via is_allowed()
-- 2. Grant permissions via regular rules, verify access
-- 3. Prohibition overrides permission (standard engine behavior)
-- 4. Role hierarchy applies: senior role inherits permissions
-- 5. assign_role() / revoke_role() - SoD/cardinality enforcement, RLS guards the INSERT/DELETE
-- 6. RLS on morbac tables: session user cannot read/write without rules
-- 7. Rules are org-scoped
--
-- Prerequisites: 00_setup.sql -> 07_audit.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '08 - SYSTEM ACCESS'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: Default deny
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Default deny: no rule = no access to system views ---'
SELECT morbac.t('Carol (manager) create rules [default deny]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
SELECT morbac.t('Grace (hr_manager) create user_roles [default deny]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'user_roles'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Grant permissions via regular rules
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Grant permissions ---'
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, v.activity, v.view, c.id, v.modality::morbac.modality
FROM (VALUES
('GlobalTech HQ', 'manager', 'create', 'rules', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'delete', 'rules', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'read', 'user_roles', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'create', 'user_roles', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'delete', 'user_roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'create', 'roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'read', 'roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'update', 'roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'delete', 'roles', 'always', 'permission')
) AS v(org_name, role_name, activity, view, context_name, modality)
JOIN morbac.orgs o ON o.name = v.org_name
JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
JOIN morbac.contexts c ON c.name = v.context_name;
\echo ''
\echo '--- 3. Verify permissions ---'
SELECT morbac.t('Carol (manager) create rules',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), TRUE);
SELECT morbac.t('Carol (manager) delete rules',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'rules'
), TRUE);
SELECT morbac.t('Carol (manager) update rules [no rule]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'update', 'rules'
), FALSE);
SELECT morbac.t('Grace (hr_manager) create user_roles',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'user_roles'
), TRUE);
SELECT morbac.t('Dave (employee) create rules [no rule]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 4: Role hierarchy applies
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Role hierarchy applies ---'
SELECT morbac.t('Bob (director, inherits manager) create rules',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), TRUE);
SELECT morbac.t('Bob (director) create roles [own rule]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'roles'
), TRUE);
SELECT morbac.t('Alice (CEO) create roles [inherits director]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'roles'
), TRUE);
SELECT morbac.t('Eve (intern) create rules [nothing in hierarchy]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 5: Prohibition overrides permission
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Prohibition overrides permission ---'
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, 'create', 'rules', c.id, 'prohibition'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'manager'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'GlobalTech HQ';
SELECT morbac.t('Carol (manager, prohibited) create rules [prohibition wins]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
SELECT morbac.t('Carol (prohibition only on create) delete rules [still allowed]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'rules'
), TRUE);
DELETE FROM morbac.rules
WHERE org_id = '10000000-0000-0000-0000-000000000001'
AND role_id = '20000000-0001-0000-0000-000000000003'
AND activity = 'create' AND view = 'rules'
AND modality = 'prohibition';
-- ---------------------------------------------------------------------------
-- Section 6: assign_role / revoke_role
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. assign_role / revoke_role ---'
-- As DB owner (bypasses RLS), assign Karl as intern to verify constraint logic
SELECT morbac.assign_role(
'30000000-0000-0000-0000-000000000011'::uuid, -- Karl
'20000000-0001-0000-0000-000000000005'::uuid, -- intern
'10000000-0000-0000-0000-000000000001'::uuid
);
SELECT morbac.t('Karl assigned intern role via assign_role()',
EXISTS(
SELECT 1 FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
), TRUE);
SELECT morbac.revoke_role(
'30000000-0000-0000-0000-000000000011'::uuid,
'20000000-0001-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
);
SELECT morbac.t('Karl intern role revoked via revoke_role()',
NOT EXISTS(
SELECT 1 FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 7: RLS on morbac tables
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. RLS on morbac tables ---'
-- Create a non-superuser role so RLS policies are enforced (superusers bypass RLS by default)
DROP ROLE IF EXISTS morbac_rls_tester;
CREATE ROLE morbac_rls_tester;
GRANT USAGE ON SCHEMA morbac TO morbac_rls_tester;
GRANT SELECT, INSERT, DELETE ON morbac.user_roles TO morbac_rls_tester;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO morbac_rls_tester;
SET SESSION AUTHORIZATION morbac_rls_tester;
-- Grace (hr_manager) has create/delete on user_roles - RLS should allow
SET morbac.user_id = '30000000-0000-0000-0000-000000000007';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000011',
'20000000-0001-0000-0000-000000000005',
'10000000-0000-0000-0000-000000000001'
);
SELECT morbac.t('Grace (hr_manager) inserted user_role via RLS',
EXISTS(
SELECT 1 FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
), TRUE);
DELETE FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
AND org_id = '10000000-0000-0000-0000-000000000001';
-- Dave (employee) has no rules for user_roles - RLS should block
SET morbac.user_id = '30000000-0000-0000-0000-000000000004';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
DO $$
BEGIN
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000011',
'20000000-0001-0000-0000-000000000005',
'10000000-0000-0000-0000-000000000001'
);
RAISE NOTICE 'CHECK FAIL: Dave (employee) inserted user_role [should have been blocked by RLS]';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'CHECK PASS: Dave (employee) blocked from inserting user_role by RLS';
END;
$$;
RESET SESSION AUTHORIZATION;
RESET morbac.user_id;
RESET morbac.org_id;
DROP OWNED BY morbac_rls_tester;
DROP ROLE IF EXISTS morbac_rls_tester;
-- ---------------------------------------------------------------------------
-- Section 8: Rules are org-scoped
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Rules are org-scoped ---'
SELECT morbac.t('Carol (GlobalTech manager) create Engineering rules [wrong org]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'create', 'rules'
), FALSE);
SELECT morbac.t('Alice (GlobalTech CEO) create Engineering roles [org-scoped]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'create', 'roles'
), FALSE);
\echo ''
\echo '=== System Access Tests Completed ==='
+22 -84
View File
@@ -1,26 +1,24 @@
-- ============================================================================= -- =============================================================================
-- Utilities, Derived Roles, RLS, and Policy DSL Tests -- Utilities, Derived Roles, and RLS Tests
-- ============================================================================= -- =============================================================================
-- 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. Policy DSL idempotency — compile_policy() is safe to run multiple times -- 7. Derived roles - computed via evaluator function
-- 8. Policy DSL error handling — reports errors for missing org/role -- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping
-- 9. Derived roles — computed via evaluator function
-- 10. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping
-- --
-- Prerequisites: 00_setup.sql -> 08_admin.sql -- Prerequisites: 00_setup.sql -> 08_system_access.sql
-- ============================================================================= -- =============================================================================
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '09 UTILITIES, DERIVED ROLES, RLS, POLICY DSL' \echo '09 - UTILITIES, DERIVED ROLES, RLS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -141,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 ---'
@@ -327,78 +325,18 @@ SELECT morbac.t('eval_context(end_of_quarter) = TRUE',
TRUE); TRUE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 7: Policy DSL — idempotency -- Section 7: Derived roles
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 7. Policy DSL idempotency ---' \echo '--- 7. Derived roles ---'
-- All existing policies are already compiled (compiled=TRUE) -- Create a derived role: 'senior_employee' - dynamically granted to Dave (only)
-- Running compile_policy() again should compile 0 new entries
SELECT morbac.t_eq('Re-running compile_policy() gives compiled_count=0 (all already compiled)',
(SELECT compiled_count FROM morbac.compile_policy()),
0);
SELECT morbac.t_eq('Re-running compile_policy() gives error_count=0',
(SELECT error_count FROM morbac.compile_policy()),
0);
-- Adding a new policy and verifying it compiles once but not twice
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('GlobalTech HQ', 'employee', 'read', 'contracts', 'permission', 'always');
SELECT morbac.t_eq('compile_policy() compiles 1 new policy entry',
(SELECT compiled_count FROM morbac.compile_policy()),
1);
SELECT morbac.t_eq('compile_policy() is idempotent: compiled_count=0 on second run',
(SELECT compiled_count FROM morbac.compile_policy()),
0);
-- Verify the rule was actually created
SELECT morbac.t_eq('Rule for employee read contracts was created',
(SELECT COUNT(*) FROM morbac.rules r
JOIN morbac.roles ro ON ro.id = r.role_id
WHERE ro.name = 'employee'
AND r.activity = 'read' AND r.view = 'contracts' AND r.modality = 'permission')::bigint,
1);
-- ---------------------------------------------------------------------------
-- Section 8: Policy DSL — error handling
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Policy DSL error handling ---'
-- Insert a policy for a non-existent organization
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('Nonexistent Corp', 'employee', 'read', 'documents', 'permission', 'always');
-- Should produce errors (org not found)
SELECT morbac.t('compile_policy: nonexistent org produces errors',
(SELECT error_count > 0 FROM morbac.compile_policy()),
TRUE);
-- Insert a policy with a valid org but nonexistent role
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('GlobalTech HQ', 'ghost_role', 'read', 'documents', 'permission', 'always');
-- Should produce errors (role not found)
SELECT morbac.t('compile_policy: nonexistent role produces errors',
(SELECT error_count > 0 FROM morbac.compile_policy()),
TRUE);
-- ---------------------------------------------------------------------------
-- Section 9: Derived roles
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 9. Derived roles ---'
-- 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
@@ -441,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(
@@ -497,10 +435,10 @@ WHERE user_id = '30000000-0000-0000-0000-000000000004'
AND role_id = '20000000-0001-0000-0000-000000000011'; AND role_id = '20000000-0001-0000-0000-000000000011';
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 10: RLS helper functions -- Section 8: RLS helper functions
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 10. RLS helpers: get_user_orgs, current_org_ids, rls_check ---' \echo '--- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check ---'
-- Without session variables set, current_user_id / current_org_id / current_org_ids return NULL -- Without session variables set, current_user_id / current_org_id / current_org_ids return NULL
SELECT morbac.t_null('current_user_id() returns NULL without session var', SELECT morbac.t_null('current_user_id() returns NULL without session var',
@@ -593,9 +531,9 @@ SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in Engineering [a
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'), morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'),
TRUE); TRUE);
-- Judy has no role at GlobalTech -> denied even in all-orgs mode -- Judy has no access to contracts at GlobalTech (no cross-org rule, no view hierarchy) -> denied
SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in GlobalTech [no role, denied]', SELECT morbac.t('rls_check all-orgs mode: Judy reads contracts in GlobalTech [no rule, denied]',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'), morbac.rls_check('read', 'contracts', '10000000-0000-0000-0000-000000000001'),
FALSE); FALSE);
-- No row org and no session org -> denied -- No row org and no session org -> denied
@@ -613,4 +551,4 @@ SELECT morbac.t('rls_check(read, documents) as Karl (no role) [denied]',
RESET morbac.user_id; RESET morbac.user_id;
\echo '' \echo ''
\echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ===' \echo '=== Utilities, Derived Roles, and RLS Tests Completed ==='
+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 ---'
+259
View File
@@ -0,0 +1,259 @@
-- =============================================================================
-- Scope Rules and Global Cross-Org Rules Tests
-- =============================================================================
-- Tests rules.scope and cross_org_rules.source_org_id = NULL:
--
-- 1. scope='self' (default) - exact org only, unchanged behavior
-- 2. scope='subtree' - rule at root covers self + Engineering + Sales
-- 3. scope='descendants' - covers Engineering + Sales but NOT GlobalTech itself
-- 4. scope='children' - covers direct children only
-- 5. New org added after rule creation - picked up automatically (cache invalidation)
--
-- Prerequisites: 00_setup.sql -> 10_activity_view_bindings.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '11 - SCOPE RULES AND GLOBAL CROSS-ORG RULES'
\echo '================================================================'
-- Setup: create a dedicated role for scope tests (avoid polluting existing rules)
INSERT INTO morbac.roles (id, org_id, name, description)
VALUES (
'20000000-0001-0000-0000-000000000012',
'10000000-0000-0000-0000-000000000001',
'analyst',
'Data analyst - scope tests'
);
-- Assign Karl (previously no role) as analyst at GlobalTech
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000011',
'20000000-0001-0000-0000-000000000012',
'10000000-0000-0000-0000-000000000001'
);
-- ---------------------------------------------------------------------------
-- Section 1: scope='self' (default) - exact org only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. scope=self (default) ---'
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'self'
);
SELECT morbac.t('Karl (analyst, scope=self) reads reports in GlobalTech HQ [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=self) reads reports in Engineering [denied, self only]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), FALSE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000001';
-- ---------------------------------------------------------------------------
-- Section 2: scope='subtree' - root + all descendants
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. scope=subtree ---'
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000002',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'subtree'
);
SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in GlobalTech HQ [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in Engineering [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in Sales [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000003'::uuid,
'read', 'reports'
), TRUE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000002';
-- ---------------------------------------------------------------------------
-- Section 3: scope='descendants' - children only, NOT self
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. scope=descendants ---'
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000003',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'descendants'
);
SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in GlobalTech HQ [denied, not self]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), FALSE);
SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in Engineering [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in Sales [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000003'::uuid,
'read', 'reports'
), TRUE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000003';
-- ---------------------------------------------------------------------------
-- Section 4: scope='children' - direct children only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. scope=children ---'
-- Add a grandchild org (child of Engineering)
INSERT INTO morbac.orgs (id, name, parent_id)
VALUES (
'10000000-0000-0000-0000-000000000004',
'Backend Team',
'10000000-0000-0000-0000-000000000002'
);
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000004',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'children'
);
SELECT morbac.t('Karl (analyst, scope=children) reads reports in Engineering [direct child, allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=children) reads reports in Backend Team [grandchild, denied]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000004'::uuid,
'read', 'reports'
), FALSE);
SELECT morbac.t('Karl (analyst, scope=children) reads reports in GlobalTech HQ [self, denied]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), FALSE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000004';
DELETE FROM morbac.orgs WHERE id = '10000000-0000-0000-0000-000000000004';
-- ---------------------------------------------------------------------------
-- Section 5: New org added after rule creation - scope picks it up automatically
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Dynamic scope: new org covered automatically ---'
-- Create a subtree-scoped rule at GlobalTech HQ for the analyst role
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000005',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'subtree'
);
-- Verify the rule works for existing child orgs
SELECT morbac.t('Karl (analyst) reads documents in Engineering [existing child, allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'documents'
), TRUE);
-- Add a new org AFTER the rule was created
INSERT INTO morbac.orgs (id, name, parent_id)
VALUES (
'10000000-0000-0000-0000-000000000005',
'Legal Dept',
'10000000-0000-0000-0000-000000000001'
);
-- The scoped rule was defined before Legal Dept existed - still covers it
SELECT morbac.t('Karl (analyst) reads documents in Legal Dept [new org, covered by subtree scope]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000005'::uuid,
'read', 'documents'
), TRUE);
-- A scope=self rule at GlobalTech HQ does NOT cover Legal Dept
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000006',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'self'
);
SELECT morbac.t('Karl (analyst, scope=self) reads reports in Legal Dept [new org, not covered]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000005'::uuid,
'read', 'reports'
), FALSE);
-- Cleanup
DELETE FROM morbac.rules WHERE id IN ('c0000000-0000-0000-0000-000000000005', 'c0000000-0000-0000-0000-000000000006');
DELETE FROM morbac.orgs WHERE id = '10000000-0000-0000-0000-000000000005';
\echo ''
\echo '=== Scope Rules and Global Cross-Org Rules Tests Completed ==='
+269
View File
@@ -0,0 +1,269 @@
-- =============================================================================
-- User Rules Tests
-- =============================================================================
-- Tests direct user-level rules via morbac.user_rules.
--
-- Scenarios:
-- 1. No user rule: Karl (no role) is denied by default
-- 2. Direct user permission: Karl gets access without any role assignment
-- 3. User rule covers activity/view hierarchy
-- 4. User-level prohibition overrides a role-based permission
-- 5. Priority: user permission with higher priority overrides user prohibition
-- 6. Temporal user rules (valid_from / valid_until)
-- 7. rls_check user filter: morbac.target_user_id scopes rows to a specific user
--
-- Prerequisites: 00_setup.sql -> 11_scope_rules.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '12 - USER RULES'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: No user rule - Karl (no role) is denied
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. No user rule: access denied ---'
SELECT morbac.t('Karl (no role) reads GlobalTech documents [no user rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'documents'
), FALSE);
SELECT morbac.t('Karl (no role) reads GlobalTech financial_data [no user rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'financial_data'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Direct user permission - Karl gets access without a role
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Direct user permission ---'
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl (no role) reads GlobalTech documents [user rule grants access]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'documents'
), TRUE);
-- Rule only covers GlobalTech, not Engineering
SELECT morbac.t('Karl reads Engineering documents [no user rule for Engineering]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'documents'
), FALSE);
-- financial_data is a subtype of documents (view hierarchy), so the documents rule covers it
SELECT morbac.t('Karl reads GlobalTech financial_data [documents rule covers it via view hierarchy]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'financial_data'
), TRUE);
-- contracts has no hierarchy relationship - documents rule does not cover it
SELECT morbac.t('Karl reads GlobalTech contracts [no user rule, no hierarchy coverage]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 3: User rule + activity/view hierarchy
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. User rule with activity/view hierarchy ---'
-- Add a user rule for 'read reports' (parent of financial_data via view hierarchy)
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
-- get_effective_activities('write') includes 'read', so read permission covers write requests
SELECT morbac.t('Karl writes GlobalTech documents [user rule read covers write via activity hierarchy]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'write', 'documents'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 4: User-level prohibition overrides role-based permission
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. User prohibition overrides role permission ---'
-- Eve (intern) can read public_data via role
SELECT morbac.t('Eve (intern) reads GlobalTech public_data [role permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
-- Add a user prohibition for Eve on public_data
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000005', -- Eve
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
SELECT morbac.t('Eve (intern) reads GlobalTech public_data [user prohibition blocks role permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
-- Dave (employee) is unaffected - only Eve has the prohibition
SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 5: Priority - higher-priority user permission overrides prohibition
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Priority: user permission overrides user prohibition ---'
-- Eve has a prohibition (priority 0) on public_data; add a higher-priority permission
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, priority)
VALUES (
'30000000-0000-0000-0000-000000000005', -- Eve
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
10
);
SELECT morbac.t('Eve reads GlobalTech public_data [priority-10 user permission beats priority-0 prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
-- Clean up the priority override for the temporal test
DELETE FROM morbac.user_rules
WHERE user_id = '30000000-0000-0000-0000-000000000005'
AND modality = 'permission'
AND priority = 10;
-- ---------------------------------------------------------------------------
-- Section 6: Temporal user rules
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. Temporal user rules ---'
-- Use contracts: Karl has no other rules covering it, so expiry is conclusive
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, valid_from, valid_until)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
now() - interval '1 hour',
now() + interval '1 day'
);
SELECT morbac.t('Karl reads GlobalTech contracts [temporal user rule, active]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
-- Expire the rule
UPDATE morbac.user_rules
SET valid_until = now() - interval '1 second'
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND org_id = '10000000-0000-0000-0000-000000000001'
AND activity = 'read' AND view = 'contracts'
AND modality = 'permission';
SELECT morbac.t('Karl reads GlobalTech contracts [temporal user rule, expired]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 7: rls_check user filter via morbac.target_user_id
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. rls_check user filter ---'
SET morbac.user_id = '30000000-0000-0000-0000-000000000004'; -- Dave (employee)
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ
-- No target_user_id set: row with any user_id passes the user filter
SELECT morbac.t('rls_check passes without target_user_id filter',
morbac.rls_check(
'read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid,
'30000000-0000-0000-0000-000000000004'::uuid
), TRUE);
-- Set target_user_id to Dave - rows belonging to Dave pass
SET morbac.target_user_id = '30000000-0000-0000-0000-000000000004';
SELECT morbac.t('rls_check passes when row user_id matches target_user_id',
morbac.rls_check(
'read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid,
'30000000-0000-0000-0000-000000000004'::uuid
), TRUE);
-- Row belonging to Alice is filtered out
SELECT morbac.t('rls_check blocked when row user_id differs from target_user_id',
morbac.rls_check(
'read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid,
'30000000-0000-0000-0000-000000000001'::uuid
), FALSE);
-- No p_row_user_id passed - user filter does not apply
SELECT morbac.t('rls_check passes when no row user_id passed (filter skipped)',
morbac.rls_check(
'read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid
), TRUE);
RESET morbac.target_user_id;
RESET morbac.user_id;
RESET morbac.org_id;
\echo ''
\echo '=== User Rules Tests Completed ==='
+388
View File
@@ -0,0 +1,388 @@
-- =============================================================================
-- Global Rules Tests
-- =============================================================================
-- Tests system-wide rules via morbac.global_rules.
--
-- Scenarios:
-- 1. No global rule: Karl (no role) is denied by default
-- 2. Global permission (user_id=NULL): all users gain access
-- 3. Global permission (user_id=uuid): only that user gains access
-- 4. Global prohibition (user_id=NULL): all users are denied regardless of role
-- 5. Global prohibition (user_id=uuid): only that user is denied
-- 6. Priority: global permission with higher priority overrides global prohibition
-- 7. Priority: high-priority global prohibition overrides role-based permission
-- 8. NULL activity/view wildcards (no hierarchy needed)
-- 9. Activity/view hierarchy applies when activity/view are set
-- 10. Temporal global rules (valid_from / valid_until)
--
-- Prerequisites: 00_setup.sql -> 12_user_rules.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '13 -- GLOBAL RULES'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: No global rule -- Karl (no role) is denied
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. No global rule: access denied ---'
SELECT morbac.t('Karl (no role) reads GlobalTech contracts [no global rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
SELECT morbac.t('Karl (no role) reads Engineering contracts [no global rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'contracts'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Global permission (user_id=NULL) -- all users gain access
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Global permission, user_id=NULL: all users ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
NULL,
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
-- Karl (no role) can now read contracts in any org
SELECT morbac.t('Karl reads GlobalTech contracts [global permission, any user]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
SELECT morbac.t('Karl reads Engineering contracts [global permission covers all orgs]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'contracts'
), TRUE);
-- Dave (employee) also benefits
SELECT morbac.t('Dave reads GlobalTech contracts [global permission, any user]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
DELETE FROM morbac.global_rules WHERE user_id IS NULL AND activity = 'read' AND view = 'contracts';
-- ---------------------------------------------------------------------------
-- Section 3: Global permission (user_id=uuid) -- specific user only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. Global permission, user_id=Karl only ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl reads GlobalTech contracts [user_id-specific global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
-- Dave has no role permission on contracts and no global rule for him
SELECT morbac.t('Dave reads GlobalTech contracts [user_id-specific rule does not cover Dave]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
-- ---------------------------------------------------------------------------
-- Section 4: Global prohibition (user_id=NULL) -- all users denied regardless of role
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Global prohibition, user_id=NULL: all users denied ---'
-- Dave (employee) has a role-based permission on public_data from setup
SELECT morbac.t('Dave reads GlobalTech public_data [role permission before global prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
NULL,
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
SELECT morbac.t('Dave reads GlobalTech public_data [global prohibition blocks role permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
-- Karl (no role) is also denied
SELECT morbac.t('Karl reads GlobalTech public_data [global prohibition, no role]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
-- Alice (CEO, highest role) is also denied
SELECT morbac.t('Alice reads GlobalTech public_data [global prohibition overrides CEO role]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
DELETE FROM morbac.global_rules WHERE user_id IS NULL AND activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 5: Global prohibition (user_id=uuid) -- specific user denied only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Global prohibition, user_id=Dave only ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000004', -- Dave
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
SELECT morbac.t('Dave reads GlobalTech public_data [user_id-specific global prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
-- Carol (manager) is unaffected
SELECT morbac.t('Carol reads GlobalTech public_data [global prohibition does not affect Carol]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000004'
AND activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 6: Priority -- global permission overrides global prohibition
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. Priority: global permission > global prohibition ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
NULL,
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 5
);
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
'30000000-0000-0000-0000-000000000004', -- Dave
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 10
);
SELECT morbac.t('Dave reads GlobalTech public_data [priority-10 global permission beats priority-5 global prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
-- Karl has no user_id-specific permission -- global prohibition still blocks him
SELECT morbac.t('Karl reads GlobalTech public_data [global prohibition still blocks non-exempted user]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
DELETE FROM morbac.global_rules WHERE activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 7: Priority -- high-priority global prohibition overrides role permission
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. Priority: global prohibition > role permission ---'
-- Dave has role-based read on public_data (priority 0 by default)
SELECT morbac.t('Dave reads GlobalTech public_data [role permission, no global rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
NULL,
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 100
);
SELECT morbac.t('Dave reads GlobalTech public_data [priority-100 global prohibition beats role permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
DELETE FROM morbac.global_rules WHERE activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 8: NULL activity/view wildcards
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. NULL wildcards: activity=NULL and view=NULL ---'
-- view=NULL grants read on every view for Karl (no role)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl reads GlobalTech contracts [view=NULL global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
SELECT morbac.t('Karl reads GlobalTech financial_data [view=NULL global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'financial_data'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view IS NULL;
-- activity=NULL and view=NULL grants everything
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
NULL, NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl deletes GlobalTech contracts [activity=NULL, view=NULL global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'contracts'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity IS NULL AND view IS NULL;
-- ---------------------------------------------------------------------------
-- Section 9: Activity/view hierarchy applies when activity/view are set
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 9. Activity/view hierarchy ---'
-- financial_data is a child of documents (view hierarchy from setup)
-- A global permission on 'documents' should cover 'financial_data'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl reads GlobalTech financial_data [global permission on documents covers child view]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'financial_data'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'documents';
-- ---------------------------------------------------------------------------
-- Section 10: Temporal global rules
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 10. Temporal global rules ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, valid_from, valid_until)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
now() - interval '1 hour',
now() + interval '1 day'
);
SELECT morbac.t('Karl reads GlobalTech contracts [temporal global rule, active]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
UPDATE morbac.global_rules
SET valid_until = now() - interval '1 second'
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
SELECT morbac.t('Karl reads GlobalTech contracts [temporal global rule, expired]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
\echo ''
\echo '=== Global Rules Tests Completed ==='
+344
View File
@@ -0,0 +1,344 @@
-- =============================================================================
-- System Principals Tests
-- =============================================================================
-- Tests the morbac.system_principals registry and all associated protections.
--
-- Scenarios:
-- 1. Setup: register a system principal and define its permissions via global_rules
-- 2. Permissions work normally (system principal can access what it is granted)
-- 3. Prohibitions are ignored (no role-based, user-level, or global prohibition applies)
-- 4. Cannot assign roles to a system principal
-- 5. Cannot revoke roles from a system principal
-- 6. Cannot add negative role assignments for a system principal
-- 7. Cannot add user_rules for a system principal
-- 8. Cannot create delegations involving a system principal
-- 9. Cannot add a targeted global_rules prohibition for a system principal
-- 10. Cannot modify or delete existing global_rules for a system principal
-- 11. Prohibitions apply to regular users (system principal bypass is not global)
-- 12. is_allowed() works correctly in read-only transactions
--
-- Prerequisites: 00_setup.sql -> 13_global_rules.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '14 -- SYSTEM PRINCIPALS'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: Setup
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Setup ---'
-- Register a system principal (done as DB owner at deploy time)
INSERT INTO morbac.system_principals (user_id, description)
VALUES (
'40000000-0000-0000-0000-000000000001',
'Backend API service account'
);
-- Define its permissions via global_rules (immutable once inserted)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
NULL, NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('System principal registered',
EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = '40000000-0000-0000-0000-000000000001'),
TRUE);
-- ---------------------------------------------------------------------------
-- Section 2: Permissions work
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. System principal permissions are effective ---'
SELECT morbac.t('System principal reads GlobalTech documents [global permission, activity=NULL view=NULL]',
morbac.is_allowed_nocache(
'40000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'documents'
), TRUE);
SELECT morbac.t('System principal deletes Engineering contracts [global permission covers all]',
morbac.is_allowed_nocache(
'40000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'delete', 'contracts'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 3: Prohibitions are ignored
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. Prohibitions are ignored for system principals ---'
-- Insert a blanket global prohibition (user_id=NULL affects everyone normally)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
NULL,
'delete', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 999
);
SELECT morbac.t('System principal deletes contracts [blanket global prohibition ignored]',
morbac.is_allowed_nocache(
'40000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'contracts'
), TRUE);
-- Regular user is still affected by the prohibition
SELECT morbac.t('Dave deletes GlobalTech contracts [blanket prohibition applies to regular users]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'contracts'
), FALSE);
DELETE FROM morbac.global_rules
WHERE user_id IS NULL AND activity = 'delete' AND view = 'contracts';
-- ---------------------------------------------------------------------------
-- Section 4: Cannot assign roles to a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Role assignment blocked ---'
SELECT morbac.t('Assigning role to system principal raises exception',
(SELECT COUNT(*) FROM (
SELECT morbac.assign_role(
'40000000-0000-0000-0000-000000000001'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'::uuid
)
) sub) = 0,
FALSE
) WHERE FALSE; -- skip, tested via exception below
DO $$
BEGIN
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'40000000-0000-0000-0000-000000000001',
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: role assignment to system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 5: Cannot add negative role assignments for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Negative role assignment blocked ---'
DO $$
BEGIN
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id)
VALUES (
'40000000-0000-0000-0000-000000000001',
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: negative role assignment for system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 6: Cannot add user_rules for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. user_rules blocked ---'
DO $$
BEGIN
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000001',
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: user_rules insert for system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 7: Cannot create delegations involving a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. Delegations blocked ---'
DO $$
BEGIN
INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000001', -- Alice as delegator
'40000000-0000-0000-0000-000000000001', -- system principal as delegatee
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: delegation to system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 8: Cannot add a targeted global_rules prohibition
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Targeted global prohibition blocked ---'
DO $$
BEGIN
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
'delete', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: global prohibition targeting system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 9: Cannot add a permission global_rule for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 9. New global permission for system principal blocked ---'
DO $$
BEGIN
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: new global permission for system principal blocked (define at deploy time only)';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 10: Cannot delete existing global_rules for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 10. Deleting system principal global_rules blocked ---'
DO $$
BEGIN
DELETE FROM morbac.global_rules
WHERE user_id = '40000000-0000-0000-0000-000000000001';
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: deletion of system principal global rules blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 11: Prohibitions apply to regular users, not just system principals
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 11. Prohibitions apply to regular users ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
NULL,
'delete', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 100
);
SELECT morbac.t('Dave deletes GlobalTech documents [blanket prohibition must apply to non-system users]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'documents'
), FALSE);
SELECT morbac.t('System principal deletes GlobalTech documents [prohibition still bypassed]',
morbac.is_allowed_nocache(
'40000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'documents'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id IS NULL AND activity = 'delete' AND view = 'documents';
-- ---------------------------------------------------------------------------
-- Section 12: is_allowed() works in read-only transactions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 12. is_allowed() in read-only transactions ---'
BEGIN;
SET TRANSACTION READ ONLY;
SELECT morbac.t('is_allowed() returns correct result in read-only transaction',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
ROLLBACK;
\echo ''
\echo '=== System Principals Tests Completed ==='
+224
View File
@@ -0,0 +1,224 @@
-- =============================================================================
-- rls_check Tests
-- =============================================================================
-- Tests morbac.rls_check() with all session-org combinations. A NULL row org
-- is an unattributed object: an org filter (single pin, or org_ids without a
-- null marker) excludes it; it is reachable via no filter or a null marker.
--
-- 1. No user_id set: always FALSE
-- 2. Single org context
-- a. org-scoped row, matching org
-- b. org-scoped row, different org (blocked)
-- c. NULL row: filtered out under an org pin (orphan not requested)
-- 3. org_ids filter
-- a. org-scoped row in list
-- b. org-scoped row not in list (blocked)
-- c. NULL row, list without null marker: filtered out even with a grant
-- c2. NULL row, list with null marker + global permission: allowed
-- d. NULL row, list with null marker + global prohibition: blocked
-- e. NULL row, list with null marker + no rule: blocked
-- 4. No org context
-- a. org-scoped row: uses row's org
-- b. NULL row + global permission [orphan + global rules -> TRUE]
-- c. NULL row + global prohibition [blocked]
-- d. NULL row + no rule [blocked]
--
-- User state carried from previous tests:
-- Karl (30000000-0000-0000-0000-000000000011):
-- - user_rules: read documents, read reports in GlobalTech HQ (no expiry)
-- - no role assignments, no org memberships
--
-- Prerequisites: 00_setup.sql -> 14_system_principals.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '15 -- RLS_CHECK'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: No user_id set - always FALSE
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. No user_id: always FALSE ---'
RESET morbac.user_id;
RESET morbac.org_id;
RESET morbac.org_ids;
SELECT morbac.t('rls_check without user_id, org row',
morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid),
FALSE);
SELECT morbac.t('rls_check without user_id, global row',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Single org context
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Single org context ---'
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ
-- 2a: org-scoped row, matching org - Karl has user_rule for read documents
SELECT morbac.t('rls_check single org, org row matches session org (Karl/documents)',
morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid),
TRUE);
-- 2b: org-scoped row, different org - blocked before is_allowed
SELECT morbac.t('rls_check single org, org row from different org (blocked)',
morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000002'::uuid),
FALSE);
-- 2c: NULL row - filtered out under a single org pin (orphan not requested)
SELECT morbac.t('rls_check single org, NULL row filtered out under org pin',
morbac.rls_check('read', 'documents', NULL),
FALSE);
-- 2c (contracts): still filtered out regardless of permission
SELECT morbac.t('rls_check single org, NULL row filtered out (contracts)',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
RESET morbac.user_id;
RESET morbac.org_id;
-- ---------------------------------------------------------------------------
-- Section 3: org_ids filter
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. org_ids filter ---'
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001"]'; -- [GlobalTech HQ]
-- 3a: org-scoped row in the list - Karl has user_rule for read documents in GlobalTech
SELECT morbac.t('rls_check org_ids, org row in list (Karl/documents/GlobalTech)',
morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid),
TRUE);
-- 3b: org-scoped row not in the list - blocked
SELECT morbac.t('rls_check org_ids, org row not in list (blocked)',
morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000002'::uuid),
FALSE);
-- 3c: NULL row, list WITHOUT null marker - filtered out even with a global grant
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('rls_check org_ids without null marker, NULL row filtered out despite grant',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
-- 3c2: NULL row, list WITH null marker + global permission - allowed
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001", null]';
SELECT morbac.t('rls_check org_ids with null marker, NULL row + global permission',
morbac.rls_check('read', 'contracts', NULL),
TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
-- 3d: NULL row, list with null marker + global prohibition
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
SELECT morbac.t('rls_check org_ids with null marker, NULL row + global prohibition',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
-- 3e: NULL row, list with null marker + no rule
SELECT morbac.t('rls_check org_ids with null marker, NULL row + no rule',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
RESET morbac.user_id;
RESET morbac.org_ids;
-- ---------------------------------------------------------------------------
-- Section 4: No org context
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. No org context ---'
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)
SELECT morbac.t('rls_check no org context, org row: uses row org (Karl/documents/GlobalTech)',
morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000001'::uuid),
TRUE);
-- 4a (no permission): Karl has no rule in Engineering
SELECT morbac.t('rls_check no org context, org row: no permission in row org (Engineering)',
morbac.rls_check('read', 'documents',
'10000000-0000-0000-0000-000000000002'::uuid),
FALSE);
-- 4b: NULL row + global permission - is_allowed(Karl, NULL, ...) sees unattributed + global rules
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('rls_check no org context, global row + global permission [new: was FALSE]',
morbac.rls_check('read', 'contracts', NULL),
TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
-- 4c: global row + global prohibition
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
SELECT morbac.t('rls_check no org context, global row + global prohibition',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
-- 4d: global row + no rule
SELECT morbac.t('rls_check no org context, global row + no rule',
morbac.rls_check('read', 'contracts', NULL),
FALSE);
RESET morbac.user_id;
\echo ''
\echo '=== rls_check Tests Completed ==='
+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 ==='
+6 -4
View File
@@ -23,22 +23,24 @@ BUILD_FILES=(
"organizations.sql" "organizations.sql"
"activities.sql" "activities.sql"
"views.sql" "views.sql"
"activity_view_bindings.sql"
"roles.sql" "roles.sql"
"hierarchy_functions.sql" "hierarchy_functions.sql"
"materialized_views.sql" "materialized_views.sql"
"contexts.sql" "contexts.sql"
"policy_dsl.sql"
"rules.sql" "rules.sql"
"cross_org_rules.sql" "cross_org_rules.sql"
"admin_rules.sql" "user_rules.sql"
"global_rules.sql"
"system_principals.sql"
"activity_view_bindings.sql"
"obligations.sql" "obligations.sql"
"audit.sql" "audit.sql"
"rls.sql" "rls.sql"
"admin_helpers.sql" "role_assignments.sql"
"auth_cache.sql" "auth_cache.sql"
"validation.sql" "validation.sql"
"authorization.sql" "authorization.sql"
"system_rls.sql"
"footer.sql" "footer.sql"
) )
+1 -1
View File
@@ -5,7 +5,7 @@
set -e # Exit immediately if a command exits with a non-zero status set -e # Exit immediately if a command exits with a non-zero status
CONTAINER="${1:-postgres}" CONTAINER="${1:-pgmorbac_postgres_test}"
DB="${2:-morbac_test}" DB="${2:-morbac_test}"
PG_USER="${3:-postgres}" PG_USER="${3:-postgres}"