Compare commits

..

11 Commits

Author SHA1 Message Date
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
44 changed files with 3364 additions and 1418 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
+2 -2
View File
@@ -5,7 +5,7 @@ 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 ## [0.1.0] - 2026-02-19
### Added ### Added
- Initial release of Multi-OrBAC PostgreSQL extension - Initial release of Multi-OrBAC PostgreSQL extension
@@ -41,4 +41,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Complete documentation - Complete documentation
- Build and installation automation (Makefile, install.sh) - Build and installation automation (Makefile, install.sh)
[1.0.0]: https://github.com/MarcVillain/pgmorbac/releases/tag/v1.0.0 [0.1.0]: https://git.villains.fr/crudy/pgmorbac/releases/tag/v0.1.0
+8 -7
View File
@@ -2,7 +2,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 — enabling organization-centric, context-aware, and hierarchical access control with delegation, separation of duty, and cross-organizational policies.",
"version": "1.0.0", "version": "0.1.0",
"maintainer": [ "maintainer": [
"Marc VILLAIN <marc.villain@epita.fr>" "Marc VILLAIN <marc.villain@epita.fr>"
], ],
@@ -10,8 +10,8 @@
"provides": { "provides": {
"pgmorbac": { "pgmorbac": {
"abstract": "Multi-OrBAC access control extension", "abstract": "Multi-OrBAC access control extension",
"file": "pgmorbac--1.0.0.sql", "file": "pgmorbac--0.1.0.sql",
"version": "1.0.0" "version": "0.1.0"
} }
}, },
"prereqs": { "prereqs": {
@@ -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
+6 -7
View File
@@ -33,7 +33,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
@@ -58,11 +58,11 @@ sudo ./tools/install.sh
```bash ```bash
# Build versioned file from source # Build versioned file from source
make build # Concatenates src/ files into pgmorbac--1.0.0.sql make build # Concatenates src/ files into pgmorbac--0.1.0.sql
# Copy files to PostgreSQL extension directory # Copy files to PostgreSQL extension directory
sudo cp pgmorbac.control $(pg_config --sharedir)/extension/ sudo cp pgmorbac.control $(pg_config --sharedir)/extension/
sudo cp pgmorbac--1.0.0.sql $(pg_config --sharedir)/extension/ sudo cp pgmorbac--0.1.0.sql $(pg_config --sharedir)/extension/
# Enable in PostgreSQL # Enable in PostgreSQL
psql -d mydb -c "CREATE EXTENSION pgmorbac;" psql -d mydb -c "CREATE EXTENSION pgmorbac;"
@@ -218,7 +218,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 +238,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
+253 -133
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,78 @@ 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 |
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.
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality)
SELECT org_id, :role_id, 'read', 'documents', 'permission'
FROM morbac.get_org_scope(:org_id, 'subtree', 2);
```
**Example: Root org only**
A compliance rule attached to the top-level org, regardless of where you start:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality)
SELECT org_id, :role_id, 'audit', 'all_data', 'permission'
FROM morbac.get_org_scope(:any_child_org_id, 'root');
```
**Example: All ancestors (upward propagation)**
A report created in a child org becomes visible to all parent orgs:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality)
SELECT org_id, :role_id, 'read', 'reports', 'permission'
FROM morbac.get_org_scope(:child_org_id, 'ancestors');
```
For cross-organization access between unrelated orgs, use `cross_org_rules` and `admin_rules`.
### Hierarchies ### Hierarchies
@@ -495,7 +474,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 +515,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 +566,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 +578,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 13.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter.
**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 +794,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 +841,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,7 +851,7 @@ 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. 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).
```sql ```sql
SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents'); SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
@@ -754,6 +874,8 @@ SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
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,7 +892,9 @@ 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.
@@ -778,10 +902,6 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2);
**`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')`.
@@ -1022,7 +1142,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": "0.1.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"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# pgmorbac extension # pgmorbac extension
# Multi-OrBAC access control model for PostgreSQL # Multi-OrBAC access control model for PostgreSQL
comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support' comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support'
default_version = '1.0.0' default_version = '0.1.0'
relocatable = false relocatable = false
schema = morbac schema = morbac
+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;
-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();
+159 -53
View File
@@ -10,6 +10,12 @@
-- - 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)
--
-- Scope:
-- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...).
-- Evaluated at query time via org_in_scope() — new orgs are covered automatically.
-- - 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).
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 +26,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 = 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 +138,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 +146,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 +158,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 = 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 +224,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 +245,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;
+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();
+40
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
@@ -64,6 +71,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
@@ -131,6 +139,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id U
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 +166,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 +190,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 +214,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 +238,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 +316,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 +331,29 @@ $$;
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
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). 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';
+49 -15
View File
@@ -48,6 +48,30 @@ $$;
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';
CREATE OR REPLACE FUNCTION morbac.current_target_user_id()
RETURNS UUID
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_user_id TEXT;
BEGIN
v_user_id := current_setting('morbac.target_user_id', TRUE);
IF v_user_id IS NULL OR v_user_id = '' THEN
RETURN NULL;
END IF;
RETURN v_user_id::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
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"]' -- Set via: SET morbac.org_ids = '["uuid1","uuid2"]'
CREATE OR REPLACE FUNCTION morbac.current_org_ids() CREATE OR REPLACE FUNCTION morbac.current_org_ids()
RETURNS UUID[] RETURNS UUID[]
@@ -77,6 +101,7 @@ 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
@@ -93,20 +118,23 @@ 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. -- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
-- Pass the row org_id column to enable scoping: rls_check('read', 'docs', org_id) -- User scoping: morbac.target_user_id filters rows to a specific user.
-- Pass row columns to enable scoping: rls_check('read', 'docs', org_id, user_id)
CREATE OR REPLACE FUNCTION morbac.rls_check( CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT, p_activity TEXT,
p_view TEXT, p_view TEXT,
p_row_org_id UUID DEFAULT NULL p_row_org_id UUID DEFAULT NULL,
p_row_user_id UUID DEFAULT NULL
) )
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
AS $$ AS $$
DECLARE DECLARE
v_user_id UUID; v_user_id UUID;
v_org_id UUID; v_org_id UUID;
v_org_ids UUID[]; v_org_ids UUID[];
v_target_user_id UUID;
BEGIN BEGIN
v_user_id := morbac.current_user_id(); v_user_id := morbac.current_user_id();
@@ -114,6 +142,14 @@ 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;
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
@@ -126,19 +162,17 @@ BEGIN
v_org_ids := morbac.current_org_ids(); v_org_ids := morbac.current_org_ids();
IF v_org_ids IS NOT NULL THEN IF v_org_ids IS NOT NULL 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 NOT NULL AND NOT (p_row_org_id = ANY(v_org_ids)) THEN
RETURN FALSE; RETURN FALSE;
END IF; END IF;
-- p_row_org_id NULL: global row — is_allowed(NULL) checks global_rules only
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END IF; END IF;
IF p_row_org_id IS NOT NULL THEN -- No org context: use row's org (or NULL for global rows — global_rules only)
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
END IF;
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: checks if current user is allowed to perform activity on view. Pass row org_id for org scoping (single org, org list, or all orgs). Pass row user_id to filter by morbac.target_user_id session variable.';
+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.';
+16 -2
View File
@@ -1,4 +1,15 @@
-- 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
-- Evaluated at query time via org_in_scope() — new orgs are picked up automatically.
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 +19,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'))
); );
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 +40,7 @@ INCLUDE (role_id, context_id)
WHERE is_active = true; WHERE is_active = true;
COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
COMMENT ON COLUMN morbac.rules.scope IS 'Org scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root. Evaluated at query time — new orgs are covered automatically.';
COMMENT ON COLUMN morbac.rules.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')));
+33
View File
@@ -0,0 +1,33 @@
-- 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.
CREATE TABLE morbac.user_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
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)
);
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';
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.';
+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;
+46 -72
View File
@@ -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
@@ -219,77 +221,49 @@ INSERT INTO morbac.contexts (name, description, evaluator) VALUES
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
@@ -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)'
+116
View File
@@ -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
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
+7 -4
View File
@@ -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',
-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 ==='
+13 -75
View File
@@ -1,5 +1,5 @@
-- ============================================================================= -- =============================================================================
-- 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:
-- --
@@ -10,17 +10,15 @@
-- 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 '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -327,70 +325,10 @@ 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)
-- 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) -- 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)
@@ -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 ==='
+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 ==='
+215
View File
@@ -0,0 +1,215 @@
-- =============================================================================
-- rls_check Tests
-- =============================================================================
-- Tests morbac.rls_check() with all session-org combinations, focusing on
-- the two cases fixed to support global rows (p_row_org_id IS NULL):
--
-- 1. No user_id set: always FALSE
-- 2. Single org context
-- a. org-scoped row, matching org
-- b. org-scoped row, different org (blocked)
-- c. global row (NULL org_id): uses session org
-- 3. org_ids filter
-- a. org-scoped row in list
-- b. org-scoped row not in list (blocked)
-- c. global row + global permission [was FALSE, now TRUE]
-- d. global row + global prohibition [was FALSE, now correctly FALSE]
-- e. global row + no rule [was FALSE, still FALSE]
-- 4. No org context
-- a. org-scoped row: uses row's org
-- b. global row + global permission [was FALSE, now TRUE]
-- c. global row + global prohibition [was FALSE, now correctly FALSE]
-- d. global row + no rule [was FALSE, still FALSE]
--
-- 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: global row — uses session org, so Karl's user_rule on GlobalTech applies
SELECT morbac.t('rls_check single org, global row (NULL org_id): uses session org',
morbac.rls_check('read', 'documents', NULL),
TRUE);
-- 2c (no permission): Karl has no rule for contracts in GlobalTech
SELECT morbac.t('rls_check single org, global row (NULL org_id): no permission for contracts',
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: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules 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('rls_check org_ids, 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';
-- 3d: 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 org_ids, 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';
-- 3e: global row + no rule
SELECT morbac.t('rls_check org_ids, global 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: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules 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('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 ==='
+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}"