Files
pgmorbac/docs/DOCUMENTATION.md
T

1152 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# pgmorbac Documentation
Complete technical documentation for the Multi-OrBAC PostgreSQL extension.
## Table of Contents
- [Architecture](#architecture)
- [Schema Reference](#schema-reference)
- [Core Concepts](#core-concepts)
- [Advanced Features](#advanced-features)
- [API Reference](#api-reference)
- [Integration Guides](#integration-guides)
- [Performance](#performance)
- [Security Model](#security-model)
## Architecture
### Design Principles
pgmorbac is built on these principles:
1. **Pure PostgreSQL**: No external dependencies
2. **Schema Isolation**: All objects in `morbac` schema
3. **Default Deny**: No permission = access denied
4. **Prohibition Precedence**: Prohibitions override permissions at equal or unset priority; a permission with strictly higher priority wins
5. **Organization-Centric**: All rules scoped to organizations
6. **Multi-Tenant Native**: Users and resources can span organizations
### Authorization Flow
```mermaid
flowchart TD
A[Authorization Request] --> B[Get Comprehensive Roles]
B --> C[Direct Assignments]
B --> D[Delegations]
B --> E[Derived Roles]
B --> F[Role Hierarchy]
C --> G[Filter Negative Assignments]
D --> G
E --> G
F --> G
G --> SP{System Principal?}
SP -->|Yes| I[Find Highest-Priority Permission]
SP -->|No| H[Find Highest-Priority Prohibition]
H --> H2[+ Global Prohibitions step 3.5]
I --> I2[+ Global Permissions step 6.5]
H2 --> J{Compare Priorities}
I2 --> J
J -->|Permission priority > Prohibition priority| K[ALLOW]
J -->|Prohibition exists, no higher-priority permission| L[DENY]
J -->|No prohibition, permission found| K
J -->|Neither found| M[DENY - Default]
```
### Performance Optimizations
**Priority-based evaluation:** Both prohibitions and permissions are evaluated; the higher-priority rule wins. At equal or unset priority, prohibition overrides permission.
**Indexed foreign keys:** All foreign key relationships have indexes for fast joins.
**Composite indexes:** Optimized indexes for common query patterns (org_id, role_id, activity, view).
**STABLE functions:** RLS functions are marked STABLE for transaction-level caching.
**Recursive CTEs:** Efficient hierarchy traversal using PostgreSQL's recursive common table expressions.
## Schema Reference
### Entity Relationship Diagram
```mermaid
erDiagram
orgs ||--o{ orgs : "parent_id"
orgs ||--o{ roles : "has"
orgs ||--o{ user_roles : "contains"
orgs ||--o{ rules : "defines"
roles ||--o{ user_roles : "assigned_to"
roles ||--o{ role_hierarchy : "senior"
roles ||--o{ role_hierarchy : "junior"
roles ||--o{ rules : "applies_to"
activities ||--o{ rules : "action"
activities ||--o{ activity_hierarchy : "parent"
activities ||--o{ activity_hierarchy : "child"
activities ||--o{ activity_view_bindings : "restricted_to"
views ||--o{ rules : "target"
views ||--o{ view_hierarchy : "parent"
views ||--o{ view_hierarchy : "child"
views ||--o{ activity_view_bindings : "allowed_for"
contexts ||--o{ rules : "condition"
contexts ||--o{ global_rules : "condition"
activities ||--o{ global_rules : "action"
views ||--o{ global_rules : "target"
system_principals ||--o{ global_rules : "ruleset"
roles ||--o{ delegations : "delegated"
roles ||--o{ negative_role_assignments : "prohibited"
roles ||--o{ sod_conflicts : "conflicts_with"
roles ||--o{ derived_roles : "computed"
```
### Core Tables
**morbac.orgs**: Organizations with hierarchy support (`parent_id` self-reference). Unique name required. Supports metadata as JSONB.
**morbac.roles**: Roles scoped to organizations. Unique `(org_id, name)` constraint ensures role names are unique within each org.
**morbac.role_hierarchy**: Role inheritance via `(senior_role_id, junior_role_id)`. Senior roles inherit all junior permissions. Supports transitive closure.
**morbac.user_roles**: Direct user-to-role assignments. Primary key on `(user_id, role_id, org_id)`. Note: `user_id` is external (application manages users).
**morbac.activities**: Abstract actions (global scope). Text primary key. Examples: `read`, `write`, `delete`, `approve`.
**morbac.activity_hierarchy**: Activity inheritance via `(senior_activity, junior_activity)`. Permission on a senior activity also grants junior activities.
**morbac.views**: Abstract object categories (global scope). Text primary key. Examples: `documents`, `reports`, `financial_data`.
**morbac.view_hierarchy**: View inheritance via `(senior_view, junior_view)`. Access on a senior view also grants junior views.
**morbac.contexts**: Contextual conditions as callable predicates. Column `evaluator` (REGPROC) references a function returning BOOLEAN (preferably STABLE). Built-in context `always` returns true.
**morbac.rules**: Core rules linking org, role, activity, view, context, modality, and scope. The `scope` column (default `'self'`) controls which orgs the rule covers relative to `org_id` — evaluated at query time so new child orgs are picked up automatically without re-inserting rules.
### Advanced Feature Tables
**morbac.delegations**
Temporal role delegation with time bounds.
**Key columns:**
- `delegator_id`: User granting the role
- `delegatee_id`: User receiving the role
- `role_id`, `org_id`: Role being delegated
- `valid_from`, `valid_until`: Time window
**Behavior:** Automatically included in `get_comprehensive_roles()` when active.
**morbac.negative_role_assignments**
Explicit role prohibitions with highest precedence.
**Key columns:**
- `user_id`, `role_id`, `org_id`: Assignment to prohibit
- `reason`: Explanation for prohibition
**Behavior:** Overrides direct assignments, delegations, and derived roles.
**morbac.sod_conflicts**
Separation of Duty constraints enforce mutually exclusive roles.
**Key columns:**
- `role_a_id`, `role_b_id`: Conflicting role pair
- `org_id`: Organization scope
- `description`: Explanation of conflict
**Behavior:** Prevents users from holding both roles simultaneously. Validated via `check_sod_violation()` before role assignment.
**morbac.role_cardinality**
Constrains the number of users per role.
**Key columns:**
- `role_id`: Role to constrain (primary key)
- `min_users`: Minimum required users (NULL = no minimum)
- `max_users`: Maximum allowed users (NULL = unlimited)
**Behavior:** Validated via `check_cardinality_violation()` before role assignment.
**morbac.derived_roles**
Dynamically computed roles via custom functions.
**Key columns:**
- `role_id`: Role to compute (primary key)
- `condition_evaluator`: Function `f(user_id UUID, org_id UUID) RETURNS BOOLEAN`
- `description`: Explanation of computation logic
**Behavior:** Function is called at runtime to determine if a user has the role. Included in `get_comprehensive_roles()`.
**morbac.cross_org_rules**
Inter-organizational access rules.
**Key columns:**
- `source_org_id`: Organization where the user must hold the role
- `target_org_id`: Organization where access is granted
- `role_id`, `activity`, `view`: Rule specification
- `context_id`: Contextual condition
- `modality`: Permission or prohibition
**Behavior:** Allows roles in a source organization to access resources in a target organization.
**morbac.system_principals**
Registry of backend service accounts. Registered user UUIDs are protected at the trigger level — no role assignment, rule, delegation, or prohibition can target them. Their permission rules in `global_rules` are equally immutable.
**Key columns:**
- `user_id`: UUID of the service account
- `description`: Human-readable label
**Behavior:** System principals bypass all prohibition evaluation. Only the DB owner can insert or remove entries (no RLS write policies).
**morbac.global_rules**
System-wide rules with no org or role binding.
**Key columns:**
- `user_id`: NULL = all users; non-NULL = specific user
- `activity`: NULL = any activity; non-NULL = specific activity (hierarchy applies)
- `view`: NULL = any view; non-NULL = specific view (hierarchy applies)
- `context_id`: Contextual condition
- `modality`: Permission or prohibition
- `priority`: Optional; same resolution semantics as `morbac.rules`
**Behavior:** Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in `is_allowed_nocache()`. NULL on `activity` or `view` matches any value — no hierarchy setup required for broad rules.
**morbac.activity_view_bindings**
Optional whitelist of valid (activity, view) pairs for rule creation.
**Key columns:**
- `activity`: Activity to restrict
- `view`: Permitted view for that activity
**Behavior:** Opt-in per activity. If any binding exists for an activity, rules (including cross-org rules) can only use listed views. Activities with no bindings are unconstrained. Enforced at the database level via triggers on `morbac.rules` and `morbac.cross_org_rules`.
```sql
-- Restrict 'approve' to invoices and contracts only
INSERT INTO morbac.activity_view_bindings (activity, view) VALUES
('approve', 'invoices'),
('approve', 'contracts');
-- This succeeds
INSERT INTO morbac.rules (..., activity, view, ...) VALUES (..., 'approve', 'invoices', ...);
-- This raises an exception
INSERT INTO morbac.rules (..., activity, view, ...) VALUES (..., 'approve', 'user_profiles', ...);
-- Remove all bindings to lift the restriction
DELETE FROM morbac.activity_view_bindings WHERE activity = 'approve';
```
## Core Concepts
### Deontic Modalities
Multi-OrBAC implements four modalities:
1. **Permission**: Allows action (if no higher-or-equal-priority prohibition)
2. **Prohibition**: Denies action (wins at equal or unset priority; loses to higher-priority permission)
3. **Obligation**: Must be done (informational only)
4. **Recommendation**: Should be done (informational only)
Only permissions and prohibitions affect `is_allowed()` decisions.
**Evaluation Priority:**
```mermaid
flowchart LR
A[Applicable Rules] --> B[Highest-Priority Prohibition]
A --> C[Highest-Priority Permission]
B --> D{Permission priority\n> Prohibition priority?}
C --> D
D -->|Yes| E[ALLOW]
D -->|No, prohibition exists| F[DENY]
D -->|No prohibition, permission exists| E
D -->|Neither| G[DENY - Default]
```
**Obligations and Recommendations**
Obligations and recommendations do not affect access control, they are informational signals your application reads and acts on.
- **Obligation**: the user *must* perform this action. Use it to model required tasks, workflow steps, or compliance actions (e.g. a doctor must file a discharge report, an employee must review the security policy weekly).
- **Recommendation**: the user *should* consider this action. Use it for suggested next steps, nudges, or guidance (e.g. a manager is recommended to review team performance reports monthly).
Query them via `pending_obligations()` and `pending_recommendations()` to drive task lists, alerts, or workflow gates in your application.
**Conflict resolution**: a prohibition voids any applicable obligation. A prohibition or obligation voids any applicable recommendation. This mirrors the deontic priority order from the Multi-OrBAC paper: prohibition > obligation > recommendation.
**Example: Employee Document Access**
```sql
-- Employees can read documents
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality)
VALUES (org_id, employee_role_id, 'read', 'documents', 'permission');
-- Suspended employees cannot (prohibition overrides permission)
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality)
VALUES (org_id, suspended_role_id, 'read', 'documents', 'prohibition');
-- Check access
SELECT morbac.is_allowed(employee_id, org_id, 'read', 'documents'); -- TRUE
SELECT morbac.is_allowed(suspended_id, org_id, 'read', 'documents'); -- FALSE (prohibited)
```
### Context Evaluation
Contexts are boolean predicates that determine if a rule applies. They enable dynamic, condition-based access control.
**Context Evaluation Flow:**
```mermaid
flowchart TD
A[Rule Matches] --> B[Evaluate Context]
B --> C{Context Returns TRUE?}
C -->|Yes| D[Rule Applies]
C -->|No| E[Rule Ignored]
```
**Example: Business Hours Context**
```sql
-- Create a context for business hours (Monday-Friday, 9am-5pm)
CREATE FUNCTION morbac.ctx_business_hours() RETURNS BOOLEAN STABLE AS $$
SELECT EXTRACT(DOW FROM now()) BETWEEN 1 AND 5
AND EXTRACT(HOUR FROM now()) BETWEEN 9 AND 17;
$$ LANGUAGE SQL;
INSERT INTO morbac.contexts (name, evaluator) VALUES
('business_hours', 'morbac.ctx_business_hours'::regproc);
-- Contractors can only work during business hours
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT org_id, contractor_role_id, 'read', 'documents', id, 'permission'
FROM morbac.contexts WHERE name = 'business_hours';
```
**Context Best Practices:**
- Use STABLE (not VOLATILE) for RLS compatibility
- Keep logic simple and fast
- Avoid external dependencies
- Consider caching if computation is expensive
- Return FALSE on errors for safe defaults
- Test thoroughly as contexts affect security
### Inserting rules
Insert directly into `morbac.rules`. Resolve names to UUIDs with a JOIN:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, 'read', 'documents', c.id, 'permission'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'employee'
JOIN morbac.contexts c ON c.name = 'always'
WHERE o.name = 'Acme Corp';
```
For bulk inserts use a VALUES list joined to the lookup tables:
```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, v.modality::morbac.modality
FROM (VALUES
('Acme Corp', 'employee', 'read', 'documents', 'always', 'permission'),
('Acme Corp', 'employee', 'write', 'documents', 'business_hours', 'permission'),
('Acme Corp', 'contractor', 'read', 'financial_data','always', 'prohibition')
) 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;
```
## Advanced Features
### Organization Rule Scope
Every rule has a `scope` column (default `'self'`) that controls which organizations the rule covers relative to its `org_id`. Scope is **evaluated at query time** — adding a new child org to the hierarchy is enough for it to be covered by existing scoped rules. No rule re-creation needed.
| Scope | Covers |
|---|---|
| `'self'` | The rule's org only (default) |
| `'children'` | Direct children of the rule's org |
| `'descendants'` | All descendants, excluding the rule's org itself |
| `'subtree'` | The rule's org + all descendants |
| `'parent'` | Direct parent of the rule's org |
| `'ancestors'` | All ancestors, excluding the rule's org itself |
| `'lineage'` | The rule's org + all ancestors |
| `'root'` | Topmost ancestor of the rule's org |
**Example: Analyst reads reports across the whole company**
One rule at the root org covers the entire hierarchy, including orgs created in the future:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT o.id, r.id, 'read', 'reports', c.id, 'permission', '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: Regional manager applies only to direct divisions**
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
SELECT o.id, r.id, 'update', 'teams', c.id, 'permission', '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';
```
**Cache behavior:** The auth cache is fully invalidated whenever the org tree changes (`INSERT`/`UPDATE`/`DELETE` on `morbac.orgs`), so scoped rules are always consistent.
**`get_org_scope(org_id, scope, max_depth?)`** is the underlying helper — it returns `(org_id, depth)` rows and can be used directly when you need to iterate over an org set. An optional `p_max_depth` limits traversal depth.
### Hierarchies
Organizations, roles, activities, and views support hierarchical relationships with transitive closure.
```mermaid
graph TD
A[Director] -->|inherits from| B[Manager]
B -->|inherits from| C[Employee]
A -.->|transitive| C
```
**Example: Role Hierarchy**
Managers inherit all employee permissions:
```sql
-- Create role hierarchy
INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id)
VALUES (manager_role_id, employee_role_id);
-- Grant permission to employees
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality)
VALUES (org_id, employee_role_id, 'read', 'documents', 'permission');
-- Managers automatically get 'read documents' through inheritance
SELECT morbac.is_allowed(manager_id, org_id, 'read', 'documents'); -- TRUE
```
### Delegation
Temporary role delegation with time bounds.
**Example: Vacation Delegation**
Alice delegates her approver role to Bob for one week:
```sql
INSERT INTO morbac.delegations (
delegator_id, delegatee_id, role_id, org_id, valid_until
) VALUES (
alice_id, bob_id, approver_role_id, org_id, NOW() + INTERVAL '7 days'
);
-- Bob can now approve during this period
SELECT morbac.is_allowed(bob_id, org_id, 'approve', 'requests'); -- TRUE (until expires)
```
### Negative Role Assignments
Explicitly revoke roles with highest precedence.
**Example: Suspended Admin**
Temporarily suspend an admin without removing the role:
```sql
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason)
VALUES (alice_id, admin_role_id, org_id, 'Under investigation');
-- Alice loses admin access even though role assignment remains
SELECT morbac.is_allowed(alice_id, org_id, 'create', 'user_roles'); -- FALSE
```
### Separation of Duty
Prevents users from holding conflicting roles.
**Example: Invoice Approval**
The same person cannot both create and approve invoices:
```sql
-- Define the conflict
INSERT INTO morbac.sod_conflicts (role_a_id, role_b_id, org_id, description)
VALUES (creator_role_id, approver_role_id, org_id, 'Cannot create and approve');
-- Check before assigning approver role to Alice (who is already a creator)
SELECT morbac.check_sod_violation(alice_id, approver_role_id, org_id);
-- Returns: TRUE if conflict would occur, FALSE if safe to assign
```
### Cardinality Constraints
Enforce minimum and maximum users per role.
**Example: Admin Count Limits**
Require 2-5 administrators at all times:
```sql
INSERT INTO morbac.role_cardinality (role_id, min_users, max_users)
VALUES (admin_role_id, 2, 5);
-- Check before adding an admin (pass FALSE when removing)
SELECT morbac.check_cardinality_violation(admin_role_id); -- adding (default)
SELECT morbac.check_cardinality_violation(admin_role_id, FALSE); -- removing
-- Returns: error message if would violate constraint, NULL if valid
```
### Rule Conflict Detection
When inserting or updating a rule, pgmorbac automatically warns if the new rule conflicts with an existing one due to modality precedence. Conflicts are non-blocking (the insert succeeds) but a `WARNING` is emitted so you can catch unintentional rule contradictions.
A conflict is flagged when two rules share the same `(org, role, activity, view, context)` tuple and their modalities create a dead rule:
| New modality | Existing modality | Issue |
|---|---|---|
| `permission` | `prohibition` | permission will always be overridden |
| `obligation` | `prohibition` | obligation will always be voided |
| `recommendation` | `prohibition` or `obligation` | recommendation will always be voided |
| `prohibition` | any | the existing rule(s) become dead or voided |
Hierarchy-based overlaps (e.g. a permission on `manage` and a prohibition on `write`, where `write` is a sub-activity of `manage`) are intentional design and not flagged.
You can also call `detect_rule_conflicts()` explicitly before inserting:
```sql
-- Check for conflicts before inserting
SELECT * FROM morbac.detect_rule_conflicts(
org_id, role_id, 'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
-- Returns empty if no conflict, or rows describing each conflicting rule
```
### Derived Roles
Roles computed dynamically from application data.
**Example: Project Leaders**
Automatically grant project_lead role to users leading projects:
```sql
-- Function returns TRUE if user is a project leader in the given org
CREATE FUNCTION morbac.eval_project_lead(p_user_id UUID, p_org_id UUID)
RETURNS BOOLEAN STABLE AS $$
SELECT EXISTS(
SELECT 1 FROM app.projects
WHERE lead_user_id = p_user_id AND org_id = p_org_id
);
$$ LANGUAGE SQL;
INSERT INTO morbac.derived_roles (role_id, condition_evaluator)
VALUES (project_lead_role_id, 'morbac.eval_project_lead'::regproc);
-- Users automatically get project_lead permissions when they lead a project
```
### Cross-Organizational Rules
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: Auditor from HQ accesses a subsidiary**
```sql
INSERT INTO morbac.cross_org_rules (
source_org_id, target_org_id, role_id, activity, view, context_id, modality
) VALUES (
global_hq_id, subsidiary_id, auditor_role_id, 'read', 'financials',
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'
);
SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TRUE
```
**Scope vs. cross-org rules — when to use which:**
| 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` |
### Global Rules
Global rules apply system-wide — no org or role required. Use them to define blanket access policies that cut across the entire org hierarchy.
**Table:** `morbac.global_rules`
| Column | Purpose |
|---|---|
| `user_id` | NULL = all users; UUID = specific user |
| `activity` | NULL = any activity; specific name = that activity (hierarchy applies) |
| `view` | NULL = any view; specific name = that view (hierarchy applies) |
| `modality` | `permission` or `prohibition` |
| `priority` | Optional; same semantics as `morbac.rules` |
**Evaluation order:** Global prohibitions are evaluated at step 3.5 (after local, cross-org, and user-level prohibitions). Global permissions at step 6.5 (after all other permission sources). Both contribute to the same priority accumulators used in step 7 resolution.
**Use case: system account that reads all resources**
```sql
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
:system_account_uuid,
'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
```
`view = NULL` matches every view. Use `activity = NULL` too if the account needs all actions.
**Use case: protect service accounts from modification by anyone**
```sql
INSERT INTO morbac.views (name) VALUES ('service_accounts');
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
SELECT NULL, a.act, 'service_accounts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 100
FROM (VALUES ('delete'), ('update')) AS a(act);
```
Priority 100 ensures this prohibition overrides any role-based permission. To exempt a specific user, add a subject-specific permission with higher priority via `morbac.user_rules`.
### System Principals
Backend service accounts that must be fully immutable at the database level — no policy, no admin, no superadmin can touch them once registered.
**Table:** `morbac.system_principals`
| Column | Purpose |
|---|---|
| `user_id` | UUID of the service account (external, from your auth system) |
| `description` | Human-readable label |
**What is protected (trigger level — fires for all users including superusers):**
| Table | Blocked operations |
|---|---|
| `user_roles` | INSERT, UPDATE, DELETE |
| `user_rules` | INSERT, UPDATE, DELETE |
| `negative_role_assignments` | INSERT, UPDATE, DELETE |
| `delegations` | INSERT, UPDATE involving the principal |
| `global_rules` | All operations where `user_id` matches a system principal |
**Authorization behavior:** Prohibition evaluation (steps 13.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter.
**Ruleset:** Define permissions for system principals via `global_rules` at deploy time. Those rows are immutable once inserted — no one can modify or delete them. Use `activity=NULL, view=NULL` to grant full access, or restrict to specific activities/views:
```sql
-- Register the service account (DB owner only)
INSERT INTO morbac.system_principals (user_id, description)
VALUES (:service_uuid, 'Backend API worker');
-- Grant full access (immutable after insert)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (:service_uuid, NULL, NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
-- Or restrict to specific operations
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (:service_uuid, 'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
```
**Access control on the registry itself:** `morbac.system_principals` has a SELECT-only RLS policy — a user needs `is_allowed(..., 'read', 'system_principals')` to list them. INSERT/UPDATE/DELETE have no RLS policy, so they are blocked for all non-superusers automatically. Only the database owner can register or remove system principals.
### Administration
Admin operations use the same `is_allowed()` engine as everything else — no separate code path.
**System table RLS**
`morbac.*` tables have RLS policies. `is_allowed()` and all its internal callees are `SECURITY DEFINER`, running as the extension owner and bypassing RLS. This breaks the recursion: RLS policies call `is_allowed()`, which queries morbac tables without re-triggering the policies.
The database owner (superuser) bypasses RLS by default — use that privilege only during bootstrap.
**System view names**
The extension seeds built-in activities (`create`, `read`, `update`, `delete`) and system view names (`orgs`, `roles`, `rules`, `user_roles`, `contexts`, `activities`, `views`, `delegations`, `cross_org_rules`, `user_rules`, `global_rules`, `system_principals`) at install time.
These names are config-driven. Override with `morbac.set_config()` to use your own naming conventions — the new name must then exist in `morbac.views` and your rules must reference it:
```sql
-- Rename 'rules' to 'policies' in your system
SELECT morbac.set_config('system_view.rules', 'policies');
INSERT INTO morbac.views (name, description) VALUES ('policies', 'Authorization policies');
-- Your rules must now grant 'create'/'delete'/... on 'policies', not 'rules'
```
**Granting access to system tables**
Insert rules like any other rule:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, v.activity, v.view, c.id, 'permission'
FROM (VALUES
('manager', 'create', 'rules'),
('manager', 'delete', 'rules'),
('hr_manager', 'create', 'user_roles'),
('hr_manager', 'delete', 'user_roles')
) AS v(role_name, activity, view)
JOIN morbac.orgs o ON o.name = 'Acme Corp'
JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name
JOIN morbac.contexts c ON c.name = 'always';
```
**Bootstrapping the first organization**
As the database owner (bypasses RLS):
```sql
INSERT INTO morbac.orgs (name) VALUES ('Acme Corp');
INSERT INTO morbac.roles (org_id, name)
SELECT id, 'superuser' FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant full access to all system views
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
SELECT o.id, r.id, a.activity, v.view, c.id, 'permission'
FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'superuser'
JOIN morbac.contexts c ON c.name = 'always'
CROSS JOIN unnest(ARRAY['create','read','update','delete']) AS a(activity)
CROSS JOIN unnest(ARRAY['orgs','roles','rules','user_roles','contexts','activities','views','delegations','cross_org_rules']) AS v(view)
WHERE o.name = 'Acme Corp';
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
SELECT 'your-user-uuid'::uuid, r.id, o.id
FROM morbac.orgs o JOIN morbac.roles r ON r.org_id = o.id
WHERE o.name = 'Acme Corp' AND r.name = 'superuser';
```
After this, that user can manage the system through the application without database owner access.
### Temporal Constraints
Rules can have time-based validity periods.
**Example: Contractor Access**
Grant contractor access for Q1 2024 only:
```sql
INSERT INTO morbac.rules (org_id, role_id, activity, view, modality, valid_from, valid_until)
VALUES (org_id, contractor_role_id, 'read', 'documents', 'permission',
'2024-01-01', '2024-03-31');
-- Rule automatically inactive after March 31, 2024
```
### Audit Logging
Comprehensive audit trail for security-critical operations. The audit system tracks all INSERT, UPDATE, and DELETE operations on specified tables.
**Audit log table structure:**
```sql
morbac.audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ, -- When change occurred
user_id UUID, -- User making change (if applicable)
org_id UUID, -- Organization context
table_name TEXT, -- Table being modified
operation TEXT, -- INSERT, UPDATE, DELETE
record_id UUID, -- ID of affected record
old_data JSONB, -- Record state before change (UPDATE/DELETE)
new_data JSONB, -- Record state after change (INSERT/UPDATE)
changed_fields TEXT[], -- List of modified columns (UPDATE)
session_username TEXT, -- PostgreSQL session user
client_addr INET, -- Client IP address
application_name TEXT, -- Application name from connection
metadata JSONB -- Additional custom metadata
)
```
**Enabling audit logging:**
```sql
-- Enable auditing on user_roles table
SELECT morbac.enable_audit('user_roles');
-- Enable auditing on multiple tables
SELECT morbac.enable_audit('rules');
SELECT morbac.enable_audit('delegations');
```
**Disabling audit logging:**
```sql
SELECT morbac.disable_audit('user_roles');
```
**Querying audit log:**
```sql
-- Recent changes to user_roles
SELECT
timestamp,
operation,
session_username,
old_data->>'role_id' AS old_role,
new_data->>'role_id' AS new_role
FROM morbac.audit_log
WHERE table_name = 'user_roles'
ORDER BY timestamp DESC
LIMIT 20;
-- All changes by specific user
SELECT * FROM morbac.audit_log
WHERE user_id = '123e4567-e89b-12d3-a456-426614174000'
ORDER BY timestamp DESC;
-- Changes to specific record
SELECT * FROM morbac.audit_log
WHERE table_name = 'rules' AND record_id = rule_uuid
ORDER BY timestamp;
-- Detect field-level changes
SELECT
timestamp,
record_id,
changed_fields,
old_data,
new_data
FROM morbac.audit_log
WHERE table_name = 'rules'
AND operation = 'UPDATE'
AND 'modality' = ANY(changed_fields);
```
**Best practices:**
- 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)`
- Implement retention policies to archive old audit logs
- Use JSONB operators to query `old_data` and `new_data` efficiently
- Consider partitioning `audit_log` by timestamp for large installations
## API Reference
### Authorization Functions
**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny. Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests).
```sql
SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
```
**`get_comprehensive_roles(user_id, org_id)`**: Returns all roles for user (direct, delegated, derived, hierarchy, minus negative assignments).
**`get_effective_roles(user_id, org_id)`**: Returns direct + role-hierarchy roles only (no delegations or derived roles). Use `get_comprehensive_roles()` for full resolution.
### Hierarchy Functions
**`get_org_ancestors(org_id)`**: Returns all parent organizations with depth (including self at depth 0).
**`get_org_descendants(org_id)`**: Returns all child organizations with depth (including self at depth 0).
**`get_org_scope(org_id, scope, max_depth?)`**: Returns a named set of organizations relative to `org_id`. Scope values: `self`, `children`, `descendants`, `subtree`, `parent`, `ancestors`, `lineage`, `root`. Optional `max_depth` limits traversal depth.
```sql
-- All orgs in the subtree, up to 2 levels deep
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_effective_activities(activity)`**: Returns activity plus all child activities.
**`get_effective_views(view)`**: Returns view plus all child views.
### Validation Functions
**`check_sod_violation(user_id, role_id, org_id)`**: Returns TRUE if assigning `role_id` to `user_id` would violate a Separation of Duty constraint, FALSE otherwise.
**`check_cardinality_violation(role_id, adding?)`**: Returns error message if adding/removing a user would violate cardinality constraints, NULL if valid. `adding` defaults to TRUE.
**`detect_rule_conflicts(org_id, role_id, activity, view, context_id, modality, exclude_id?)`**: Returns rows describing rules that directly conflict with the given tuple due to modality precedence. `exclude_id` is used on UPDATE to skip the rule being modified. Also fires automatically as a non-blocking trigger warning on every INSERT or UPDATE to `morbac.rules`.
### Administration Functions
**`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.
### Context Functions
**`eval_context(context_id)`**: Evaluate a context predicate.
### RLS Helper Functions
**`current_user_id()`**: Get user ID from `request.header.x-user-id` (PostgREST) or `current_setting('morbac.user_id')`.
**`current_org_id()`**: Get org ID from `request.header.x-org-id` (PostgREST) or `current_setting('morbac.org_id')`.
**`rls_check(activity, view)`**: Authorization check for RLS policies using current user/org context.
```sql
CREATE POLICY my_policy ON app.table
FOR SELECT USING (morbac.rls_check('read', 'documents'));
```
### Informational Functions
**`pending_obligations(user_id, org_id)`**: Returns obligations for user (informational only).
**`pending_recommendations(user_id, org_id)`**: Returns recommendations for user (informational only).
**`user_has_role(user_id, org_id, role_name)`**: Check if user has specific role by name.
```sql
morbac.user_has_role(
p_user_id UUID,
p_org_id UUID,
p_role_name TEXT
) RETURNS BOOLEAN
```
## Integration Guides
### PostgREST Integration
Enable RLS and grant permissions:
```sql
ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY;
GRANT USAGE ON SCHEMA morbac TO postgrest_role;
GRANT SELECT ON ALL TABLES IN SCHEMA morbac TO postgrest_role;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO postgrest_role;
```
Create RLS policies. Pass the row's `org_id` column to `rls_check` so org scoping works in all modes:
```sql
CREATE POLICY document_read ON app.documents
FOR SELECT
USING (morbac.rls_check('read', 'documents', org_id));
CREATE POLICY document_write ON app.documents
FOR INSERT
WITH CHECK (morbac.rls_check('write', 'documents', org_id));
```
#### Org scoping modes
`rls_check` resolves the org scope from session variables in priority order:
| Session variable | Behaviour |
|---|---|
| `morbac.org_id` set | scoped to that single org |
| `morbac.org_ids` set | scoped to the provided list of orgs |
| neither set | all orgs the user belongs to |
#### Setting context from HTTP headers
Use a PostgREST [pre-request function](https://postgrest.org/en/stable/references/transactions.html#pre-request) to map headers to session variables:
```sql
CREATE OR REPLACE FUNCTION app.set_morbac_context()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
v_headers json := current_setting('request.headers', true)::json;
v_user_id text := v_headers->>'x-user-id';
v_org_id text := v_headers->>'x-org-id';
v_org_ids text := v_headers->>'x-org-ids';
BEGIN
IF v_user_id IS NOT NULL THEN
PERFORM set_config('morbac.user_id', v_user_id, true);
END IF;
IF v_org_id IS NOT NULL THEN
PERFORM set_config('morbac.org_id', v_org_id, true);
ELSIF v_org_ids IS NOT NULL THEN
-- x-org-ids is a comma-separated list: uuid1,uuid2,...
PERFORM set_config('morbac.org_ids',
(SELECT json_agg(trim(u))::text FROM unnest(string_to_array(v_org_ids, ',')) u),
true);
END IF;
END;
$$;
```
In `postgrest.conf`:
```ini
db-pre-request = app.set_morbac_context
```
Header usage:
```
X-User-Id: <user-uuid>
X-Org-Id: <org-uuid> # single org
X-Org-Ids: <uuid1>,<uuid2>,<uuid3> # multiple orgs, comma-separated
# omit both X-Org-Id and X-Org-Ids for all-orgs mode
```
### Application Integration
Python example:
```python
import psycopg2
conn = psycopg2.connect("dbname=mydb")
cursor = conn.cursor()
cursor.execute("SET morbac.user_id = %s", [user_id])
cursor.execute("SET morbac.org_id = %s", [org_id])
cursor.execute("SELECT * FROM app.documents") # RLS enforced
```
Node.js example:
```javascript
const client = await pool.connect();
await client.query('SET morbac.user_id = $1', [userId]);
await client.query('SET morbac.org_id = $1', [orgId]);
const res = await client.query('SELECT * FROM app.documents');
```
## Performance
### Optimization Strategies
Priority-based evaluation compares the best prohibition and best permission in a single pass.
For large hierarchies, use materialized views:
```sql
CREATE MATERIALIZED VIEW morbac.mv_role_closure AS
SELECT senior_role_id, junior_role_id FROM ...;
CREATE INDEX ON morbac.mv_role_closure(senior_role_id);
REFRESH MATERIALIZED VIEW morbac.mv_role_closure;
```
Context optimization:
- Use STABLE functions (not VOLATILE) for RLS caching
- Avoid expensive computations or external calls
- Keep context logic simple
All foreign keys are indexed. Critical composite indexes exist on `(user_id, org_id)` and `(org_id, role_id, activity, view, modality)`.
### Benchmarking
Example query performance (1M rules, 10K users, 100 orgs):
```sql
-- Simple authorization check
EXPLAIN ANALYZE
SELECT morbac.is_allowed(user_id, org_id, 'read', 'documents');
-- Typical: 5-15ms
-- With hierarchies (3 levels)
EXPLAIN ANALYZE
SELECT morbac.is_allowed(user_id, org_id, 'read', 'documents');
-- Typical: 10-30ms
-- RLS query (1M rows)
EXPLAIN ANALYZE
SELECT * FROM app.documents WHERE morbac.rls_check('read', 'documents');
-- Depends on selectivity, uses indexes
```
## Security Model
### Threat Model
Protected against:
- Unauthorized access (default deny)
- Privilege escalation (prohibition precedence)
- SQL injection (parameterized queries)
- Org boundary violations (explicit cross-org rules)
Not protected against:
- Malicious context functions (trusted code)
- Database user privilege escalation (PostgreSQL security model)
- Time-of-check/time-of-use races (transaction-based consistency only)
### Security Best Practices
**Context Function Security**
```sql
-- BAD: Leaks information
CREATE FUNCTION morbac.bad_context()
RETURNS BOOLEAN VOLATILE AS $$
SELECT EXISTS(SELECT 1 FROM sensitive_table);
$$;
-- GOOD: Self-contained, stable
CREATE FUNCTION morbac.good_context()
RETURNS BOOLEAN STABLE AS $$
SELECT EXTRACT(HOUR FROM CURRENT_TIME) BETWEEN 9 AND 17;
$$;
```
**User ID Validation**
```sql
-- Validate user exists before authorization
SELECT morbac.is_allowed(
user_id, -- Must be from authenticated session
org_id,
activity,
view
);
```
**Org Context Validation**
```sql
-- Verify user belongs to org
SELECT EXISTS(
SELECT 1 FROM morbac.user_roles
WHERE user_id = ? AND org_id = ?
);
```
### Comparison: Multi-OrBAC vs Traditional RBAC
| Aspect | Traditional RBAC | Multi-OrBAC (pgmorbac) |
|--------|-----------------|-------------------------|
| **Multi-tenancy** | Single tenant or complex workarounds | Native multi-organization |
| **Prohibition** | No standard support | First-class, wins at equal or unset priority |
| **Context** | Static permissions | Dynamic, condition-based |
| **Hierarchy** | Basic (roles only) | Full (orgs, roles, activities, views) |
| **Delegation** | Manual implementation | Native temporal delegation |
| **SoD** | Application logic | Database-enforced constraints |
| **Cross-tenant** | Not supported | Native cross-org rules |
| **Audit** | Application layer | Native audit log with field-level change tracking |
---
## Additional Resources
- [Multi-OrBAC Research Paper](https://webhost.laas.fr/TSF/deswarte/Publications/06427.pdf)