diff --git a/Makefile b/Makefile index 7112f3e..95d66eb 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ PROJECT_FILENAME = pgmorbac PROJECT_VERSION = $(shell ./tools/get_version.sh $(PROJECT_FILENAME).control) # Docker configuration -DOCKER_CONTAINER ?= postgres +DOCKER_CONTAINER ?= pgmorbac_postgres_test DOCKER_PORT ?= 5432 # For development, work on source files in src/ diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md deleted file mode 100644 index 05aeba9..0000000 --- a/docs/ADMIN_GUIDE.md +++ /dev/null @@ -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 diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 9f335a9..fcf9d46 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -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.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 @@ -181,25 +180,13 @@ Dynamically computed roles via custom functions. Inter-organizational access rules. **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 - `role_id`, `activity`, `view`: Rule specification - `context_id`: Contextual condition - `modality`: Permission or prohibition -**Behavior:** Allows roles in source organization to access resources in 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. +**Behavior:** Allows roles in a source organization to access resources in a target organization. **morbac.activity_view_bindings** @@ -322,118 +309,78 @@ FROM morbac.contexts WHERE name = 'business_hours'; - Return FALSE on errors for safe defaults - 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. - -**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** +Insert directly into `morbac.rules`. Resolve names to UUIDs with a JOIN: ```sql --- Use names instead of UUIDs -INSERT INTO morbac.policy (org_name, role_name, activity, view, modality) VALUES - ('Acme Corp', 'employee', 'read', 'documents', 'permission'); - --- Compile into actual rules -SELECT * FROM morbac.compile_policy(); +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'; ``` -**Compiler Behavior:** +For bulk inserts use a VALUES list joined to the lookup tables: -- Creates missing activities/views automatically -- Resolves names to UUIDs for orgs/roles/contexts -- Creates entries in `morbac.rules` table -- Reports errors for missing orgs/roles/contexts -- Safe to run multiple times (idempotent) -- Returns detailed results for each policy - -**When to Use Policy DSL vs Direct Rules:** - -- **Use Policy DSL**: Initial setup, bulk imports, human-readable policy files -- **Use Direct Rules**: Dynamic rules, application-generated rules, when you already have UUIDs +```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 -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 | -| `'children'` | Direct children only (depth = 1) | -| `'descendants'` | All descendants, excluding self | -| `'subtree'` | Self + all descendants | -| `'parent'` | Direct parent only | -| `'ancestors'` | All ancestors, excluding self | -| `'lineage'` | Self + all ancestors | -| `'root'` | Topmost ancestor only | +| `'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 | -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)** - -A holding company grants its auditor role read access across all subsidiaries: +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, modality) -SELECT org_id, :role_id, 'read', 'financials', 'permission' -FROM morbac.get_org_scope(:holding_org_id, 'subtree'); +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: Direct children only** - -A regional manager role applies only to first-level divisions, not deeper sub-divisions: +**Example: Regional manager applies only to direct divisions** ```sql -INSERT INTO morbac.rules (org_id, role_id, activity, view, modality) -SELECT org_id, :role_id, 'manage', 'teams', 'permission' -FROM morbac.get_org_scope(:region_org_id, 'children'); +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'; ``` -**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: - -```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`. +**`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 @@ -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'); -- 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 @@ -587,11 +534,9 @@ VALUES (project_lead_role_id, 'morbac.eval_project_lead'::regproc); ### 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** - -Global auditors can review subsidiary financial data: +**Example: Auditor from HQ accesses a subsidiary** ```sql 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' ); --- Auditor from Global HQ can now access Subsidiary data 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 --- Grant HR the ability to assign roles -INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, context_id, modality) -VALUES (org_id, hr_manager_role_id, 'assign_role', 'roles', - (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); - --- Check if HR user can assign roles -SELECT morbac.is_admin_allowed(hr_user_id, org_id, 'assign_role', 'roles'); -- TRUE +-- 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. @@ -673,7 +675,6 @@ SELECT morbac.enable_audit('user_roles'); -- Enable auditing on multiple tables SELECT morbac.enable_audit('rules'); SELECT morbac.enable_audit('delegations'); -SELECT morbac.enable_audit('admin_rules'); ``` **Disabling audit logging:** @@ -721,7 +722,7 @@ WHERE table_name = 'rules' ``` **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)` - Implement retention policies to archive old audit logs - 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); ``` +**`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. @@ -770,7 +773,9 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2); ### 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. @@ -778,10 +783,6 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2); **`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 **`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 | | **SoD** | Application logic | Database-enforced constraints | | **Cross-tenant** | Not supported | Native cross-org rules | -| **Audit** | Application layer | Admin rules | +| **Audit** | Application layer | Native audit log with field-level change tracking | --- diff --git a/src/activities.sql b/src/activities.sql index ff14cbb..87a22e0 100644 --- a/src/activities.sql +++ b/src/activities.sql @@ -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 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)'; + +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; diff --git a/src/admin_helpers.sql b/src/admin_helpers.sql deleted file mode 100644 index 5a9f2a2..0000000 --- a/src/admin_helpers.sql +++ /dev/null @@ -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'; diff --git a/src/admin_rules.sql b/src/admin_rules.sql deleted file mode 100644 index c6d6207..0000000 --- a/src/admin_rules.sql +++ /dev/null @@ -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.'; diff --git a/src/auth_cache.sql b/src/auth_cache.sql index 6f282d3..e6115b8 100644 --- a/src/auth_cache.sql +++ b/src/auth_cache.sql @@ -59,6 +59,7 @@ COMMENT ON FUNCTION morbac.cleanup_auth_cache() IS CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change() RETURNS TRIGGER LANGUAGE plpgsql +SECURITY DEFINER AS $$ DECLARE 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() RETURNS TRIGGER LANGUAGE plpgsql +SECURITY DEFINER AS $$ BEGIN 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 AFTER INSERT OR UPDATE OR DELETE ON morbac.view_hierarchy 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(); diff --git a/src/authorization.sql b/src/authorization.sql index 67f5c3d..f86a419 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -10,6 +10,11 @@ -- - Each rule has an optional integer priority (NULL = 0, lowest) -- - When both a prohibition and a permission apply, the higher-priority rule wins -- - 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( p_user_id UUID, @@ -20,6 +25,7 @@ CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( RETURNS BOOLEAN LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ DECLARE v_rule RECORD; @@ -30,11 +36,11 @@ BEGIN FOR v_rule IN SELECT r.context_id, COALESCE(r.priority, 0) AS prio 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.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)) + AND 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) ORDER BY COALESCE(r.priority, 0) DESC LOOP @@ -52,7 +58,7 @@ BEGIN AND cr.modality = 'prohibition' AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) + AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) ORDER BY COALESCE(cr.priority, 0) DESC LOOP @@ -68,11 +74,11 @@ BEGIN FOR v_rule IN SELECT r.context_id, COALESCE(r.priority, 0) AS prio 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.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)) + AND 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) ORDER BY COALESCE(r.priority, 0) DESC LOOP @@ -90,7 +96,7 @@ BEGIN AND cr.modality = 'permission' AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) + AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) ORDER BY COALESCE(cr.priority, 0) DESC LOOP @@ -129,6 +135,7 @@ CREATE OR REPLACE FUNCTION morbac.is_allowed( RETURNS BOOLEAN LANGUAGE plpgsql VOLATILE +SECURITY DEFINER AS $$ DECLARE v_cached_result BOOLEAN; diff --git a/src/config.sql b/src/config.sql index 4e51464..677a6bf 100644 --- a/src/config.sql +++ b/src/config.sql @@ -8,14 +8,27 @@ CREATE TABLE morbac.config ( COMMENT ON TABLE morbac.config IS 'Extension configuration - edit values to customize behavior'; INSERT INTO morbac.config (key, value, description) VALUES - ('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'), - ('enable_audit_by_default', 'false', 'Whether to enable audit logging by default on installation'); + ('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'), + ('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) RETURNS TEXT LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ DECLARE v_value TEXT; diff --git a/src/contexts.sql b/src/contexts.sql index 6170200..618b3eb 100644 --- a/src/contexts.sql +++ b/src/contexts.sql @@ -38,6 +38,7 @@ CREATE OR REPLACE FUNCTION morbac.eval_context(p_context_id UUID) RETURNS BOOLEAN LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ DECLARE v_evaluator REGPROC; diff --git a/src/cross_org_rules.sql b/src/cross_org_rules.sql index 4b2adc2..7e6c236 100644 --- a/src/cross_org_rules.sql +++ b/src/cross_org_rules.sql @@ -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 ( 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); 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.target_org_id IS 'Organization where resource resides'; +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 'Org where the resource resides'; diff --git a/src/hierarchy_functions.sql b/src/hierarchy_functions.sql index 1b9d024..65c6b85 100644 --- a/src/hierarchy_functions.sql +++ b/src/hierarchy_functions.sql @@ -1,9 +1,15 @@ -- 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) RETURNS TABLE(org_id UUID, depth INTEGER) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN 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) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN RETURN QUERY @@ -64,6 +71,7 @@ CREATE OR REPLACE FUNCTION morbac.get_org_scope( RETURNS TABLE(org_id UUID, depth INTEGER) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN 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) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN 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) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN RETURN QUERY @@ -180,6 +190,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_activities(p_activity TEXT) RETURNS TABLE(activity TEXT, depth INTEGER) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN RETURN QUERY @@ -203,6 +214,7 @@ CREATE OR REPLACE FUNCTION morbac.get_effective_views(p_view TEXT) RETURNS TABLE(view TEXT, depth INTEGER) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN 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) LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ BEGIN RETURN QUERY @@ -303,6 +316,7 @@ CREATE OR REPLACE FUNCTION morbac.eval_derived_role( RETURNS BOOLEAN LANGUAGE plpgsql STABLE +SECURITY DEFINER AS $$ DECLARE v_result BOOLEAN; @@ -317,3 +331,29 @@ $$; COMMENT ON FUNCTION morbac.eval_derived_role(REGPROC, UUID, UUID) IS '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.'; diff --git a/src/policy_dsl.sql b/src/policy_dsl.sql deleted file mode 100644 index 3d24b4e..0000000 --- a/src/policy_dsl.sql +++ /dev/null @@ -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'; diff --git a/src/rls.sql b/src/rls.sql index 665f72a..569fdeb 100644 --- a/src/rls.sql +++ b/src/rls.sql @@ -77,6 +77,7 @@ CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID) RETURNS TABLE(org_id UUID) LANGUAGE sql STABLE +SECURITY DEFINER AS $$ SELECT DISTINCT ur.org_id FROM morbac.user_roles ur diff --git a/src/role_assignments.sql b/src/role_assignments.sql new file mode 100644 index 0000000..062fce3 --- /dev/null +++ b/src/role_assignments.sql @@ -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.'; diff --git a/src/rules.sql b/src/rules.sql index 0bec674..495527e 100644 --- a/src/rules.sql +++ b/src/rules.sql @@ -1,4 +1,15 @@ -- 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 ( 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, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, modality morbac.modality NOT NULL, + scope TEXT NOT NULL DEFAULT 'self', priority INTEGER, valid_from TIMESTAMPTZ, valid_until TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), is_active BOOLEAN NOT NULL DEFAULT FALSE, metadata JSONB DEFAULT '{}'::jsonb, - UNIQUE(org_id, role_id, activity, view, context_id, modality), - CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from) + 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 (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root')) ); 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; 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.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.'; diff --git a/src/system_rls.sql b/src/system_rls.sql new file mode 100644 index 0000000..9773127 --- /dev/null +++ b/src/system_rls.sql @@ -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'))); diff --git a/src/views.sql b/src/views.sql index 92b3516..dbdd173 100644 --- a/src/views.sql +++ b/src/views.sql @@ -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 COLUMN morbac.view_hierarchy.senior_view IS 'Senior view (more specific)'; 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; diff --git a/tests/00_setup.sql b/tests/00_setup.sql index be5380e..4e9dbe4 100644 --- a/tests/00_setup.sql +++ b/tests/00_setup.sql @@ -173,10 +173,11 @@ INSERT INTO morbac.activities (name, description) VALUES ('read', 'Read / view data'), ('write', 'Create or modify data'), ('delete', 'Delete data'), + ('manage', 'Full management access'), ('approve', 'Approve requests or documents'), ('export', 'Export data to external format'), - ('audit', 'Audit trail review'), - ('manage', 'Full administrative control'); + ('audit', 'Audit trail review') +ON CONFLICT (name) DO NOTHING; INSERT INTO morbac.views (name, description) VALUES ('documents', 'General company documents'), @@ -185,7 +186,8 @@ INSERT INTO morbac.views (name, description) VALUES ('hr_data', 'Human resources data'), ('contracts', 'Legal contracts'), ('audit_logs', 'System audit logs'), - ('public_data', 'Publicly accessible data'); + ('public_data', 'Publicly accessible data') +ON CONFLICT (name) DO NOTHING; -- --------------------------------------------------------------------------- -- CONTEXTS @@ -219,77 +221,49 @@ INSERT INTO morbac.contexts (name, description, evaluator) VALUES SELECT name, description FROM morbac.contexts ORDER BY name; -- --------------------------------------------------------------------------- --- POLICIES (via Policy DSL — resolved by compile_policy) +-- RULES -- --------------------------------------------------------------------------- \echo '' -\echo '=== Setup: Policies ===' +\echo '=== Setup: Rules ===' --- GlobalTech HQ policies -INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) VALUES - -- Intern: read public data only - ('GlobalTech HQ', 'intern', 'read', 'public_data', 'permission', 'always'), - - -- Employee: read documents and reports; write documents only during business hours - ('GlobalTech HQ', 'employee', 'read', 'documents', 'permission', 'always'), - ('GlobalTech HQ', 'employee', 'write', 'documents', 'permission', 'business_hours'), - ('GlobalTech HQ', 'employee', 'read', 'reports', 'permission', 'always'), - ('GlobalTech HQ', 'employee', 'read', 'public_data', 'permission', 'always'), - - -- Obligation: employees must review reports weekly - ('GlobalTech HQ', 'employee', 'read', 'reports', 'obligation', 'always'), - - -- Manager: approve and delete documents; read financial data - ('GlobalTech HQ', 'manager', 'approve','documents', 'permission', 'always'), - ('GlobalTech HQ', 'manager', 'delete', 'documents', 'permission', 'always'), - ('GlobalTech HQ', 'manager', 'read', 'financial_data', 'permission', 'always'), - - -- HR Manager: full access to HR data - ('GlobalTech HQ', 'hr_manager', 'read', 'hr_data', 'permission', 'always'), - ('GlobalTech HQ', 'hr_manager', 'write', 'hr_data', 'permission', 'always'), - ('GlobalTech HQ', 'hr_manager', 'delete', 'hr_data', 'permission', 'always'), - - -- Auditor: read audit logs, financial data, and documents - ('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'permission', 'always'), - ('GlobalTech HQ', 'auditor', 'read', 'financial_data', 'permission', 'always'), - ('GlobalTech HQ', 'auditor', 'read', 'documents', 'permission', 'always'), - - -- Accountant: read and write financial data - ('GlobalTech HQ', 'accountant', 'read', 'financial_data', 'permission', 'always'), - ('GlobalTech HQ', 'accountant', 'write', 'financial_data', 'permission', 'always'), - - -- Contractor: read documents; PROHIBITED from financial and HR data - ('GlobalTech HQ', 'contractor', 'read', 'documents', 'permission', 'always'), - ('GlobalTech HQ', 'contractor', 'read', 'financial_data', 'prohibition', 'always'), - ('GlobalTech HQ', 'contractor', 'read', 'hr_data', 'prohibition', 'always'), - - -- 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(); +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', 'intern', 'read', 'public_data', 'always', 'permission'), + ('GlobalTech HQ', 'employee', 'read', 'documents', 'always', 'permission'), + ('GlobalTech HQ', 'employee', 'write', 'documents', 'business_hours','permission'), + ('GlobalTech HQ', 'employee', 'read', 'reports', 'always', 'permission'), + ('GlobalTech HQ', 'employee', 'read', 'public_data', 'always', 'permission'), + ('GlobalTech HQ', 'employee', 'read', 'reports', 'always', 'obligation'), + ('GlobalTech HQ', 'employee', 'read', 'reports', 'end_of_quarter','recommendation'), + ('GlobalTech HQ', 'employee', 'read', 'public_data', 'always', 'recommendation'), + ('GlobalTech HQ', 'manager', 'approve','documents', 'always', 'permission'), + ('GlobalTech HQ', 'manager', 'delete', 'documents', 'always', 'permission'), + ('GlobalTech HQ', 'manager', 'read', 'financial_data', 'always', 'permission'), + ('GlobalTech HQ', 'hr_manager', 'read', 'hr_data', 'always', 'permission'), + ('GlobalTech HQ', 'hr_manager', 'write', 'hr_data', 'always', 'permission'), + ('GlobalTech HQ', 'hr_manager', 'delete', 'hr_data', 'always', 'permission'), + ('GlobalTech HQ', 'auditor', 'read', 'audit_logs', 'always', 'permission'), + ('GlobalTech HQ', 'auditor', 'read', 'financial_data', 'always', 'permission'), + ('GlobalTech HQ', 'auditor', 'read', 'documents', 'always', 'permission'), + ('GlobalTech HQ', 'accountant', 'read', 'financial_data', 'always', 'permission'), + ('GlobalTech HQ', 'accountant', 'write', 'financial_data', 'always', 'permission'), + ('GlobalTech HQ', 'contractor', 'read', 'documents', 'always', 'permission'), + ('GlobalTech HQ', 'contractor', 'read', 'financial_data', 'always', 'prohibition'), + ('GlobalTech HQ', 'contractor', 'read', 'hr_data', 'always', 'prohibition'), + ('GlobalTech HQ', 'compliance_officer', 'read', 'audit_logs', 'always', 'permission'), + ('GlobalTech HQ', 'compliance_officer', 'read', 'financial_data', 'always', 'permission'), + ('Engineering Dept', 'engineer', 'read', 'documents', 'always', 'permission'), + ('Engineering Dept', 'engineer', 'write', 'documents', 'always', 'permission'), + ('Engineering Dept', 'tech_lead', 'approve','documents', 'always', 'permission'), + ('Sales Dept', 'sales_rep', 'read', 'documents', 'always', 'permission'), + ('Sales Dept', 'sales_rep', 'write', 'contracts', 'always', 'permission'), + ('Sales Dept', 'sales_manager', 'read', 'reports', 'always', 'permission'), + ('Sales Dept', 'sales_manager', 'approve','contracts', '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; -- --------------------------------------------------------------------------- -- Test infrastructure @@ -343,6 +317,6 @@ $$; \echo ' Organizations : GlobalTech HQ, Engineering Dept, Sales Dept' \echo ' Roles : 14 across 3 orgs' \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 ' Contexts : always (true), business_hours (true), after_hours (false), end_of_quarter (true)' diff --git a/tests/06_cross_org.sql b/tests/06_cross_org.sql index 7bf49a8..8ec82cf 100644 --- a/tests/06_cross_org.sql +++ b/tests/06_cross_org.sql @@ -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 ('30000000-0000-0000-0000-000000000014', '20000000-0002-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002'); --- Policies for eng_auditor in Engineering -INSERT INTO morbac.policy (org_name, role_name, activity, view, modality, context_name) -VALUES ('Engineering Dept', 'eng_auditor', 'read', 'audit_logs', 'permission', 'always'); -SELECT * FROM morbac.compile_policy(); +-- Rules for eng_auditor in Engineering +INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) +SELECT o.id, r.id, 'read', 'audit_logs', c.id, 'permission' +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 SELECT morbac.t('Nina has eng_auditor role at Engineering', diff --git a/tests/08_admin.sql b/tests/08_admin.sql deleted file mode 100644 index 4595b3a..0000000 --- a/tests/08_admin.sql +++ /dev/null @@ -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 ===' diff --git a/tests/08_system_access.sql b/tests/08_system_access.sql new file mode 100644 index 0000000..7714af3 --- /dev/null +++ b/tests/08_system_access.sql @@ -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 ===' diff --git a/tests/09_utilities.sql b/tests/09_utilities.sql index 5d935bd..ca8dde8 100644 --- a/tests/09_utilities.sql +++ b/tests/09_utilities.sql @@ -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: -- @@ -10,17 +10,15 @@ -- 4. user_has_role — checks if user holds a named role -- 5. user_roles_in_org — lists all roles for user in org -- 6. eval_context — evaluates context predicates directly --- 7. Policy DSL idempotency — compile_policy() is safe to run multiple times --- 8. Policy DSL error handling — reports errors for missing org/role --- 9. Derived roles — computed via evaluator function --- 10. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping +-- 7. Derived roles — computed via evaluator function +-- 8. 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 '09 — UTILITIES, DERIVED ROLES, RLS, POLICY DSL' +\echo '09 — UTILITIES, DERIVED ROLES, RLS' \echo '================================================================' -- --------------------------------------------------------------------------- @@ -327,70 +325,10 @@ SELECT morbac.t('eval_context(end_of_quarter) = TRUE', TRUE); -- --------------------------------------------------------------------------- --- Section 7: Policy DSL — idempotency +-- Section 7: Derived roles -- --------------------------------------------------------------------------- \echo '' -\echo '--- 7. Policy DSL idempotency ---' - --- 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 ---' +\echo '--- 7. Derived roles ---' -- Create a derived role: 'senior_employee' — dynamically granted to Dave (only) 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'; -- --------------------------------------------------------------------------- --- Section 10: RLS helper functions +-- Section 8: RLS helper functions -- --------------------------------------------------------------------------- \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 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'), TRUE); --- Judy has no role at GlobalTech -> denied even in all-orgs mode -SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in GlobalTech [no role, denied]', - morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'), +-- 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 contracts in GlobalTech [no rule, denied]', + morbac.rls_check('read', 'contracts', '10000000-0000-0000-0000-000000000001'), FALSE); -- 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; \echo '' -\echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ===' +\echo '=== Utilities, Derived Roles, and RLS Tests Completed ===' diff --git a/tests/11_scope_rules.sql b/tests/11_scope_rules.sql new file mode 100644 index 0000000..48aa8df --- /dev/null +++ b/tests/11_scope_rules.sql @@ -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 ===' diff --git a/tools/build.sh b/tools/build.sh index 2db3106..683aedb 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -23,22 +23,21 @@ BUILD_FILES=( "organizations.sql" "activities.sql" "views.sql" - "activity_view_bindings.sql" "roles.sql" "hierarchy_functions.sql" "materialized_views.sql" "contexts.sql" - "policy_dsl.sql" "rules.sql" "cross_org_rules.sql" - "admin_rules.sql" + "activity_view_bindings.sql" "obligations.sql" "audit.sql" "rls.sql" - "admin_helpers.sql" + "role_assignments.sql" "auth_cache.sql" "validation.sql" "authorization.sql" + "system_rls.sql" "footer.sql" ) diff --git a/tools/docker_test.sh b/tools/docker_test.sh index ddd1cdf..c5463ef 100755 --- a/tools/docker_test.sh +++ b/tools/docker_test.sh @@ -5,7 +5,7 @@ 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}" PG_USER="${3:-postgres}"