# 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 1–3.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter. **Ruleset:** Define permissions for system principals via `global_rules` at deploy time. Those rows are immutable once inserted — no one can modify or delete them. Use `activity=NULL, view=NULL` to grant full access, or restrict to specific activities/views: ```sql -- Register the service account (DB owner only) INSERT INTO morbac.system_principals (user_id, description) VALUES (:service_uuid, 'Backend API worker'); -- Grant full access (immutable after insert) INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) VALUES (:service_uuid, NULL, NULL, (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); -- Or restrict to specific operations INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) VALUES (:service_uuid, 'read', NULL, (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); ``` **Access control on the registry itself:** `morbac.system_principals` has a SELECT-only RLS policy — a user needs `is_allowed(..., 'read', 'system_principals')` to list them. INSERT/UPDATE/DELETE have no RLS policy, so they are blocked for all non-superusers automatically. Only the database owner can register or remove system principals. ### Administration Admin operations use the same `is_allowed()` engine as everything else — no separate code path. **System table RLS** `morbac.*` tables have RLS policies. `is_allowed()` and all its internal callees are `SECURITY DEFINER`, running as the extension owner and bypassing RLS. This breaks the recursion: RLS policies call `is_allowed()`, which queries morbac tables without re-triggering the policies. The database owner (superuser) bypasses RLS by default — use that privilege only during bootstrap. **System view names** The extension seeds built-in activities (`create`, `read`, `update`, `delete`) and system view names (`orgs`, `roles`, `rules`, `user_roles`, `contexts`, `activities`, `views`, `delegations`, `cross_org_rules`, `user_rules`, `global_rules`, `system_principals`) at install time. These names are config-driven. Override with `morbac.set_config()` to use your own naming conventions — the new name must then exist in `morbac.views` and your rules must reference it: ```sql -- Rename 'rules' to 'policies' in your system SELECT morbac.set_config('system_view.rules', 'policies'); INSERT INTO morbac.views (name, description) VALUES ('policies', 'Authorization policies'); -- Your rules must now grant 'create'/'delete'/... on 'policies', not 'rules' ``` **Granting access to system tables** Insert rules like any other rule: ```sql INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) SELECT o.id, r.id, v.activity, v.view, c.id, 'permission' FROM (VALUES ('manager', 'create', 'rules'), ('manager', 'delete', 'rules'), ('hr_manager', 'create', 'user_roles'), ('hr_manager', 'delete', 'user_roles') ) AS v(role_name, activity, view) JOIN morbac.orgs o ON o.name = 'Acme Corp' JOIN morbac.roles r ON r.org_id = o.id AND r.name = v.role_name JOIN morbac.contexts c ON c.name = 'always'; ``` **Bootstrapping the first organization** As the database owner (bypasses RLS): ```sql INSERT INTO morbac.orgs (name) VALUES ('Acme Corp'); INSERT INTO morbac.roles (org_id, name) SELECT id, 'superuser' FROM morbac.orgs WHERE name = 'Acme Corp'; -- Grant full access to all system views INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) SELECT o.id, r.id, a.activity, v.view, c.id, 'permission' FROM morbac.orgs o JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'superuser' JOIN morbac.contexts c ON c.name = 'always' CROSS JOIN unnest(ARRAY['create','read','update','delete']) AS a(activity) CROSS JOIN unnest(ARRAY['orgs','roles','rules','user_roles','contexts','activities','views','delegations','cross_org_rules']) AS v(view) WHERE o.name = 'Acme Corp'; INSERT INTO morbac.user_roles (user_id, role_id, org_id) SELECT 'your-user-uuid'::uuid, r.id, o.id FROM morbac.orgs o JOIN morbac.roles r ON r.org_id = o.id WHERE o.name = 'Acme Corp' AND r.name = 'superuser'; ``` After this, that user can manage the system through the application without database owner access. ### Temporal Constraints 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: X-Org-Id: # single org X-Org-Ids: ,, # 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)