Compare commits
11 Commits
80be5f913c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dd1026c5a | |||
| 3ee1e426a8 | |||
| 14592b91ae | |||
| 499df68bf5 | |||
| 97a13bfeb3 | |||
| 4eefc0897f | |||
| e653b38b88 | |||
| 0e3b90af22 | |||
| f64cd73159 | |||
| 5ef8bae1b4 | |||
| 8273d56d4e |
+34
@@ -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
@@ -15,4 +15,8 @@ Thumbs.db
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
# Release tooling
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
package-lock.json
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 1–3.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter.
|
||||||
|
|
||||||
|
**Ruleset:** Define permissions for system principals via `global_rules` at deploy time. Those rows are immutable once inserted — no one can modify or delete them. Use `activity=NULL, view=NULL` to grant full access, or restrict to specific activities/views:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Register the service account (DB owner only)
|
||||||
|
INSERT INTO morbac.system_principals (user_id, description)
|
||||||
|
VALUES (:service_uuid, 'Backend API worker');
|
||||||
|
|
||||||
|
-- Grant full access (immutable after insert)
|
||||||
|
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
|
||||||
|
VALUES (:service_uuid, NULL, NULL,
|
||||||
|
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
|
||||||
|
|
||||||
|
-- Or restrict to specific operations
|
||||||
|
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
|
||||||
|
VALUES (:service_uuid, 'read', NULL,
|
||||||
|
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access control on the registry itself:** `morbac.system_principals` has a SELECT-only RLS policy — a user needs `is_allowed(..., 'read', 'system_principals')` to list them. INSERT/UPDATE/DELETE have no RLS policy, so they are blocked for all non-superusers automatically. Only the database owner can register or remove system principals.
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
|
||||||
|
Admin operations use the same `is_allowed()` engine as everything else — no separate code path.
|
||||||
|
|
||||||
|
**System table RLS**
|
||||||
|
|
||||||
|
`morbac.*` tables have RLS policies. `is_allowed()` and all its internal callees are `SECURITY DEFINER`, running as the extension owner and bypassing RLS. This breaks the recursion: RLS policies call `is_allowed()`, which queries morbac tables without re-triggering the policies.
|
||||||
|
|
||||||
|
The database owner (superuser) bypasses RLS by default — use that privilege only during bootstrap.
|
||||||
|
|
||||||
|
**System view names**
|
||||||
|
|
||||||
|
The extension seeds built-in activities (`create`, `read`, `update`, `delete`) and system view names (`orgs`, `roles`, `rules`, `user_roles`, `contexts`, `activities`, `views`, `delegations`, `cross_org_rules`, `user_rules`, `global_rules`, `system_principals`) at install time.
|
||||||
|
|
||||||
|
These names are config-driven. Override with `morbac.set_config()` to use your own naming conventions — the new name must then exist in `morbac.views` and your rules must reference it:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Rename 'rules' to 'policies' in your system
|
||||||
|
SELECT morbac.set_config('system_view.rules', 'policies');
|
||||||
|
INSERT INTO morbac.views (name, description) VALUES ('policies', 'Authorization policies');
|
||||||
|
-- Your rules must now grant 'create'/'delete'/... on 'policies', not 'rules'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Granting access to system tables**
|
||||||
|
|
||||||
|
Insert rules like any other rule:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
|
||||||
|
SELECT o.id, r.id, v.activity, v.view, c.id, 'permission'
|
||||||
|
FROM (VALUES
|
||||||
|
('manager', 'create', 'rules'),
|
||||||
|
('manager', 'delete', 'rules'),
|
||||||
|
('hr_manager', 'create', 'user_roles'),
|
||||||
|
('hr_manager', 'delete', 'user_roles')
|
||||||
|
) AS v(role_name, activity, view)
|
||||||
|
JOIN morbac.orgs o ON o.name = 'Acme Corp'
|
||||||
|
JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
|
||||||
|
JOIN morbac.contexts c ON c.name = 'always';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bootstrapping the first organization**
|
||||||
|
|
||||||
|
As the database owner (bypasses RLS):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO morbac.orgs (name) VALUES ('Acme Corp');
|
||||||
|
|
||||||
|
INSERT INTO morbac.roles (org_id, name)
|
||||||
|
SELECT id, 'superuser' FROM morbac.orgs WHERE name = 'Acme Corp';
|
||||||
|
|
||||||
|
-- Grant full access to all system views
|
||||||
|
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
|
||||||
|
SELECT o.id, r.id, a.activity, v.view, c.id, 'permission'
|
||||||
|
FROM morbac.orgs o
|
||||||
|
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'superuser'
|
||||||
|
JOIN morbac.contexts c ON c.name = 'always'
|
||||||
|
CROSS JOIN unnest(ARRAY['create','read','update','delete']) AS a(activity)
|
||||||
|
CROSS JOIN unnest(ARRAY['orgs','roles','rules','user_roles','contexts','activities','views','delegations','cross_org_rules']) AS v(view)
|
||||||
|
WHERE o.name = 'Acme Corp';
|
||||||
|
|
||||||
|
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
|
||||||
|
SELECT 'your-user-uuid'::uuid, r.id, o.id
|
||||||
|
FROM morbac.orgs o JOIN morbac.roles r ON r.org_id = o.id
|
||||||
|
WHERE o.name = 'Acme Corp' AND r.name = 'superuser';
|
||||||
|
```
|
||||||
|
|
||||||
|
After this, that user can manage the system through the application without database owner access.
|
||||||
|
|
||||||
### Temporal Constraints
|
### Temporal Constraints
|
||||||
|
|
||||||
Rules can have time-based validity periods.
|
Rules can have time-based validity periods.
|
||||||
@@ -673,7 +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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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}`);
|
||||||
@@ -23,3 +23,10 @@ CREATE INDEX idx_activity_hierarchy_junior ON morbac.activity_hierarchy(junior_a
|
|||||||
COMMENT ON TABLE morbac.activity_hierarchy IS 'Activity hierarchy - senior activities imply junior activities';
|
COMMENT ON TABLE morbac.activity_hierarchy IS 'Activity hierarchy - senior activities imply junior activities';
|
||||||
COMMENT ON COLUMN morbac.activity_hierarchy.senior_activity IS 'Senior activity (implies junior)';
|
COMMENT ON COLUMN morbac.activity_hierarchy.senior_activity IS 'Senior activity (implies junior)';
|
||||||
COMMENT ON COLUMN morbac.activity_hierarchy.junior_activity IS 'Junior activity (implied by senior)';
|
COMMENT ON COLUMN morbac.activity_hierarchy.junior_activity IS 'Junior activity (implied by senior)';
|
||||||
|
|
||||||
|
INSERT INTO morbac.activities (name, description) VALUES
|
||||||
|
('create', 'Create new entities'),
|
||||||
|
('read', 'Read or list entities'),
|
||||||
|
('update', 'Modify existing entities'),
|
||||||
|
('delete', 'Remove entities')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|||||||
@@ -1,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';
|
|
||||||
@@ -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.';
|
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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.';
|
||||||
|
|||||||
@@ -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
@@ -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.';
|
||||||
|
|||||||
@@ -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
@@ -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.';
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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')));
|
||||||
@@ -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.';
|
||||||
@@ -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
@@ -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)'
|
||||||
|
|||||||
@@ -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
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 ==='
|
|
||||||
@@ -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
@@ -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 ==='
|
||||||
|
|||||||
@@ -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 ==='
|
||||||
@@ -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 ==='
|
||||||
@@ -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 ==='
|
||||||
@@ -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 ==='
|
||||||
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user