feat(all): add scope handling in rules

This commit is contained in:
2026-04-01 23:07:20 +02:00
parent 8273d56d4e
commit 5ef8bae1b4
26 changed files with 1129 additions and 1330 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ PROJECT_FILENAME = pgmorbac
PROJECT_VERSION = $(shell ./tools/get_version.sh $(PROJECT_FILENAME).control) PROJECT_VERSION = $(shell ./tools/get_version.sh $(PROJECT_FILENAME).control)
# Docker configuration # Docker configuration
DOCKER_CONTAINER ?= postgres DOCKER_CONTAINER ?= pgmorbac_postgres_test
DOCKER_PORT ?= 5432 DOCKER_PORT ?= 5432
# For development, work on source files in src/ # For development, work on source files in src/
-378
View File
@@ -1,378 +0,0 @@
# Administration Guide
This guide explains how to set up organization-scoped administrators without relying on database superadmins.
## Concept
Multi-OrBAC allows you to delegate administrative capabilities to specific roles within each organization. This means:
- Organization administrators can manage users, roles, and policies within their own organization
- No need for database-level superadmin access for day-to-day administration
- Fine-grained control over what each admin role can do
- Admins cannot affect other organizations
## Administration Rules
The `morbac.admin_rules` table defines administrative capabilities for specific roles:
**Key columns:**
- `org_id`, `role_id`: Role receiving capabilities
- `admin_activity`: Action type (e.g., 'manage', 'assign_role')
- `admin_target`: Target type (e.g., 'policies', 'roles', specific role name, '*' for wildcard)
- `modality`: Permission or prohibition
- `context_id`: Optional conditional evaluation
## Common Admin Patterns
### 1. Organization Administrator
Full admin within their organization:
```sql
-- Create admin role
INSERT INTO morbac.roles (org_id, name, description)
SELECT id, 'org_admin', 'Organization administrator'
FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant ability to manage policies
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'manage',
'policies',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Grant ability to manage roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'manage',
'roles',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Grant ability to assign ALL roles (wildcard)
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
'*',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Assign someone as org admin
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
SELECT
'alice-uuid'::uuid,
r.id,
o.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
```
### 2. HR Manager (User/Role Assignment Only)
Can assign users to roles but cannot modify policies:
```sql
-- Create HR manager role
INSERT INTO morbac.roles (org_id, name, description)
SELECT id, 'hr_manager', 'HR manager - can assign users to roles'
FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant ability to assign specific roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
target_role,
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
CROSS JOIN (VALUES ('employee'), ('manager'), ('contractor')) AS roles(target_role)
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
-- Prohibit assigning admin roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
'org_admin',
'prohibition',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
```
### 3. Security Manager (Policy Management Only)
Can define policies but cannot assign users:
```sql
-- Create security manager role
INSERT INTO morbac.roles (org_id, name, description)
SELECT id, 'security_manager', 'Security manager - can manage policies'
FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant policy management
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'manage',
'policies',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'always')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'security_manager';
```
## Using Admin Functions
### Check Permissions
```sql
-- Check if Alice can manage policies
SELECT morbac.can_manage_policies('alice-uuid'::uuid, org_id);
-- Check if Alice can manage roles
SELECT morbac.can_manage_roles('alice-uuid'::uuid, org_id);
-- Check if Alice can assign a specific role
SELECT morbac.can_manage_user_role('alice-uuid'::uuid, org_id, employee_role_id);
-- General admin check
SELECT morbac.is_admin_allowed('alice-uuid'::uuid, org_id, 'assign_role', 'manager');
```
### Assign/Revoke Roles Safely
```sql
-- Alice (HR manager) assigns Bob to employee role
SELECT morbac.admin_assign_role(
'alice-uuid'::uuid, -- Admin user
'bob-uuid'::uuid, -- Target user
employee_role_id, -- Role to assign
org_id -- Organization
);
-- Alice revokes Bob's employee role
SELECT morbac.admin_revoke_role(
'alice-uuid'::uuid,
'bob-uuid'::uuid,
employee_role_id,
org_id
);
```
These functions automatically:
- Check if Alice has permission to manage the role
- Validate SoD constraints
- Validate cardinality constraints
- Raise exceptions if constraints are violated
## Application Integration
### REST API Example
Your application can expose admin endpoints that use these functions:
```sql
-- Endpoint: POST /api/orgs/:orgId/users/:userId/roles/:roleId
-- Handler checks:
CREATE OR REPLACE FUNCTION app.assign_role_endpoint(
p_requesting_user_id UUID,
p_org_id UUID,
p_target_user_id UUID,
p_role_id UUID
)
RETURNS JSON
LANGUAGE plpgsql
AS $$
DECLARE
v_result JSON;
BEGIN
BEGIN
PERFORM morbac.admin_assign_role(
p_requesting_user_id,
p_target_user_id,
p_role_id,
p_org_id
);
v_result := json_build_object(
'success', true,
'message', 'Role assigned successfully'
);
EXCEPTION WHEN OTHERS THEN
v_result := json_build_object(
'success', false,
'error', SQLERRM
);
END;
RETURN v_result;
END;
$$;
```
### Row-Level Security for Admin Tables
Protect admin configuration with RLS:
```sql
-- Only org admins can see admin rules
ALTER TABLE morbac.admin_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY admin_rules_access ON morbac.admin_rules
FOR SELECT
USING (
org_id = morbac.current_org_id()
AND (
-- User is org admin
morbac.can_manage_policies(morbac.current_user_id(), org_id)
OR morbac.can_manage_roles(morbac.current_user_id(), org_id)
)
);
-- Only org admins can modify admin rules
CREATE POLICY admin_rules_modify ON morbac.admin_rules
FOR ALL
USING (
org_id = morbac.current_org_id()
AND morbac.can_manage_policies(morbac.current_user_id(), org_id)
);
```
## Context-Based Admin Rules
You can limit admin actions to specific contexts (e.g., business hours):
```sql
-- Create context for business hours
CREATE OR REPLACE FUNCTION morbac.context_business_hours()
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN EXTRACT(DOW FROM CURRENT_DATE) BETWEEN 1 AND 5
AND EXTRACT(HOUR FROM CURRENT_TIME) BETWEEN 9 AND 17;
END;
$$;
INSERT INTO morbac.contexts (name, description, evaluator) VALUES
('business_hours', 'Monday-Friday 9am-5pm', 'morbac.context_business_hours');
-- HR can only assign roles during business hours
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT
o.id,
r.id,
'assign_role',
'*',
'permission',
(SELECT id FROM morbac.contexts WHERE name = 'business_hours')
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
```
## Best Practices
1. **Principle of Least Privilege**: Grant only necessary admin capabilities to each role
2. **Separation of Duties**: Separate policy management from user assignment
3. **Audit Trail**: Log all admin actions (consider triggers on user_roles, rules tables)
4. **Context-Based**: Use contexts to limit when admin actions can occur
5. **Multiple Admins**: Use role cardinality to ensure multiple admins exist
6. **Prohibitions**: Use prohibition admin rules to explicitly deny certain actions
## Example: Complete Setup
```sql
-- 1. Create organization
INSERT INTO morbac.orgs (name) VALUES ('Acme Corp');
-- 2. Create roles
INSERT INTO morbac.roles (org_id, name)
SELECT id, name FROM morbac.orgs,
(VALUES ('org_admin'), ('hr_manager'), ('employee'), ('manager')) AS roles(name)
WHERE morbac.orgs.name = 'Acme Corp';
-- 3. Set up org_admin with full permissions
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT o.id, r.id, activity, target, 'permission', c.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
CROSS JOIN (VALUES
('manage', 'policies'),
('manage', 'roles'),
('assign_role', '*')
) AS perms(activity, target)
CROSS JOIN morbac.contexts c
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin' AND c.name = 'always';
-- 4. Set up hr_manager with limited permissions
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT o.id, r.id, 'assign_role', role_name, 'permission', c.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
CROSS JOIN (VALUES ('employee'), ('manager')) AS assignable(role_name)
CROSS JOIN morbac.contexts c
WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager' AND c.name = 'always';
-- 5. Assign Alice as org_admin
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
SELECT 'alice-uuid'::uuid, r.id, o.id
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- 6. Now Alice can assign Bob as hr_manager
SELECT morbac.admin_assign_role(
'alice-uuid'::uuid,
'bob-uuid'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'hr_manager' AND org_id =
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')),
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')
);
-- 7. Now Bob can assign users to employee/manager roles
SELECT morbac.admin_assign_role(
'bob-uuid'::uuid,
'charlie-uuid'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id =
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')),
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')
);
```
## Summary
Multi-OrBAC provides complete delegation of administrative capabilities:
- **No superadmins needed**: for day-to-day operations
- **Organization-scoped**: admins only affect their own org
- **Fine-grained**: control exactly what each admin role can do
- **Safe**: automatic constraint validation (SoD, cardinality)
- **Auditable**: all actions go through tracked functions
- **Context-aware**: limit when admin actions can occur
+130 -129
View File
@@ -115,9 +115,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 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
@@ -181,25 +180,13 @@ Dynamically computed roles via custom functions.
Inter-organizational access rules. 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`: Rule 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**
Administration rules for delegated management.
**Key columns:**
- `org_id`, `role_id`: Role receiving admin capabilities
- `admin_activity`: Admin action (e.g., `create_rule`, `assign_role`, `delete_rule`)
- `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.
**morbac.activity_view_bindings** **morbac.activity_view_bindings**
@@ -322,118 +309,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 rules, 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 rule that covers an 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 +442,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
@@ -587,11 +534,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 +546,85 @@ 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** ### Administration
HR can assign users to roles without being a superuser: 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`) 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 ```sql
-- Grant HR the ability to assign roles -- Rename 'rules' to 'policies' in your system
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) SELECT morbac.set_config('system_view.rules', 'policies');
VALUES (org_id, hr_manager_role_id, 'assign_role', 'roles', INSERT INTO morbac.views (name, description) VALUES ('policies', 'Authorization policies');
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); -- Your rules must now grant 'create'/'delete'/... on 'policies', not 'rules'
-- Check if HR user can assign roles
SELECT morbac.is_admin_allowed(hr_user_id, org_id, 'assign_role', 'roles'); -- TRUE
``` ```
**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 +675,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 +722,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
@@ -754,6 +755,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 +773,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 +783,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 +1023,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 | Admin rules | | **Audit** | Application layer | Native audit log with field-level change tracking |
--- ---
+7
View File
@@ -23,3 +23,10 @@ CREATE INDEX idx_activity_hierarchy_junior ON morbac.activity_hierarchy(junior_a
COMMENT ON TABLE morbac.activity_hierarchy IS 'Activity hierarchy - senior activities imply junior activities'; COMMENT ON TABLE morbac.activity_hierarchy IS 'Activity hierarchy - senior activities imply junior activities';
COMMENT ON COLUMN morbac.activity_hierarchy.senior_activity IS 'Senior activity (implies junior)'; COMMENT ON COLUMN morbac.activity_hierarchy.senior_activity IS 'Senior activity (implies junior)';
COMMENT ON COLUMN morbac.activity_hierarchy.junior_activity IS 'Junior activity (implied by senior)'; COMMENT ON COLUMN morbac.activity_hierarchy.junior_activity IS 'Junior activity (implied by senior)';
INSERT INTO morbac.activities (name, description) VALUES
('create', 'Create new entities'),
('read', 'Read or list entities'),
('update', 'Modify existing entities'),
('delete', 'Remove entities')
ON CONFLICT (name) DO NOTHING;
-148
View File
@@ -1,148 +0,0 @@
CREATE OR REPLACE FUNCTION morbac.can_manage_user_role(
p_admin_user_id UUID,
p_org_id UUID,
p_target_role_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_role_name TEXT;
BEGIN
SELECT name INTO v_role_name
FROM morbac.roles
WHERE id = p_target_role_id AND org_id = p_org_id;
IF v_role_name IS NULL THEN
RETURN FALSE;
END IF;
RETURN morbac.is_admin_allowed(
p_admin_user_id,
p_org_id,
'assign_role',
v_role_name
);
END;
$$;
COMMENT ON FUNCTION morbac.can_manage_user_role(UUID, UUID, UUID) IS
'Check if user can assign/revoke a specific role in organization';
CREATE OR REPLACE FUNCTION morbac.can_manage_roles(
p_user_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN morbac.is_admin_allowed(
p_user_id,
p_org_id,
'manage',
'roles'
);
END;
$$;
COMMENT ON FUNCTION morbac.can_manage_roles(UUID, UUID) IS
'Check if user can create/modify/delete roles in organization';
CREATE OR REPLACE FUNCTION morbac.can_manage_policies(
p_user_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
RETURN morbac.is_admin_allowed(
p_user_id,
p_org_id,
'manage',
'policies'
);
END;
$$;
COMMENT ON FUNCTION morbac.can_manage_policies(UUID, UUID) IS
'Check if user can manage policies in organization';
CREATE OR REPLACE FUNCTION morbac.admin_assign_role(
p_admin_user_id UUID,
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT morbac.can_manage_user_role(p_admin_user_id, p_org_id, p_role_id) THEN
RAISE EXCEPTION 'User % does not have permission to assign role % in org %',
p_admin_user_id, p_role_id, p_org_id;
END IF;
IF morbac.check_sod_violation(p_target_user_id, p_role_id, p_org_id) THEN
RAISE EXCEPTION 'Role assignment would violate Separation of Duty constraints';
END IF;
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (p_target_user_id, p_role_id, p_org_id)
ON CONFLICT (user_id, role_id, org_id) DO NOTHING;
DECLARE
v_cardinality_error TEXT;
BEGIN
v_cardinality_error := morbac.check_cardinality_violation(p_role_id);
IF v_cardinality_error IS NOT NULL THEN
RAISE EXCEPTION 'Role assignment violates cardinality constraint: %', v_cardinality_error;
END IF;
END;
RETURN TRUE;
END;
$$;
COMMENT ON FUNCTION morbac.admin_assign_role(UUID, UUID, UUID, UUID) IS
'Assign role to user with admin permission check and constraint validation';
CREATE OR REPLACE FUNCTION morbac.admin_revoke_role(
p_admin_user_id UUID,
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT morbac.can_manage_user_role(p_admin_user_id, p_org_id, p_role_id) THEN
RAISE EXCEPTION 'User % does not have permission to revoke role % in org %',
p_admin_user_id, p_role_id, p_org_id;
END IF;
DELETE FROM morbac.user_roles
WHERE user_id = p_target_user_id
AND role_id = p_role_id
AND org_id = p_org_id;
DECLARE
v_cardinality_error TEXT;
BEGIN
v_cardinality_error := morbac.check_cardinality_violation(p_role_id, FALSE);
IF v_cardinality_error IS NOT NULL THEN
RAISE WARNING 'Role revocation may violate cardinality constraint: %', v_cardinality_error;
END IF;
END;
RETURN TRUE;
END;
$$;
COMMENT ON FUNCTION morbac.admin_revoke_role(UUID, UUID, UUID, UUID) IS
'Revoke role from user with admin permission check';
-20
View File
@@ -1,20 +0,0 @@
-- Meta-policies defining who can create/modify policies (AdministrationPermission)
CREATE TABLE morbac.admin_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
admin_activity TEXT NOT NULL,
admin_target TEXT NOT NULL,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, admin_activity, admin_target, context_id, modality)
);
CREATE INDEX idx_admin_rules_org_role ON morbac.admin_rules(org_id, role_id);
COMMENT ON TABLE morbac.admin_rules IS 'Administration rules - meta-policies for policy management';
COMMENT ON COLUMN morbac.admin_rules.admin_activity IS 'Admin action: create_rule, modify_rule, delete_rule, assign_role, etc.';
COMMENT ON COLUMN morbac.admin_rules.admin_target IS 'What can be administered: rules, roles, users, orgs, etc.';
+21
View File
@@ -59,6 +59,7 @@ COMMENT ON FUNCTION morbac.cleanup_auth_cache() IS
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change() CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_org_id UUID; v_org_id UUID;
@@ -104,6 +105,7 @@ FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_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 +125,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();
+11 -4
View File
@@ -10,6 +10,11 @@
-- - 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.
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,6 +25,7 @@ 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;
@@ -30,11 +36,11 @@ BEGIN
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 = 'prohibition' AND r.modality = 'prohibition'
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
@@ -68,11 +74,11 @@ BEGIN
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
@@ -129,6 +135,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;
+14 -1
View File
@@ -10,12 +10,25 @@ COMMENT ON TABLE morbac.config IS 'Extension configuration - edit values to cust
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');
CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT) CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT)
RETURNS TEXT RETURNS TEXT
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_value TEXT; v_value TEXT;
+1
View File
@@ -38,6 +38,7 @@ CREATE OR REPLACE FUNCTION morbac.eval_context(p_context_id UUID)
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_evaluator REGPROC; v_evaluator REGPROC;
+6 -3
View File
@@ -1,4 +1,7 @@
-- Rules that grant access across organization boundaries -- Rules that grant access across organization boundaries.
--
-- source_org_id: the org where the user must hold role_id.
-- target_org_id: the org where the resource resides.
CREATE TABLE morbac.cross_org_rules ( CREATE TABLE morbac.cross_org_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -24,5 +27,5 @@ CREATE INDEX idx_cross_org_rules_target ON morbac.cross_org_rules(target_org_id)
CREATE INDEX idx_cross_org_rules_temporal ON morbac.cross_org_rules(valid_from, valid_until); CREATE INDEX idx_cross_org_rules_temporal ON morbac.cross_org_rules(valid_from, valid_until);
COMMENT ON TABLE morbac.cross_org_rules IS 'Inter-organizational rules for cross-org access'; COMMENT ON TABLE morbac.cross_org_rules IS 'Inter-organizational rules for cross-org access';
COMMENT ON COLUMN morbac.cross_org_rules.source_org_id IS 'Organization where user has role'; COMMENT ON COLUMN morbac.cross_org_rules.source_org_id IS 'Org where user holds the role';
COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Organization where resource resides'; COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Org where the resource resides';
+40
View File
@@ -1,9 +1,15 @@
-- Transitive closure and effective role/activity/view lookup functions -- Transitive closure and effective role/activity/view lookup functions
--
-- All functions that query morbac tables are SECURITY DEFINER so they run
-- as the extension owner and bypass RLS on morbac tables. This prevents
-- infinite recursion when RLS policies call is_allowed(), which in turn
-- calls these functions.
CREATE OR REPLACE FUNCTION morbac.get_org_ancestors(p_org_id UUID) CREATE OR REPLACE FUNCTION morbac.get_org_ancestors(p_org_id UUID)
RETURNS TABLE(org_id UUID, depth INTEGER) RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -26,6 +32,7 @@ CREATE OR REPLACE FUNCTION morbac.get_org_descendants(p_org_id UUID)
RETURNS TABLE(org_id UUID, depth INTEGER) RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -64,6 +71,7 @@ CREATE OR REPLACE FUNCTION morbac.get_org_scope(
RETURNS TABLE(org_id UUID, depth INTEGER) RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
CASE p_scope CASE p_scope
@@ -131,6 +139,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id U
RETURNS TABLE(role_id UUID, depth INTEGER) RETURNS TABLE(role_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -157,6 +166,7 @@ CREATE OR REPLACE FUNCTION morbac.get_inherited_roles(p_role_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER) RETURNS TABLE(role_id UUID, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -180,6 +190,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_activities(p_activity TEXT)
RETURNS TABLE(activity TEXT, depth INTEGER) RETURNS TABLE(activity TEXT, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -203,6 +214,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_views(p_view TEXT)
RETURNS TABLE(view TEXT, depth INTEGER) RETURNS TABLE(view TEXT, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -226,6 +238,7 @@ CREATE OR REPLACE FUNCTION morbac.get_comprehensive_roles(p_user_id UUID, p_org_
RETURNS TABLE(role_id UUID, source TEXT, depth INTEGER) RETURNS TABLE(role_id UUID, source TEXT, depth INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
RETURN QUERY RETURN QUERY
@@ -303,6 +316,7 @@ CREATE OR REPLACE FUNCTION morbac.eval_derived_role(
RETURNS BOOLEAN RETURNS BOOLEAN
LANGUAGE plpgsql LANGUAGE plpgsql
STABLE STABLE
SECURITY DEFINER
AS $$ AS $$
DECLARE DECLARE
v_result BOOLEAN; v_result BOOLEAN;
@@ -317,3 +331,29 @@ $$;
COMMENT ON FUNCTION morbac.eval_derived_role(REGPROC, UUID, UUID) IS COMMENT ON FUNCTION morbac.eval_derived_role(REGPROC, UUID, UUID) IS
'Evaluates a derived role condition function'; 'Evaluates a derived role condition function';
-- Check if p_target_org_id falls within the scope of p_rule_org_id.
-- Used by the authorization engine to evaluate scoped rules at query time.
CREATE OR REPLACE FUNCTION morbac.org_in_scope(
p_target_org_id UUID,
p_rule_org_id UUID,
p_scope TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
AS $$
BEGIN
IF p_scope = 'self' THEN
RETURN p_target_org_id = p_rule_org_id;
END IF;
RETURN EXISTS(
SELECT 1 FROM morbac.get_org_scope(p_rule_org_id, p_scope)
WHERE org_id = p_target_org_id
);
END;
$$;
COMMENT ON FUNCTION morbac.org_in_scope(UUID, UUID, TEXT) IS
'Returns TRUE if p_target_org_id is within get_org_scope(p_rule_org_id, p_scope). SECURITY DEFINER to bypass RLS on morbac.orgs.';
-142
View File
@@ -1,142 +0,0 @@
-- Simplified policy declaration using names instead of UUIDs
CREATE TABLE morbac.policy (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_name TEXT NOT NULL,
role_name TEXT NOT NULL,
activity TEXT NOT NULL,
view TEXT NOT NULL,
modality morbac.modality NOT NULL,
context_name TEXT NOT NULL DEFAULT 'always',
priority INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
compiled BOOLEAN NOT NULL DEFAULT FALSE
);
-- NULL priority treated as -1 so two NULL-priority rows for the same tuple conflict
CREATE UNIQUE INDEX idx_policy_unique
ON morbac.policy(org_name, role_name, activity, view, modality, context_name, COALESCE(priority, -1));
CREATE INDEX idx_policy_not_compiled ON morbac.policy(compiled) WHERE NOT compiled;
COMMENT ON TABLE morbac.policy IS 'Policy DSL - simplified policy declaration using names';
COMMENT ON COLUMN morbac.policy.compiled IS 'Whether this policy entry has been compiled into rules';
COMMENT ON COLUMN morbac.policy.priority IS 'Optional rule priority (higher wins over lower; NULL = 0)';
CREATE OR REPLACE FUNCTION morbac.is_admin_allowed(
p_user_id UUID,
p_org_id UUID,
p_admin_activity TEXT,
p_admin_target TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_rule RECORD;
BEGIN
FOR v_rule IN
SELECT ar.context_id
FROM morbac.admin_rules ar
WHERE ar.org_id = p_org_id
AND ar.admin_activity = p_admin_activity
AND ar.admin_target = p_admin_target
AND ar.modality = 'prohibition'
AND ar.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
)
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
RETURN FALSE;
END IF;
END LOOP;
FOR v_rule IN
SELECT ar.context_id
FROM morbac.admin_rules ar
WHERE ar.org_id = p_org_id
AND ar.admin_activity = p_admin_activity
AND ar.admin_target = p_admin_target
AND ar.modality = 'permission'
AND ar.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
)
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
RETURN TRUE;
END IF;
END LOOP;
RETURN FALSE;
END;
$$;
COMMENT ON FUNCTION morbac.is_admin_allowed(UUID, UUID, TEXT, TEXT) IS
'Checks administration permissions for policy management operations';
-- Translates policy DSL entries into concrete rules by resolving names to IDs.
-- Idempotent - safe to run multiple times.
CREATE OR REPLACE FUNCTION morbac.compile_policy()
RETURNS TABLE(
compiled_count INTEGER,
error_count INTEGER,
errors TEXT[]
)
LANGUAGE plpgsql
AS $$
DECLARE
v_policy RECORD;
v_org_id UUID;
v_role_id UUID;
v_context_id UUID;
v_compiled INTEGER := 0;
v_errors TEXT[] := ARRAY[]::TEXT[];
v_error_count INTEGER := 0;
BEGIN
FOR v_policy IN
SELECT * FROM morbac.policy WHERE NOT compiled
LOOP
BEGIN
SELECT id INTO STRICT v_org_id
FROM morbac.orgs
WHERE name = v_policy.org_name;
SELECT id INTO STRICT v_role_id
FROM morbac.roles
WHERE org_id = v_org_id AND name = v_policy.role_name;
SELECT id INTO STRICT v_context_id
FROM morbac.contexts
WHERE name = v_policy.context_name;
INSERT INTO morbac.activities (name)
VALUES (v_policy.activity)
ON CONFLICT (name) DO NOTHING;
INSERT INTO morbac.views (name)
VALUES (v_policy.view)
ON CONFLICT (name) DO NOTHING;
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, priority)
VALUES (v_org_id, v_role_id, v_policy.activity, v_policy.view, v_context_id, v_policy.modality, v_policy.priority)
ON CONFLICT (org_id, role_id, activity, view, context_id, modality) DO NOTHING;
UPDATE morbac.policy SET compiled = TRUE WHERE id = v_policy.id;
v_compiled := v_compiled + 1;
EXCEPTION WHEN OTHERS THEN
v_error_count := v_error_count + 1;
v_errors := array_append(v_errors,
format('Policy %s: %s', v_policy.id, SQLERRM));
END;
END LOOP;
RETURN QUERY SELECT v_compiled, v_error_count, v_errors;
END;
$$;
COMMENT ON FUNCTION morbac.compile_policy() IS
'Compiles policy DSL entries into concrete rules - idempotent and safe to run multiple times';
+1
View File
@@ -77,6 +77,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
+63
View File
@@ -0,0 +1,63 @@
-- assign_role / revoke_role: convenience wrappers that enforce SoD and
-- cardinality constraints. Access control is handled by RLS on morbac.user_roles.
CREATE OR REPLACE FUNCTION morbac.assign_role(
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
IF morbac.check_sod_violation(p_target_user_id, p_role_id, p_org_id) THEN
RAISE EXCEPTION 'Role assignment would violate Separation of Duty constraints';
END IF;
-- INSERT triggers RLS policy on morbac.user_roles (create on user_roles view)
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (p_target_user_id, p_role_id, p_org_id)
ON CONFLICT (user_id, role_id, org_id) DO NOTHING;
DECLARE
v_error TEXT;
BEGIN
v_error := morbac.check_cardinality_violation(p_role_id);
IF v_error IS NOT NULL THEN
RAISE EXCEPTION 'Role assignment violates cardinality constraint: %', v_error;
END IF;
END;
END;
$$;
COMMENT ON FUNCTION morbac.assign_role(UUID, UUID, UUID) IS
'Assign role with SoD and cardinality validation. RLS on morbac.user_roles enforces authorization.';
CREATE OR REPLACE FUNCTION morbac.revoke_role(
p_target_user_id UUID,
p_role_id UUID,
p_org_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
-- DELETE triggers RLS policy on morbac.user_roles (delete on user_roles view)
DELETE FROM morbac.user_roles
WHERE user_id = p_target_user_id
AND role_id = p_role_id
AND org_id = p_org_id;
DECLARE
v_error TEXT;
BEGIN
v_error := morbac.check_cardinality_violation(p_role_id, FALSE);
IF v_error IS NOT NULL THEN
RAISE WARNING 'Role revocation may violate cardinality constraint: %', v_error;
END IF;
END;
END;
$$;
COMMENT ON FUNCTION morbac.revoke_role(UUID, UUID, UUID) IS
'Revoke role with cardinality validation. RLS on morbac.user_roles enforces authorization.';
+16 -2
View File
@@ -1,4 +1,15 @@
-- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality) -- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality)
--
-- scope controls which orgs this rule covers relative to org_id:
-- 'self' — exact org only (default, current behavior)
-- 'subtree' — org + all descendants
-- 'descendants' — all descendants, excluding self
-- 'children' — direct children only
-- 'parent' — direct parent only
-- 'ancestors' — all ancestors, excluding self
-- 'lineage' — self + all ancestors
-- 'root' — topmost ancestor only
-- Evaluated at query time via org_in_scope() — new orgs are picked up automatically.
CREATE TABLE morbac.rules ( CREATE TABLE morbac.rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -8,14 +19,16 @@ CREATE TABLE morbac.rules (
view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL, modality morbac.modality NOT NULL,
scope TEXT NOT NULL DEFAULT 'self',
priority INTEGER, priority INTEGER,
valid_from TIMESTAMPTZ, valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ, valid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_active BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}'::jsonb, metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, activity, view, context_id, modality), UNIQUE(org_id, role_id, activity, view, context_id, modality, scope),
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from) CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root'))
); );
CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id); CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id);
@@ -27,6 +40,7 @@ INCLUDE (role_id, context_id)
WHERE is_active = true; WHERE is_active = true;
COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
COMMENT ON COLUMN morbac.rules.scope IS 'Org scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root. Evaluated at query time — new orgs are covered automatically.';
COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.'; COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.';
+181
View File
@@ -0,0 +1,181 @@
-- 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.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')));
+14
View File
@@ -23,3 +23,17 @@ CREATE INDEX idx_view_hierarchy_junior ON morbac.view_hierarchy(junior_view);
COMMENT ON TABLE morbac.view_hierarchy IS 'View hierarchy - senior views inherit from junior views'; COMMENT ON TABLE morbac.view_hierarchy IS 'View hierarchy - senior views inherit from junior views';
COMMENT ON COLUMN morbac.view_hierarchy.senior_view IS 'Senior view (more specific)'; COMMENT ON COLUMN morbac.view_hierarchy.senior_view IS 'Senior view (more specific)';
COMMENT ON COLUMN morbac.view_hierarchy.junior_view IS 'Junior view (more general)'; COMMENT ON COLUMN morbac.view_hierarchy.junior_view IS 'Junior view (more general)';
-- Default system view names — match system_view.* config keys.
-- Override config values to rename; the new name must be seeded here too.
INSERT INTO morbac.views (name, description) VALUES
('orgs', 'Organizations table'),
('roles', 'Roles table'),
('rules', 'Authorization rules table'),
('user_roles', 'User-role assignments table'),
('contexts', 'Rule contexts table'),
('activities', 'Activities table'),
('views', 'Views table'),
('delegations', 'Role delegations table'),
('cross_org_rules', 'Cross-organization rules table')
ON CONFLICT (name) DO NOTHING;
+46 -72
View File
@@ -173,10 +173,11 @@ INSERT INTO morbac.activities (name, description) VALUES
('read', 'Read / view data'), ('read', 'Read / view data'),
('write', 'Create or modify data'), ('write', 'Create or modify data'),
('delete', 'Delete data'), ('delete', 'Delete data'),
('manage', 'Full management access'),
('approve', 'Approve requests or documents'), ('approve', 'Approve requests or documents'),
('export', 'Export data to external format'), ('export', 'Export data to external format'),
('audit', 'Audit trail review'), ('audit', 'Audit trail review')
('manage', 'Full administrative control'); ON CONFLICT (name) DO NOTHING;
INSERT INTO morbac.views (name, description) VALUES INSERT INTO morbac.views (name, description) VALUES
('documents', 'General company documents'), ('documents', 'General company documents'),
@@ -185,7 +186,8 @@ INSERT INTO morbac.views (name, description) VALUES
('hr_data', 'Human resources data'), ('hr_data', 'Human resources data'),
('contracts', 'Legal contracts'), ('contracts', 'Legal contracts'),
('audit_logs', 'System audit logs'), ('audit_logs', 'System audit logs'),
('public_data', 'Publicly accessible data'); ('public_data', 'Publicly accessible data')
ON CONFLICT (name) DO NOTHING;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- CONTEXTS -- CONTEXTS
@@ -219,77 +221,49 @@ INSERT INTO morbac.contexts (name, description, evaluator) VALUES
SELECT name, description FROM morbac.contexts ORDER BY name; SELECT name, description FROM morbac.contexts ORDER BY name;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- POLICIES (via Policy DSL — resolved by compile_policy) -- RULES
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '=== Setup: Policies ===' \echo '=== Setup: Rules ==='
-- GlobalTech HQ policies INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES SELECT o.id, r.id, v.activity, v.view, c.id, v.modality::morbac.modality
-- Intern: read public data only FROM (VALUES
('GlobalTech HQ', 'intern', 'read', 'public_data', 'permission', 'always'), ('GlobalTech HQ', 'intern', 'read', 'public_data', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'read', 'documents', 'always', 'permission'),
-- Employee: read documents and reports; write documents only during business hours ('GlobalTech HQ', 'employee', 'write', 'documents', 'business_hours','permission'),
('GlobalTech HQ', 'employee', 'read', 'documents', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'reports', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'write', 'documents', 'permission', 'business_hours'), ('GlobalTech HQ', 'employee', 'read', 'public_data', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'read', 'reports', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'reports', 'always', 'obligation'),
('GlobalTech HQ', 'employee', 'read', 'public_data', 'permission', 'always'), ('GlobalTech HQ', 'employee', 'read', 'reports', 'end_of_quarter','recommendation'),
('GlobalTech HQ', 'employee', 'read', 'public_data', 'always', 'recommendation'),
-- Obligation: employees must review reports weekly ('GlobalTech HQ', 'manager', 'approve','documents', 'always', 'permission'),
('GlobalTech HQ', 'employee', 'read', 'reports', 'obligation', 'always'), ('GlobalTech HQ', 'manager', 'delete', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'read', 'financial_data', 'always', 'permission'),
-- Manager: approve and delete documents; read financial data ('GlobalTech HQ', 'hr_manager', 'read', 'hr_data', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'approve','documents', 'permission', 'always'), ('GlobalTech HQ', 'hr_manager', 'write', 'hr_data', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'delete', 'documents', 'permission', 'always'), ('GlobalTech HQ', 'hr_manager', 'delete', 'hr_data', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'read', 'financial_data', 'permission', 'always'), ('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'always', 'permission'),
('GlobalTech HQ', 'auditor', 'read', 'financial_data', 'always', 'permission'),
-- HR Manager: full access to HR data ('GlobalTech HQ', 'auditor', 'read', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'read', 'hr_data', 'permission', 'always'), ('GlobalTech HQ', 'accountant', 'read', 'financial_data', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'write', 'hr_data', 'permission', 'always'), ('GlobalTech HQ', 'accountant', 'write', 'financial_data', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'delete', 'hr_data', 'permission', 'always'), ('GlobalTech HQ', 'contractor', 'read', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'contractor', 'read', 'financial_data', 'always', 'prohibition'),
-- Auditor: read audit logs, financial data, and documents ('GlobalTech HQ', 'contractor', 'read', 'hr_data', 'always', 'prohibition'),
('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'permission', 'always'), ('GlobalTech HQ', 'compliance_officer', 'read', 'audit_logs', 'always', 'permission'),
('GlobalTech HQ', 'auditor', 'read', 'financial_data', 'permission', 'always'), ('GlobalTech HQ', 'compliance_officer', 'read', 'financial_data', 'always', 'permission'),
('GlobalTech HQ', 'auditor', 'read', 'documents', 'permission', 'always'), ('Engineering Dept', 'engineer', 'read', 'documents', 'always', 'permission'),
('Engineering Dept', 'engineer', 'write', 'documents', 'always', 'permission'),
-- Accountant: read and write financial data ('Engineering Dept', 'tech_lead', 'approve','documents', 'always', 'permission'),
('GlobalTech HQ', 'accountant', 'read', 'financial_data', 'permission', 'always'), ('Sales Dept', 'sales_rep', 'read', 'documents', 'always', 'permission'),
('GlobalTech HQ', 'accountant', 'write', 'financial_data', 'permission', 'always'), ('Sales Dept', 'sales_rep', 'write', 'contracts', 'always', 'permission'),
('Sales Dept', 'sales_manager', 'read', 'reports', 'always', 'permission'),
-- Contractor: read documents; PROHIBITED from financial and HR data ('Sales Dept', 'sales_manager', 'approve','contracts', 'always', 'permission')
('GlobalTech HQ', 'contractor', 'read', 'documents', 'permission', 'always'), ) AS v(org_name, role_name, activity, view, context_name, modality)
('GlobalTech HQ', 'contractor', 'read', 'financial_data', 'prohibition', 'always'), JOIN morbac.orgs o ON o.name = v.org_name
('GlobalTech HQ', 'contractor', 'read', 'hr_data', 'prohibition', 'always'), JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
JOIN morbac.contexts c ON c.name = v.context_name;
-- Compliance Officer: read audit logs and financial data
('GlobalTech HQ', 'compliance_officer', 'read', 'audit_logs', 'permission', 'always'),
('GlobalTech HQ', 'compliance_officer', 'read', 'financial_data', 'permission', 'always'),
-- Recommendation: staff should review reports end of quarter
-- Note: voided by the obligation above (conflict resolution: obligation > recommendation)
('GlobalTech HQ', 'employee', 'read', 'reports', 'recommendation','end_of_quarter'),
-- Recommendation: staff should stay informed on public data (no conflicting obligation)
('GlobalTech HQ', 'employee', 'read', 'public_data', 'recommendation','always');
-- Engineering Dept policies
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES
('Engineering Dept', 'engineer', 'read', 'documents', 'permission', 'always'),
('Engineering Dept', 'engineer', 'write', 'documents', 'permission', 'always'),
('Engineering Dept', 'tech_lead', 'approve', 'documents', 'permission', 'always');
-- Sales Dept policies
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES
('Sales Dept', 'sales_rep', 'read', 'documents', 'permission', 'always'),
('Sales Dept', 'sales_rep', 'write', 'contracts', 'permission', 'always'),
('Sales Dept', 'sales_manager', 'read', 'reports', 'permission', 'always'),
('Sales Dept', 'sales_manager', 'approve', 'contracts', 'permission', 'always');
-- Compile all policies into rules
\echo ''
\echo '=== Setup: Compiling Policies ==='
SELECT * FROM morbac.compile_policy();
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Test infrastructure -- Test infrastructure
@@ -343,6 +317,6 @@ $$;
\echo ' Organizations : GlobalTech HQ, Engineering Dept, Sales Dept' \echo ' Organizations : GlobalTech HQ, Engineering Dept, Sales Dept'
\echo ' Roles : 14 across 3 orgs' \echo ' Roles : 14 across 3 orgs'
\echo ' Users : Alice, Bob, Carol, Dave, Eve, Frank, Grace, Heidi, Ivan, Judy, Karl, Leo' \echo ' Users : Alice, Bob, Carol, Dave, Eve, Frank, Grace, Heidi, Ivan, Judy, Karl, Leo'
\echo ' Activities : read, write, delete, approve, export, audit, manage' \echo ' Activities : read, write, delete, manage, approve, export, audit (+ built-ins: create, update)'
\echo ' Views : documents, reports, financial_data, hr_data, contracts, audit_logs, public_data' \echo ' Views : documents, reports, financial_data, hr_data, contracts, audit_logs, public_data'
\echo ' Contexts : always (true), business_hours (true), after_hours (false), end_of_quarter (true)' \echo ' Contexts : always (true), business_hours (true), after_hours (false), end_of_quarter (true)'
+7 -4
View File
@@ -36,10 +36,13 @@ INSERT INTO morbac.roles (id, org_id, name, description) VALUES
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
('30000000-0000-0000-0000-000000000014', '20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002'); ('30000000-0000-0000-0000-000000000014', '20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002');
-- Policies for eng_auditor in Engineering -- Rules for eng_auditor in Engineering
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
VALUES ('Engineering Dept', 'eng_auditor', 'read', 'audit_logs', 'permission', 'always'); SELECT o.id, r.id, 'read', 'audit_logs', c.id, 'permission'
SELECT * FROM morbac.compile_policy(); FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'eng_auditor'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'Engineering Dept';
-- Verify nina's role was set up correctly -- Verify nina's role was set up correctly
SELECT morbac.t('Nina has eng_auditor role at Engineering', SELECT morbac.t('Nina has eng_auditor role at Engineering',
-342
View File
@@ -1,342 +0,0 @@
-- =============================================================================
-- Administration Rules Tests
-- =============================================================================
-- Tests the admin meta-policy system: who can manage policies, roles, and users.
--
-- is_admin_allowed(user_id, org_id, admin_activity, admin_target) is the main API.
-- Helper functions: can_manage_roles(), can_manage_policies(), can_manage_user_role().
--
-- Scenarios:
-- 1. Default deny: no admin rule = no admin access
-- 2. Grant admin permission to a role, verify the role holder can manage
-- 3. Admin prohibition blocks management even with permission
-- 4. Role hierarchy applies: senior role inherits admin permissions
-- 5. Admin helper functions
-- 6. can_manage_user_role() per-role management permission
-- 7. Admin rules are org-scoped
--
-- Company context:
-- Grace (hr_manager) can manage users (assign_role) for employee-level roles
-- Carol (manager) can create rules
-- Alice (CEO) inherits Carol's admin perms via hierarchy
--
-- Prerequisites: 00_setup.sql -> 07_audit.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '08 — ADMINISTRATION RULES'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: Default deny — no admin rule means no admin access
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Default deny: no admin rule ---'
-- Nobody has admin rules yet — all should be denied
SELECT morbac.t('Alice (CEO, no admin rule yet) creates rules [default deny]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
SELECT morbac.t('Grace (hr_manager, no admin rule) assigns roles [default deny]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'employee'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Grant admin permissions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Grant admin permissions ---'
-- Grant manager role: can create and delete rules
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000003', -- manager
'create_rule', 'rules',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'),
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000003', -- manager
'delete_rule', 'rules',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission');
-- Grant hr_manager role: can assign employee and intern roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000008', -- hr_manager
'assign_role', 'employee',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'),
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000008', -- hr_manager
'assign_role', 'intern',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission');
-- Grant director role: can manage roles and policies
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000002', -- director
'manage', 'roles',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'),
('10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000002', -- director
'manage', 'policies',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission');
-- ---------------------------------------------------------------------------
-- Section 3: Verify admin permissions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. Verify admin permissions ---'
-- Carol (manager) can create rules
SELECT morbac.t('Carol (manager) creates rules',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), TRUE);
-- Carol (manager) can delete rules
SELECT morbac.t('Carol (manager) deletes rules',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete_rule', 'rules'
), TRUE);
-- Grace (hr_manager) can assign employee role
SELECT morbac.t('Grace (hr_manager) assigns employee role',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'employee'
), TRUE);
-- Grace (hr_manager) can assign intern role
SELECT morbac.t('Grace (hr_manager) assigns intern role',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'intern'
), TRUE);
-- Grace (hr_manager) cannot assign manager role (no rule for that)
SELECT morbac.t('Grace (hr_manager) assigns manager role [no admin rule for manager]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'assign_role', 'manager'
), FALSE);
-- Dave (employee) has no admin permissions
SELECT morbac.t('Dave (employee) creates rules [no admin rule for employee]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 4: Role hierarchy applies to admin permissions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Role hierarchy applies to admin permissions ---'
-- Bob (director) has own admin perm (manage roles/policies) and
-- inherits manager's admin perms (create_rule, delete_rule) via director->manager hierarchy
-- Bob (director) creates rules — inherited from manager via director->manager hierarchy
SELECT morbac.t('Bob (director, inherits manager admin) creates rules',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), TRUE);
-- Bob (director) manages roles — own admin perm
SELECT morbac.t('Bob (director) manages roles [own admin perm]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'manage', 'roles'
), TRUE);
-- Alice (CEO) inherits everything through CEO->director->manager chain
SELECT morbac.t('Alice (CEO, inherits director+manager) manages roles',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'manage', 'roles'
), TRUE);
SELECT morbac.t('Alice (CEO) manages policies',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'manage', 'policies'
), TRUE);
-- Eve (intern) inherits nothing useful for admin
SELECT morbac.t('Eve (intern) creates rules [no admin permission in hierarchy]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 5: Admin prohibition overrides permission
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Admin prohibition overrides permission ---'
-- Carol (manager) currently can create rules. Add a prohibition.
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality)
VALUES (
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000003', -- manager
'create_rule', 'rules',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
-- Prohibition overrides permission
SELECT morbac.t('Carol (manager, now prohibited) creates rules [prohibition wins]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), FALSE);
-- Carol can still delete rules (prohibition only applied to create_rule)
SELECT morbac.t('Carol (manager, prohibition only on create) deletes rules [still allowed]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete_rule', 'rules'
), TRUE);
-- Remove the prohibition for subsequent tests
DELETE FROM morbac.admin_rules
WHERE org_id = '10000000-0000-0000-0000-000000000001'
AND role_id = '20000000-0001-0000-0000-000000000003'
AND admin_activity = 'create_rule' AND admin_target = 'rules'
AND modality = 'prohibition';
-- Carol can again create rules after prohibition removed
SELECT morbac.t('Carol (manager) creates rules after prohibition removed',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create_rule', 'rules'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 6: Admin helper functions
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. Admin helper functions ---'
-- can_manage_roles: wraps is_admin_allowed('manage', 'roles')
SELECT morbac.t('Bob (director) can_manage_roles',
morbac.can_manage_roles(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), TRUE);
SELECT morbac.t('Dave (employee) can_manage_roles [denied]',
morbac.can_manage_roles(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), FALSE);
-- can_manage_policies: wraps is_admin_allowed('manage', 'policies')
SELECT morbac.t('Bob (director) can_manage_policies',
morbac.can_manage_policies(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), TRUE);
SELECT morbac.t('Carol (manager) can_manage_policies [no manage policies rule for manager]',
morbac.can_manage_policies(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 7: can_manage_user_role — per-role management check
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. can_manage_user_role ---'
-- Grace (hr_manager) has assign_role perm for 'employee'
-- can_manage_user_role checks is_admin_allowed('assign_role', role_name)
SELECT morbac.t('Grace (hr_manager) can manage employee role assignments',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000007'::uuid, -- Grace
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000004'::uuid -- employee role
), TRUE);
SELECT morbac.t('Grace (hr_manager) can manage intern role assignments',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000007'::uuid, -- Grace
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000005'::uuid -- intern role
), TRUE);
SELECT morbac.t('Grace (hr_manager) cannot manage manager role assignments [no rule]',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000007'::uuid, -- Grace
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000003'::uuid -- manager role
), FALSE);
SELECT morbac.t('Dave (employee) cannot manage any role assignments',
morbac.can_manage_user_role(
'30000000-0000-0000-0000-000000000004'::uuid, -- Dave
'10000000-0000-0000-0000-000000000001'::uuid,
'20000000-0001-0000-0000-000000000005'::uuid -- intern role
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 8: Admin rules are org-scoped
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Admin rules are org-scoped ---'
-- Carol (manager at GlobalTech) cannot manage Engineering rules
-- (her admin rule is for GlobalTech org only)
SELECT morbac.t('Carol (GlobalTech manager) creates Engineering rules [wrong org]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid, -- Engineering org
'create_rule', 'rules'
), FALSE);
-- Alice (CEO at GlobalTech) cannot manage Engineering rules via admin (same org scoping)
SELECT morbac.t('Alice (GlobalTech CEO) creates Engineering rules [org-scoped]',
morbac.is_admin_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid, -- Engineering org
'manage', 'roles'
), FALSE);
\echo ''
\echo '=== Administration Rules Tests Completed ==='
+290
View File
@@ -0,0 +1,290 @@
-- =============================================================================
-- System Access Tests
-- =============================================================================
-- morbac system tables are protected by RLS.
-- is_allowed() and its internals are SECURITY DEFINER to avoid recursion.
-- System view names are config-driven (system_view.* keys, default: orgs/roles/rules/...).
--
-- Scenarios:
-- 1. Default deny: no rule = no access to system views via is_allowed()
-- 2. Grant permissions via regular rules, verify access
-- 3. Prohibition overrides permission (standard engine behavior)
-- 4. Role hierarchy applies: senior role inherits permissions
-- 5. assign_role() / revoke_role() — SoD/cardinality enforcement, RLS guards the INSERT/DELETE
-- 6. RLS on morbac tables: session user cannot read/write without rules
-- 7. Rules are org-scoped
--
-- Prerequisites: 00_setup.sql -> 07_audit.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '08 — SYSTEM ACCESS'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: Default deny
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Default deny: no rule = no access to system views ---'
SELECT morbac.t('Carol (manager) create rules [default deny]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
SELECT morbac.t('Grace (hr_manager) create user_roles [default deny]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'user_roles'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Grant permissions via regular rules
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Grant permissions ---'
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, v.activity, v.view, c.id, v.modality::morbac.modality
FROM (VALUES
('GlobalTech HQ', 'manager', 'create', 'rules', 'always', 'permission'),
('GlobalTech HQ', 'manager', 'delete', 'rules', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'read', 'user_roles', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'create', 'user_roles', 'always', 'permission'),
('GlobalTech HQ', 'hr_manager', 'delete', 'user_roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'create', 'roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'read', 'roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'update', 'roles', 'always', 'permission'),
('GlobalTech HQ', 'director', 'delete', 'roles', 'always', 'permission')
) AS v(org_name, role_name, activity, view, context_name, modality)
JOIN morbac.orgs o ON o.name = v.org_name
JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
JOIN morbac.contexts c ON c.name = v.context_name;
\echo ''
\echo '--- 3. Verify permissions ---'
SELECT morbac.t('Carol (manager) create rules',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), TRUE);
SELECT morbac.t('Carol (manager) delete rules',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'rules'
), TRUE);
SELECT morbac.t('Carol (manager) update rules [no rule]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'update', 'rules'
), FALSE);
SELECT morbac.t('Grace (hr_manager) create user_roles',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000007'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'user_roles'
), TRUE);
SELECT morbac.t('Dave (employee) create rules [no rule]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 4: Role hierarchy applies
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Role hierarchy applies ---'
SELECT morbac.t('Bob (director, inherits manager) create rules',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), TRUE);
SELECT morbac.t('Bob (director) create roles [own rule]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000002'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'roles'
), TRUE);
SELECT morbac.t('Alice (CEO) create roles [inherits director]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'roles'
), TRUE);
SELECT morbac.t('Eve (intern) create rules [nothing in hierarchy]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 5: Prohibition overrides permission
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Prohibition overrides permission ---'
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, 'create', 'rules', c.id, 'prohibition'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'manager'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'GlobalTech HQ';
SELECT morbac.t('Carol (manager, prohibited) create rules [prohibition wins]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'create', 'rules'
), FALSE);
SELECT morbac.t('Carol (prohibition only on create) delete rules [still allowed]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'rules'
), TRUE);
DELETE FROM morbac.rules
WHERE org_id = '10000000-0000-0000-0000-000000000001'
AND role_id = '20000000-0001-0000-0000-000000000003'
AND activity = 'create' AND view = 'rules'
AND modality = 'prohibition';
-- ---------------------------------------------------------------------------
-- Section 6: assign_role / revoke_role
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. assign_role / revoke_role ---'
-- As DB owner (bypasses RLS), assign Karl as intern to verify constraint logic
SELECT morbac.assign_role(
'30000000-0000-0000-0000-000000000011'::uuid, -- Karl
'20000000-0001-0000-0000-000000000005'::uuid, -- intern
'10000000-0000-0000-0000-000000000001'::uuid
);
SELECT morbac.t('Karl assigned intern role via assign_role()',
EXISTS(
SELECT 1 FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
), TRUE);
SELECT morbac.revoke_role(
'30000000-0000-0000-0000-000000000011'::uuid,
'20000000-0001-0000-0000-000000000005'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid
);
SELECT morbac.t('Karl intern role revoked via revoke_role()',
NOT EXISTS(
SELECT 1 FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 7: RLS on morbac tables
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. RLS on morbac tables ---'
-- Create a non-superuser role so RLS policies are enforced (superusers bypass RLS by default)
DROP ROLE IF EXISTS morbac_rls_tester;
CREATE ROLE morbac_rls_tester;
GRANT USAGE ON SCHEMA morbac TO morbac_rls_tester;
GRANT SELECT, INSERT, DELETE ON morbac.user_roles TO morbac_rls_tester;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO morbac_rls_tester;
SET SESSION AUTHORIZATION morbac_rls_tester;
-- Grace (hr_manager) has create/delete on user_roles — RLS should allow
SET morbac.user_id = '30000000-0000-0000-0000-000000000007';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000011',
'20000000-0001-0000-0000-000000000005',
'10000000-0000-0000-0000-000000000001'
);
SELECT morbac.t('Grace (hr_manager) inserted user_role via RLS',
EXISTS(
SELECT 1 FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
), TRUE);
DELETE FROM morbac.user_roles
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND role_id = '20000000-0001-0000-0000-000000000005'
AND org_id = '10000000-0000-0000-0000-000000000001';
-- Dave (employee) has no rules for user_roles — RLS should block
SET morbac.user_id = '30000000-0000-0000-0000-000000000004';
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
DO $$
BEGIN
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000011',
'20000000-0001-0000-0000-000000000005',
'10000000-0000-0000-0000-000000000001'
);
RAISE NOTICE 'CHECK FAIL: Dave (employee) inserted user_role [should have been blocked by RLS]';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'CHECK PASS: Dave (employee) blocked from inserting user_role by RLS';
END;
$$;
RESET SESSION AUTHORIZATION;
RESET morbac.user_id;
RESET morbac.org_id;
DROP OWNED BY morbac_rls_tester;
DROP ROLE IF EXISTS morbac_rls_tester;
-- ---------------------------------------------------------------------------
-- Section 8: Rules are org-scoped
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Rules are org-scoped ---'
SELECT morbac.t('Carol (GlobalTech manager) create Engineering rules [wrong org]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'create', 'rules'
), FALSE);
SELECT morbac.t('Alice (GlobalTech CEO) create Engineering roles [org-scoped]',
morbac.is_allowed(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'create', 'roles'
), FALSE);
\echo ''
\echo '=== System Access Tests Completed ==='
+13 -75
View File
@@ -1,5 +1,5 @@
-- ============================================================================= -- =============================================================================
-- Utilities, Derived Roles, RLS, and Policy DSL Tests -- Utilities, Derived Roles, and RLS Tests
-- ============================================================================= -- =============================================================================
-- Tests miscellaneous utility functions and advanced features: -- Tests miscellaneous utility functions and advanced features:
-- --
@@ -10,17 +10,15 @@
-- 4. user_has_role — checks if user holds a named role -- 4. user_has_role — checks if user holds a named role
-- 5. user_roles_in_org — lists all roles for user in org -- 5. user_roles_in_org — lists all roles for user in org
-- 6. eval_context — evaluates context predicates directly -- 6. eval_context — evaluates context predicates directly
-- 7. Policy DSL idempotency — compile_policy() is safe to run multiple times -- 7. Derived roles — computed via evaluator function
-- 8. Policy DSL error handling — reports errors for missing org/role -- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping
-- 9. Derived roles — computed via evaluator function
-- 10. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping
-- --
-- Prerequisites: 00_setup.sql -> 08_admin.sql -- Prerequisites: 00_setup.sql -> 08_system_access.sql
-- ============================================================================= -- =============================================================================
\echo '' \echo ''
\echo '================================================================' \echo '================================================================'
\echo '09 — UTILITIES, DERIVED ROLES, RLS, POLICY DSL' \echo '09 — UTILITIES, DERIVED ROLES, RLS'
\echo '================================================================' \echo '================================================================'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -327,70 +325,10 @@ SELECT morbac.t('eval_context(end_of_quarter) = TRUE',
TRUE); TRUE);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 7: Policy DSL — idempotency -- Section 7: Derived roles
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 7. Policy DSL idempotency ---' \echo '--- 7. Derived roles ---'
-- All existing policies are already compiled (compiled=TRUE)
-- Running compile_policy() again should compile 0 new entries
SELECT morbac.t_eq('Re-running compile_policy() gives compiled_count=0 (all already compiled)',
(SELECT compiled_count FROM morbac.compile_policy()),
0);
SELECT morbac.t_eq('Re-running compile_policy() gives error_count=0',
(SELECT error_count FROM morbac.compile_policy()),
0);
-- Adding a new policy and verifying it compiles once but not twice
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('GlobalTech HQ', 'employee', 'read', 'contracts', 'permission', 'always');
SELECT morbac.t_eq('compile_policy() compiles 1 new policy entry',
(SELECT compiled_count FROM morbac.compile_policy()),
1);
SELECT morbac.t_eq('compile_policy() is idempotent: compiled_count=0 on second run',
(SELECT compiled_count FROM morbac.compile_policy()),
0);
-- Verify the rule was actually created
SELECT morbac.t_eq('Rule for employee read contracts was created',
(SELECT COUNT(*) FROM morbac.rules r
JOIN morbac.roles ro ON ro.id = r.role_id
WHERE ro.name = 'employee'
AND r.activity = 'read' AND r.view = 'contracts' AND r.modality = 'permission')::bigint,
1);
-- ---------------------------------------------------------------------------
-- Section 8: Policy DSL — error handling
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Policy DSL error handling ---'
-- Insert a policy for a non-existent organization
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('Nonexistent Corp', 'employee', 'read', 'documents', 'permission', 'always');
-- Should produce errors (org not found)
SELECT morbac.t('compile_policy: nonexistent org produces errors',
(SELECT error_count > 0 FROM morbac.compile_policy()),
TRUE);
-- Insert a policy with a valid org but nonexistent role
INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name)
VALUES ('GlobalTech HQ', 'ghost_role', 'read', 'documents', 'permission', 'always');
-- Should produce errors (role not found)
SELECT morbac.t('compile_policy: nonexistent role produces errors',
(SELECT error_count > 0 FROM morbac.compile_policy()),
TRUE);
-- ---------------------------------------------------------------------------
-- Section 9: Derived roles
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 9. Derived roles ---'
-- Create a derived role: 'senior_employee' — dynamically granted to Dave (only) -- Create a derived role: 'senior_employee' — dynamically granted to Dave (only)
INSERT INTO morbac.roles (id, org_id, name, description) INSERT INTO morbac.roles (id, org_id, name, description)
@@ -497,10 +435,10 @@ WHERE user_id = '30000000-0000-0000-0000-000000000004'
AND role_id = '20000000-0001-0000-0000-000000000011'; AND role_id = '20000000-0001-0000-0000-000000000011';
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Section 10: RLS helper functions -- Section 8: RLS helper functions
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
\echo '' \echo ''
\echo '--- 10. RLS helpers: get_user_orgs, current_org_ids, rls_check ---' \echo '--- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check ---'
-- Without session variables set, current_user_id / current_org_id / current_org_ids return NULL -- Without session variables set, current_user_id / current_org_id / current_org_ids return NULL
SELECT morbac.t_null('current_user_id() returns NULL without session var', SELECT morbac.t_null('current_user_id() returns NULL without session var',
@@ -593,9 +531,9 @@ SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in Engineering [a
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'), morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'),
TRUE); TRUE);
-- Judy has no role at GlobalTech -> denied even in all-orgs mode -- Judy has no access to contracts at GlobalTech (no cross-org rule, no view hierarchy) -> denied
SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in GlobalTech [no role, denied]', SELECT morbac.t('rls_check all-orgs mode: Judy reads contracts in GlobalTech [no rule, denied]',
morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'), morbac.rls_check('read', 'contracts', '10000000-0000-0000-0000-000000000001'),
FALSE); FALSE);
-- No row org and no session org -> denied -- No row org and no session org -> denied
@@ -613,4 +551,4 @@ SELECT morbac.t('rls_check(read, documents) as Karl (no role) [denied]',
RESET morbac.user_id; RESET morbac.user_id;
\echo '' \echo ''
\echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ===' \echo '=== Utilities, Derived Roles, and RLS Tests Completed ==='
+259
View File
@@ -0,0 +1,259 @@
-- =============================================================================
-- Scope Rules and Global Cross-Org Rules Tests
-- =============================================================================
-- Tests rules.scope and cross_org_rules.source_org_id = NULL:
--
-- 1. scope='self' (default) — exact org only, unchanged behavior
-- 2. scope='subtree' — rule at root covers self + Engineering + Sales
-- 3. scope='descendants' — covers Engineering + Sales but NOT GlobalTech itself
-- 4. scope='children' — covers direct children only
-- 5. New org added after rule creation — picked up automatically (cache invalidation)
--
-- Prerequisites: 00_setup.sql -> 10_activity_view_bindings.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '11 — SCOPE RULES AND GLOBAL CROSS-ORG RULES'
\echo '================================================================'
-- Setup: create a dedicated role for scope tests (avoid polluting existing rules)
INSERT INTO morbac.roles (id, org_id, name, description)
VALUES (
'20000000-0001-0000-0000-000000000012',
'10000000-0000-0000-0000-000000000001',
'analyst',
'Data analyst — scope tests'
);
-- Assign Karl (previously no role) as analyst at GlobalTech
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000011',
'20000000-0001-0000-0000-000000000012',
'10000000-0000-0000-0000-000000000001'
);
-- ---------------------------------------------------------------------------
-- Section 1: scope='self' (default) — exact org only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. scope=self (default) ---'
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'self'
);
SELECT morbac.t('Karl (analyst, scope=self) reads reports in GlobalTech HQ [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=self) reads reports in Engineering [denied, self only]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), FALSE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000001';
-- ---------------------------------------------------------------------------
-- Section 2: scope='subtree' — root + all descendants
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. scope=subtree ---'
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000002',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'subtree'
);
SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in GlobalTech HQ [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in Engineering [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in Sales [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000003'::uuid,
'read', 'reports'
), TRUE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000002';
-- ---------------------------------------------------------------------------
-- Section 3: scope='descendants' — children only, NOT self
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. scope=descendants ---'
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000003',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'descendants'
);
SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in GlobalTech HQ [denied, not self]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), FALSE);
SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in Engineering [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in Sales [allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000003'::uuid,
'read', 'reports'
), TRUE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000003';
-- ---------------------------------------------------------------------------
-- Section 4: scope='children' — direct children only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. scope=children ---'
-- Add a grandchild org (child of Engineering)
INSERT INTO morbac.orgs (id, name, parent_id)
VALUES (
'10000000-0000-0000-0000-000000000004',
'Backend Team',
'10000000-0000-0000-0000-000000000002'
);
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000004',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'children'
);
SELECT morbac.t('Karl (analyst, scope=children) reads reports in Engineering [direct child, allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'reports'
), TRUE);
SELECT morbac.t('Karl (analyst, scope=children) reads reports in Backend Team [grandchild, denied]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000004'::uuid,
'read', 'reports'
), FALSE);
SELECT morbac.t('Karl (analyst, scope=children) reads reports in GlobalTech HQ [self, denied]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'reports'
), FALSE);
DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000004';
DELETE FROM morbac.orgs WHERE id = '10000000-0000-0000-0000-000000000004';
-- ---------------------------------------------------------------------------
-- Section 5: New org added after rule creation — scope picks it up automatically
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Dynamic scope: new org covered automatically ---'
-- Create a subtree-scoped rule at GlobalTech HQ for the analyst role
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000005',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'subtree'
);
-- Verify the rule works for existing child orgs
SELECT morbac.t('Karl (analyst) reads documents in Engineering [existing child, allowed]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'documents'
), TRUE);
-- Add a new org AFTER the rule was created
INSERT INTO morbac.orgs (id, name, parent_id)
VALUES (
'10000000-0000-0000-0000-000000000005',
'Legal Dept',
'10000000-0000-0000-0000-000000000001'
);
-- The scoped rule was defined before Legal Dept existed — still covers it
SELECT morbac.t('Karl (analyst) reads documents in Legal Dept [new org, covered by subtree scope]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000005'::uuid,
'read', 'documents'
), TRUE);
-- A scope=self rule at GlobalTech HQ does NOT cover Legal Dept
INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality, scope)
VALUES (
'c0000000-0000-0000-0000-000000000006',
'10000000-0000-0000-0000-000000000001',
'20000000-0001-0000-0000-000000000012',
'read', 'reports',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 'self'
);
SELECT morbac.t('Karl (analyst, scope=self) reads reports in Legal Dept [new org, not covered]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000005'::uuid,
'read', 'reports'
), FALSE);
-- Cleanup
DELETE FROM morbac.rules WHERE id IN ('c0000000-0000-0000-0000-000000000005', 'c0000000-0000-0000-0000-000000000006');
DELETE FROM morbac.orgs WHERE id = '10000000-0000-0000-0000-000000000005';
\echo ''
\echo '=== Scope Rules and Global Cross-Org Rules Tests Completed ==='
+3 -4
View File
@@ -23,22 +23,21 @@ 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" "activity_view_bindings.sql"
"obligations.sql" "obligations.sql"
"audit.sql" "audit.sql"
"rls.sql" "rls.sql"
"admin_helpers.sql" "role_assignments.sql"
"auth_cache.sql" "auth_cache.sql"
"validation.sql" "validation.sql"
"authorization.sql" "authorization.sql"
"system_rls.sql"
"footer.sql" "footer.sql"
) )
+1 -1
View File
@@ -5,7 +5,7 @@
set -e # Exit immediately if a command exits with a non-zero status set -e # Exit immediately if a command exits with a non-zero status
CONTAINER="${1:-postgres}" CONTAINER="${1:-pgmorbac_postgres_test}"
DB="${2:-morbac_test}" DB="${2:-morbac_test}"
PG_USER="${3:-postgres}" PG_USER="${3:-postgres}"