diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 7a1f5da..0000000 --- a/.drone.yml +++ /dev/null @@ -1,56 +0,0 @@ -# Per-repo Drone pipeline template for an extracted @crudy/* package or module (RFC 0006). -# Copy this to .drone.yml in the standalone repo. It consumes dependencies from Verdaccio and -# publishes the package on a tag. Set the `verdaccio_url` and `npm_token` Drone secrets. ---- -kind: pipeline -type: docker -name: default - -trigger: - event: - - push - - pull_request - - tag - -steps: - - name: install - image: node:24 - environment: - VERDACCIO: - from_secret: verdaccio_url - commands: - - npm ci --registry "$VERDACCIO" - - - name: lint - image: node:24 - commands: - - npm run --if-present lint -- --max-warnings=0 - - - name: typecheck - image: node:24 - commands: - - npx tsc --noEmit - - - name: test - image: node:24 - commands: - - npm test --if-present - - - name: build - image: node:24 - commands: - - npm run --if-present build - - - name: publish - image: node:24 - environment: - VERDACCIO: - from_secret: verdaccio_url - NPM_TOKEN: - from_secret: npm_token - commands: - - echo "//${VERDACCIO#*://}:_authToken=$NPM_TOKEN" > .npmrc - - npm publish --registry "$VERDACCIO" - when: - event: - - tag diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..320c107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +package-lock.json diff --git a/LICENSE b/LICENSE index dc94a5b..bea2f00 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +MIT License - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +Copyright (c) 2025-2026 Marc Villain - 1. Definitions. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2025-2026 Marc Villain - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE deleted file mode 100644 index 0b11931..0000000 --- a/NOTICE +++ /dev/null @@ -1,4 +0,0 @@ -@crudy/pgmorbac -Copyright 2025-2026 Marc Villain - -This product includes software developed as part of the Crudy project. diff --git a/README.md b/README.md index 8c38d67..0e91bff 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ -# @crudy/pgmorbac +# @crudy/pgmorbac-fastify **Multi-OrBAC permission helpers for Fastify and PostgreSQL.** -[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node](https://img.shields.io/badge/Node-%3E%3D18-brightgreen.svg)](https://nodejs.org/) -This is the Node.js companion to the [`pgmorbac` PostgreSQL extension](https://github.com/crudylabs/pgmorbac). The extension implements the Multi-OrBAC access control model in the database (`morbac` schema); this package wires it into a Fastify backend: permission checks, RLS session context, management routes, a PostgREST data proxy, and the migration loader that ships the extension SQL. +This is the Node.js companion to the [`pgmorbac` PostgreSQL extension](https://github.com/crudylabs/pgmorbac). The extension implements the Multi-OrBAC access control model in the database (`morbac` schema); this package wires it into a Fastify backend: permission checks, RLS session context, management routes, a PostgREST data proxy, and the migration loader for the extension SQL from [`@crudy/pgmorbac`](https://www.npmjs.com/package/@crudy/pgmorbac). -> The SQL extension is a separate project under the MIT license. This helper package is Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). +> MIT, like the pgmorbac SQL extension it builds on. See [LICENSE](LICENSE). ## Install ```bash -npm install @crudy/pgmorbac +npm install @crudy/pgmorbac-fastify ``` Peer dependencies (both optional, install the ones you use): `fastify >=5`, `pg >=8`. @@ -23,14 +23,14 @@ The package is split into focused entry points so you only pull in what you need | Import | Purpose | |--------|---------| -| `@crudy/pgmorbac` | RLS session context and shared domain types | -| `@crudy/pgmorbac/permissions` | `hasPermission`, org-scope resolution, admin checks | -| `@crudy/pgmorbac/proxy` | PostgREST data proxy with per-route permissions | -| `@crudy/pgmorbac/plugin` | Fastify management routes (orgs, roles, rules, users, delegations) | -| `@crudy/pgmorbac/list-query` | PostgREST-style sort/filter/pagination parsing | -| `@crudy/pgmorbac/schemas` | JSON schema response fragments | -| `@crudy/pgmorbac/migrations` | Loads the bundled `morbac` extension SQL as migrations | -| `@crudy/pgmorbac/api` | Domain type definitions (`Org`, `Role`, `Rule`, ...) | +| `@crudy/pgmorbac-fastify` | RLS session context and shared domain types | +| `@crudy/pgmorbac-fastify/permissions` | `hasPermission`, org-scope resolution, admin checks | +| `@crudy/pgmorbac-fastify/proxy` | PostgREST data proxy with per-route permissions | +| `@crudy/pgmorbac-fastify/plugin` | Fastify management routes (orgs, roles, rules, users, delegations) | +| `@crudy/pgmorbac-fastify/list-query` | PostgREST-style sort/filter/pagination parsing | +| `@crudy/pgmorbac-fastify/schemas` | JSON schema response fragments | +| `@crudy/pgmorbac-fastify/migrations` | Loads the bundled `morbac` extension SQL as migrations | +| `@crudy/pgmorbac-fastify/api` | Domain type definitions (`Org`, `Role`, `Rule`, ...) | ## Usage @@ -39,7 +39,7 @@ The package is split into focused entry points so you only pull in what you need Every org-scoped route authorizes with `hasPermission`. Authentication is necessary but not sufficient; the check calls `morbac.is_allowed` and requires an `X-Org-Id` header. ```ts -import { hasPermission } from '@crudy/pgmorbac/permissions'; +import { hasPermission } from '@crudy/pgmorbac-fastify/permissions'; app.get('/documents', async (req, reply) => { const orgId = req.headers['x-org-id'] as string; @@ -53,7 +53,7 @@ app.get('/documents', async (req, reply) => { Run queries under the acting user so `morbac` row-level security policies apply as a second gate behind `hasPermission`. Required for non-`BYPASSRLS` runtime roles. ```ts -import { withMorbacContext } from '@crudy/pgmorbac'; +import { withMorbacContext } from '@crudy/pgmorbac-fastify'; const rows = await withMorbacContext(pool, { userId, orgId }, async (client) => { const { rows } = await client.query('SELECT * FROM app.orgs'); @@ -66,7 +66,7 @@ const rows = await withMorbacContext(pool, { userId, orgId }, async (client) => Expose an `app`-schema table through the backend with an explicit permission. A path that is never registered does not exist (fail closed). ```ts -import { createProxyRegistry } from '@crudy/pgmorbac/proxy'; +import { createProxyRegistry } from '@crudy/pgmorbac-fastify/proxy'; const proxy = createProxyRegistry(pool, process.env.POSTGREST_URL!); proxy.proxyDataRoute(app, 'GET', '/data/documents', 'documents', { activity: 'read', view: 'documents' }); @@ -76,10 +76,10 @@ Pass `null` as the permission for any-authenticated-user access with no org chec ### Migrations -The extension SQL ships inside this package (`sql/pgmorbac--.sql`, plus `sql/upgrades/`). Feed it to your migration runner before your own DDL. +`morbacMigrations()` returns the extension SQL from `@crudy/pgmorbac` (install script plus upgrade scripts) ready for your migration runner. Feed it before your own DDL. ```ts -import { morbacMigrations } from '@crudy/pgmorbac/migrations'; +import { morbacMigrations } from '@crudy/pgmorbac-fastify/migrations'; for (const { filename, sql } of morbacMigrations()) { await runMigration(filename, sql); @@ -93,7 +93,7 @@ The base script keeps the historical filename `0001_morbac.sql` so `schema_migra Register CRUD routes for orgs, roles, rules, users, global rules, activities, and delegations. Supply optional hooks for license-limit enforcement and event emission. ```ts -import { createMgmtRoutes } from '@crudy/pgmorbac/plugin'; +import { createMgmtRoutes } from '@crudy/pgmorbac-fastify/plugin'; createMgmtRoutes({ db: pool, @@ -116,4 +116,4 @@ createMgmtRoutes({ ## License -Apache-2.0 (c) 2025-2026 Marc Villain. See [LICENSE](LICENSE) and [NOTICE](NOTICE). +MIT (c) 2025-2026 Marc Villain. See [LICENSE](LICENSE). diff --git a/package.json b/package.json index b8e2c0a..ba340e0 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,30 @@ { - "name": "@crudy/pgmorbac", + "name": "@crudy/pgmorbac-fastify", "version": "1.0.0", "type": "module", - "description": "Crudy pgmorbac - Multi-OrBAC permission helpers for Fastify and PostgreSQL", + "description": "Fastify layer over pgmorbac: permission checks, RLS session context, management routes, PostgREST data proxy, migration loader", "keywords": [ - "morbac", - "orbac", - "rbac", - "authorization", "access-control", - "postgresql", + "authorization", "fastify", - "rls", - "multi-tenant" + "morbac", + "multi-tenant", + "orbac", + "pgmorbac", + "postgresql", + "rbac", + "rls" ], "author": "Marc Villain", "homepage": "https://pgmorbac.crudy.fr", "repository": { "type": "git", - "url": "https://github.com/crudylabs/pgmorbac-node.git" + "url": "git+https://github.com/crudylabs/pgmorbac-fastify.git" }, "bugs": { - "url": "https://github.com/crudylabs/pgmorbac-node/issues" + "url": "https://github.com/crudylabs/pgmorbac-fastify/issues" }, + "license": "MIT", "exports": { ".": { "types": "./dist/index.d.ts", @@ -74,13 +76,11 @@ "types": "./dist/index.d.ts", "files": [ "dist", - "sql", "package.json", "src", "README.md", "CHANGELOG.md", - "LICENSE", - "NOTICE" + "LICENSE" ], "scripts": { "build": "tsc", @@ -88,10 +88,10 @@ "check": "tsc --noEmit" }, "publishConfig": { - "registry": "http://localhost:4873", "access": "public" }, "dependencies": { + "@crudy/pgmorbac": "^1.0.0", "bcryptjs": "^2" }, "peerDependencies": { @@ -112,6 +112,5 @@ "fastify": "^5", "pg": "^8", "typescript": "^5" - }, - "license": "Apache-2.0" + } } diff --git a/sql/pgmorbac--1.0.0.sql b/sql/pgmorbac--1.0.0.sql deleted file mode 100644 index 3d0aa18..0000000 --- a/sql/pgmorbac--1.0.0.sql +++ /dev/null @@ -1,2841 +0,0 @@ --- pgmorbac: Multi-OrBAC for PostgreSQL --- Organization-Based Access Control with multi-organization support, --- role/activity/view hierarchies, deontic modalities, and temporal constraints. --- Based on the CNRS Multi-OrBAC research model. - -CREATE SCHEMA IF NOT EXISTS morbac; - -CREATE TYPE morbac.modality AS ENUM ( - 'permission', - 'prohibition', - 'obligation', - 'recommendation' -); - -COMMENT ON TYPE morbac.modality IS 'Deontic modalities: permission, prohibition, obligation, recommendation'; - -CREATE TABLE morbac.config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - description TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -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'), - -- System view names used in RLS policies on morbac tables. - -- Override with morbac.set_config() to use your own naming conventions. - -- The configured name must exist in morbac.views and your rules must reference it. - ('system_view.orgs', 'orgs', 'View name for morbac.orgs table access control'), - ('system_view.roles', 'roles', 'View name for morbac.roles table access control'), - ('system_view.rules', 'rules', 'View name for morbac.rules table access control'), - ('system_view.user_roles', 'user_roles', 'View name for morbac.user_roles table access control'), - ('system_view.contexts', 'contexts', 'View name for morbac.contexts table access control'), - ('system_view.activities', 'activities', 'View name for morbac.activities table access control'), - ('system_view.views', 'views', 'View name for morbac.views table access control'), - ('system_view.delegations', 'delegations', 'View name for morbac.delegations table access control'), - ('system_view.cross_org_rules', 'cross_org_rules', 'View name for morbac.cross_org_rules table access control'), - ('system_view.user_rules', 'user_rules', 'View name for morbac.user_rules table access control'), - ('system_view.global_rules', 'global_rules', 'View name for morbac.global_rules table access control'), - ('system_view.system_principals', 'system_principals', 'View name for morbac.system_principals table access control'); - -CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT) -RETURNS TEXT -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -DECLARE - v_value TEXT; -BEGIN - SELECT value INTO v_value FROM morbac.config WHERE key = p_key; - RETURN v_value; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$; - -COMMENT ON FUNCTION morbac.get_config(TEXT) IS -'Get configuration value by key'; - -CREATE OR REPLACE FUNCTION morbac.set_config(p_key TEXT, p_value TEXT) -RETURNS VOID -LANGUAGE plpgsql -AS $$ -BEGIN - UPDATE morbac.config - SET value = p_value, updated_at = CURRENT_TIMESTAMP - WHERE key = p_key; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Configuration key % does not exist', p_key; - END IF; -END; -$$; - -COMMENT ON FUNCTION morbac.set_config(TEXT, TEXT) IS -'Set configuration value by key'; - -CREATE TABLE morbac.orgs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - parent_id UUID REFERENCES morbac.orgs(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - metadata JSONB DEFAULT '{}'::jsonb -); - -CREATE INDEX idx_orgs_name ON morbac.orgs(name); -CREATE INDEX idx_orgs_parent ON morbac.orgs(parent_id); - -COMMENT ON TABLE morbac.orgs IS 'Organizations - first-class entities in Multi-OrBAC with hierarchy support'; - --- Activities are global abstract actions (not org-scoped) - -CREATE TABLE morbac.activities ( - name TEXT PRIMARY KEY, - description TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -COMMENT ON TABLE morbac.activities IS 'Activities - abstract actions in OrBAC model (global)'; - --- Senior activities imply junior activities (e.g., write implies read) -CREATE TABLE morbac.activity_hierarchy ( - senior_activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, - junior_activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (senior_activity, junior_activity), - CHECK (senior_activity != junior_activity) -); - -CREATE INDEX idx_activity_hierarchy_senior ON morbac.activity_hierarchy(senior_activity); -CREATE INDEX idx_activity_hierarchy_junior ON morbac.activity_hierarchy(junior_activity); - -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; - --- Views are global abstract object categories (not org-scoped) - -CREATE TABLE morbac.views ( - name TEXT PRIMARY KEY, - description TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -COMMENT ON TABLE morbac.views IS 'Views - abstract object categories in OrBAC model (global)'; - --- Senior views inherit from junior views (e.g., confidential_documents is a documents) -CREATE TABLE morbac.view_hierarchy ( - senior_view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, - junior_view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (senior_view, junior_view), - CHECK (senior_view != junior_view) -); - -CREATE INDEX idx_view_hierarchy_senior ON morbac.view_hierarchy(senior_view); -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'), - ('user_rules', 'User-level authorization rules table'), - ('global_rules', 'System-wide authorization rules table'), - ('system_principals', 'System principals table'), - ('delegations', 'Role delegations table'), - ('cross_org_rules', 'Cross-organization rules table') -ON CONFLICT (name) DO NOTHING; - --- Roles are scoped to organizations and abstract sets of subjects - -CREATE TABLE morbac.roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(org_id, name) -); - -CREATE INDEX idx_roles_org_id ON morbac.roles(org_id); -CREATE INDEX idx_roles_org_name ON morbac.roles(org_id, name); - -COMMENT ON TABLE morbac.roles IS 'Roles scoped to organizations - abstract sets of subjects'; - --- Senior roles inherit permissions from junior roles - -CREATE TABLE morbac.role_hierarchy ( - senior_role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - junior_role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (senior_role_id, junior_role_id), - CHECK (senior_role_id != junior_role_id) -); - -CREATE INDEX idx_role_hierarchy_senior ON morbac.role_hierarchy(senior_role_id); -CREATE INDEX idx_role_hierarchy_junior ON morbac.role_hierarchy(junior_role_id); - -COMMENT ON TABLE morbac.role_hierarchy IS 'Role hierarchy - senior roles inherit from junior roles'; -COMMENT ON COLUMN morbac.role_hierarchy.senior_role_id IS 'Senior role (inherits permissions)'; -COMMENT ON COLUMN morbac.role_hierarchy.junior_role_id IS 'Junior role (provides permissions)'; - --- Maps users to roles within organizations; user_id is external (e.g., from auth system) - -CREATE TABLE morbac.user_roles ( - user_id UUID NOT NULL, - role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, - assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, role_id, org_id) -); - -CREATE INDEX idx_user_roles_user_org ON morbac.user_roles(user_id, org_id); -CREATE INDEX idx_user_roles_role ON morbac.user_roles(role_id); -CREATE INDEX idx_user_roles_fast ON morbac.user_roles(user_id, org_id) INCLUDE (role_id); - -COMMENT ON TABLE morbac.user_roles IS 'Maps users to roles within organizations'; -COMMENT ON COLUMN morbac.user_roles.user_id IS 'External user identifier'; - --- Temporary delegation of a role from one user to another, time-bounded - -CREATE TABLE morbac.delegations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - delegator_id UUID NOT NULL, - delegatee_id UUID NOT NULL, - role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, - valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), - valid_until TIMESTAMPTZ NOT NULL, - revoked BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CHECK (delegator_id != delegatee_id), - CHECK (valid_until > valid_from) -); - -CREATE INDEX idx_delegations_delegatee ON morbac.delegations(delegatee_id, org_id); -CREATE INDEX idx_delegations_validity ON morbac.delegations(valid_from, valid_until) WHERE NOT revoked; - -COMMENT ON TABLE morbac.delegations IS 'Temporary delegation of roles from one user to another'; -COMMENT ON COLUMN morbac.delegations.valid_from IS 'Delegation start time'; -COMMENT ON COLUMN morbac.delegations.valid_until IS 'Delegation end time'; -COMMENT ON COLUMN morbac.delegations.revoked IS 'Whether delegation has been revoked'; - --- Explicitly prevent users from ever getting certain roles; takes precedence over positive assignments - -CREATE TABLE morbac.negative_role_assignments ( - user_id UUID NOT NULL, - role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, - reason TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, role_id, org_id) -); - -CREATE INDEX idx_negative_assignments ON morbac.negative_role_assignments(user_id, org_id); - -COMMENT ON TABLE morbac.negative_role_assignments IS 'Explicit prohibition of role assignments'; -COMMENT ON COLUMN morbac.negative_role_assignments.reason IS 'Reason for prohibition'; - --- Mutually exclusive roles that cannot be held simultaneously - -CREATE TABLE morbac.sod_conflicts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_a_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - role_b_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, - description TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CHECK (role_a_id != role_b_id), - UNIQUE (role_a_id, role_b_id, org_id) -); - -CREATE INDEX idx_sod_conflicts_org ON morbac.sod_conflicts(org_id); -CREATE INDEX idx_sod_conflicts_roles ON morbac.sod_conflicts(role_a_id, role_b_id); - -COMMENT ON TABLE morbac.sod_conflicts IS 'Separation of Duty: mutually exclusive roles'; - --- Min/max user count constraints per role - -CREATE TABLE morbac.role_cardinality ( - role_id UUID PRIMARY KEY REFERENCES morbac.roles(id) ON DELETE CASCADE, - min_users INTEGER, - max_users INTEGER, - description TEXT, - CHECK (min_users IS NULL OR min_users >= 0), - CHECK (max_users IS NULL OR max_users >= 1), - CHECK (min_users IS NULL OR max_users IS NULL OR max_users >= min_users) -); - -COMMENT ON TABLE morbac.role_cardinality IS 'Cardinality constraints for roles (min/max number of users)'; -COMMENT ON COLUMN morbac.role_cardinality.min_users IS 'Minimum number of users required for this role'; -COMMENT ON COLUMN morbac.role_cardinality.max_users IS 'Maximum number of users allowed for this role'; - --- Roles computed dynamically based on conditions rather than explicit assignment - -CREATE TABLE morbac.derived_roles ( - role_id UUID PRIMARY KEY REFERENCES morbac.roles(id) ON DELETE CASCADE, - condition_evaluator REGPROC NOT NULL, - description TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -COMMENT ON TABLE morbac.derived_roles IS 'Roles computed dynamically based on conditions'; -COMMENT ON COLUMN morbac.derived_roles.condition_evaluator IS 'Function(user_id, org_id) returning boolean'; - --- 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 - WITH RECURSIVE org_ancestors AS ( - SELECT p_org_id AS org_id, 0 AS depth - UNION - SELECT o.parent_id, oa.depth + 1 - FROM org_ancestors oa - INNER JOIN morbac.orgs o ON o.id = oa.org_id - WHERE o.parent_id IS NOT NULL - ) - SELECT * FROM org_ancestors; -END; -$$; - -COMMENT ON FUNCTION morbac.get_org_ancestors(UUID) IS -'Returns all ancestor organizations (including self) with depth in hierarchy'; - -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 - WITH RECURSIVE org_descendants AS ( - SELECT p_org_id AS org_id, 0 AS depth - UNION - SELECT o.id, od.depth + 1 - FROM org_descendants od - INNER JOIN morbac.orgs o ON o.parent_id = od.org_id - ) - SELECT * FROM org_descendants; -END; -$$; - -COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS -'Returns all descendant organizations (including self) with depth in hierarchy'; - --- Get a named scope of organizations relative to a given org. --- --- Supported scopes: --- 'self' - the org itself only (depth = 0) --- 'children' - direct children only (descendants at depth = 1) --- 'descendants' - all descendants, excluding self (depth > 0) --- 'subtree' - self + all descendants (equivalent to get_org_descendants) --- 'parent' - direct parent only (ancestor at depth = 1) --- 'ancestors' - all ancestors, excluding self (depth > 0) --- 'lineage' - self + all ancestors (equivalent to get_org_ancestors) --- 'root' - topmost ancestor only (max depth ancestor) --- 'unattributed' - the no-org bucket; resolves to no real orgs (empty set) --- 'all' - every organization (unattributed is not an org, so it is --- not listed here; org_in_scope('all') does cover it) --- --- Optional p_max_depth limits how many levels are traversed (NULL = unlimited). -CREATE OR REPLACE FUNCTION morbac.get_org_scope( - p_org_id UUID, - p_scope TEXT, - p_max_depth INTEGER DEFAULT NULL -) -RETURNS TABLE(org_id UUID, depth INTEGER) -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -BEGIN - CASE p_scope - WHEN 'self' THEN - RETURN QUERY - SELECT p_org_id, 0; - - WHEN 'children' THEN - RETURN QUERY - SELECT d.org_id, d.depth - FROM morbac.get_org_descendants(p_org_id) d - WHERE d.depth = 1 - AND (p_max_depth IS NULL OR d.depth <= p_max_depth); - - WHEN 'descendants' THEN - RETURN QUERY - SELECT d.org_id, d.depth - FROM morbac.get_org_descendants(p_org_id) d - WHERE d.depth > 0 - AND (p_max_depth IS NULL OR d.depth <= p_max_depth); - - WHEN 'subtree' THEN - RETURN QUERY - SELECT d.org_id, d.depth - FROM morbac.get_org_descendants(p_org_id) d - WHERE (p_max_depth IS NULL OR d.depth <= p_max_depth); - - WHEN 'parent' THEN - RETURN QUERY - SELECT a.org_id, a.depth - FROM morbac.get_org_ancestors(p_org_id) a - WHERE a.depth = 1 - AND (p_max_depth IS NULL OR a.depth <= p_max_depth); - - WHEN 'ancestors' THEN - RETURN QUERY - SELECT a.org_id, a.depth - FROM morbac.get_org_ancestors(p_org_id) a - WHERE a.depth > 0 - AND (p_max_depth IS NULL OR a.depth <= p_max_depth); - - WHEN 'lineage' THEN - RETURN QUERY - SELECT a.org_id, a.depth - FROM morbac.get_org_ancestors(p_org_id) a - WHERE (p_max_depth IS NULL OR a.depth <= p_max_depth); - - WHEN 'root' THEN - RETURN QUERY - SELECT a.org_id, a.depth - FROM morbac.get_org_ancestors(p_org_id) a - ORDER BY a.depth DESC - LIMIT 1; - - WHEN 'unattributed' THEN - RETURN QUERY - SELECT NULL::UUID, 0 WHERE FALSE; - - WHEN 'all' THEN - RETURN QUERY - SELECT o.id, 0 FROM morbac.orgs o; - - ELSE - RAISE EXCEPTION 'get_org_scope: unknown scope "%". Valid scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all', p_scope; - END CASE; -END; -$$; - -COMMENT ON FUNCTION morbac.get_org_scope(UUID, TEXT, INTEGER) IS -'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root, unattributed, all. Optional p_max_depth limits traversal depth.'; - -CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id UUID) -RETURNS TABLE(role_id UUID, depth INTEGER) -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - WITH RECURSIVE effective_roles AS ( - SELECT ur.role_id, 0 AS depth - FROM morbac.user_roles ur - WHERE ur.user_id = p_user_id - AND ur.org_id = p_org_id - UNION - SELECT rh.junior_role_id, er.depth + 1 - FROM effective_roles er - INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = er.role_id - ) - SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.depth - FROM effective_roles - ORDER BY effective_roles.role_id, effective_roles.depth; -END; -$$; - -COMMENT ON FUNCTION morbac.get_effective_roles(UUID, UUID) IS -'Returns all effective roles for a user in an organization (direct + inherited via role hierarchy)'; - -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 - WITH RECURSIVE inherited_roles AS ( - SELECT p_role_id AS role_id, 0 AS depth - UNION - SELECT rh.junior_role_id, ir.depth + 1 - FROM inherited_roles ir - INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ir.role_id - ) - SELECT DISTINCT ON (inherited_roles.role_id) inherited_roles.role_id, inherited_roles.depth - FROM inherited_roles - ORDER BY inherited_roles.role_id, inherited_roles.depth; -END; -$$; - -COMMENT ON FUNCTION morbac.get_inherited_roles(UUID) IS -'Returns all roles inherited by a role (transitive closure via role hierarchy)'; - -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 - WITH RECURSIVE effective_activities AS ( - SELECT p_activity AS activity, 0 AS depth - UNION - SELECT ah.junior_activity, ea.depth + 1 - FROM effective_activities ea - INNER JOIN morbac.activity_hierarchy ah ON ah.senior_activity = ea.activity - ) - SELECT DISTINCT ON (effective_activities.activity) effective_activities.activity, effective_activities.depth - FROM effective_activities - ORDER BY effective_activities.activity, effective_activities.depth; -END; -$$; - -COMMENT ON FUNCTION morbac.get_effective_activities(TEXT) IS -'Returns all activities including those implied via activity hierarchy (e.g., write implies read)'; - -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 - WITH RECURSIVE effective_views AS ( - SELECT p_view AS view, 0 AS depth - UNION - SELECT vh.junior_view, ev.depth + 1 - FROM effective_views ev - INNER JOIN morbac.view_hierarchy vh ON vh.senior_view = ev.view - ) - SELECT DISTINCT ON (effective_views.view) effective_views.view, effective_views.depth - FROM effective_views - ORDER BY effective_views.view, effective_views.depth; -END; -$$; - -COMMENT ON FUNCTION morbac.get_effective_views(TEXT) IS -'Returns all views including parent categories via view hierarchy'; - -CREATE OR REPLACE FUNCTION morbac.get_comprehensive_roles(p_user_id UUID, p_org_id UUID) -RETURNS TABLE(role_id UUID, source TEXT, depth INTEGER) -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - WITH RECURSIVE effective_roles AS ( - -- Direct role assignments - SELECT ur.role_id, 'direct'::TEXT as source, 0 AS depth - FROM morbac.user_roles ur - WHERE ur.user_id = p_user_id - AND ur.org_id = p_org_id - AND NOT EXISTS ( - SELECT 1 FROM morbac.negative_role_assignments nra - WHERE nra.user_id = p_user_id - AND nra.role_id = ur.role_id - AND nra.org_id = p_org_id - ) - - UNION - - -- Delegated roles (active and not revoked) - SELECT d.role_id, 'delegation'::TEXT, 0 AS depth - FROM morbac.delegations d - WHERE d.delegatee_id = p_user_id - AND d.org_id = p_org_id - AND NOT d.revoked - AND now() BETWEEN d.valid_from AND d.valid_until - AND EXISTS ( - SELECT 1 FROM morbac.user_roles ur - WHERE ur.user_id = d.delegator_id - AND ur.role_id = d.role_id - AND ur.org_id = d.org_id - ) - AND NOT EXISTS ( - SELECT 1 FROM morbac.negative_role_assignments nra - WHERE nra.user_id = p_user_id - AND nra.role_id = d.role_id - AND nra.org_id = p_org_id - ) - - UNION - - -- Role hierarchy (junior roles inherited by user's roles) - SELECT rh.junior_role_id, er.source || '_inherited', er.depth + 1 - FROM effective_roles er - INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = er.role_id - ) - SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.source, effective_roles.depth - FROM effective_roles - WHERE effective_roles.role_id IS NOT NULL - ORDER BY effective_roles.role_id, effective_roles.depth; - - -- Add derived roles separately - RETURN QUERY - SELECT dr.role_id, 'derived'::TEXT, 0 - FROM morbac.derived_roles dr - INNER JOIN morbac.roles r ON r.id = dr.role_id - WHERE r.org_id = p_org_id - AND morbac.eval_derived_role(dr.condition_evaluator, p_user_id, p_org_id) - AND NOT EXISTS ( - SELECT 1 FROM morbac.negative_role_assignments nra - WHERE nra.user_id = p_user_id - AND nra.role_id = dr.role_id - AND nra.org_id = p_org_id - ); -END; -$$; - -COMMENT ON FUNCTION morbac.get_comprehensive_roles(UUID, UUID) IS -'Returns all effective roles including direct, delegated, derived, and inherited via hierarchy'; - -CREATE OR REPLACE FUNCTION morbac.eval_derived_role( - p_evaluator REGPROC, - p_user_id UUID, - p_org_id UUID -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -DECLARE - v_result BOOLEAN; -BEGIN - EXECUTE format('SELECT %s(%L, %L)', p_evaluator::text, p_user_id, p_org_id) INTO v_result; - RETURN COALESCE(v_result, FALSE); -EXCEPTION - WHEN OTHERS THEN - RETURN FALSE; -END; -$$; - -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 - -- Org target vocabulary: all (every org, unattributed included), - -- unattributed (no-org objects only), or a specific org via the tree scopes. - -- Tree scopes never match an unattributed object. - IF p_scope = 'all' THEN - RETURN TRUE; - END IF; - IF p_scope = 'unattributed' THEN - RETURN p_target_org_id IS NULL; - END IF; - IF p_target_org_id IS NULL THEN - RETURN FALSE; - END IF; - - 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). Scope unattributed matches only a NULL target; other scopes never match NULL. SECURITY DEFINER to bypass RLS on morbac.orgs.'; - --- Precomputed transitive closures for all hierarchies to avoid recursive CTEs on each request - -CREATE MATERIALIZED VIEW morbac.mv_role_closure AS -WITH RECURSIVE role_closure AS ( - SELECT id as senior_role_id, id as junior_role_id, 0 as depth - FROM morbac.roles - UNION - SELECT rh.senior_role_id, rh.junior_role_id, 1 as depth - FROM morbac.role_hierarchy rh - UNION - SELECT rc.senior_role_id, rh.junior_role_id, rc.depth + 1 - FROM role_closure rc - INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = rc.junior_role_id - WHERE rc.depth < (SELECT value::integer FROM morbac.config WHERE key = 'hierarchy_max_depth') -) -SELECT DISTINCT senior_role_id, junior_role_id, MIN(depth) as depth -FROM role_closure -GROUP BY senior_role_id, junior_role_id; - -CREATE UNIQUE INDEX idx_mv_role_closure ON morbac.mv_role_closure(senior_role_id, junior_role_id); -CREATE INDEX idx_mv_role_closure_junior ON morbac.mv_role_closure(junior_role_id); - -COMMENT ON MATERIALIZED VIEW morbac.mv_role_closure IS -'Precomputed role hierarchy transitive closure - refresh after role hierarchy changes'; - -CREATE MATERIALIZED VIEW morbac.mv_org_closure AS -WITH RECURSIVE org_closure AS ( - SELECT id as descendant_id, id as ancestor_id, 0 as depth - FROM morbac.orgs - UNION - SELECT o.id, oc.ancestor_id, oc.depth + 1 - FROM morbac.orgs o - INNER JOIN org_closure oc ON o.parent_id = oc.descendant_id - WHERE oc.depth < (SELECT value::integer FROM morbac.config WHERE key = 'hierarchy_max_depth') -) -SELECT DISTINCT descendant_id, ancestor_id, MIN(depth) as depth -FROM org_closure -GROUP BY descendant_id, ancestor_id; - -CREATE UNIQUE INDEX idx_mv_org_closure ON morbac.mv_org_closure(descendant_id, ancestor_id); -CREATE INDEX idx_mv_org_closure_ancestor ON morbac.mv_org_closure(ancestor_id); - -COMMENT ON MATERIALIZED VIEW morbac.mv_org_closure IS -'Precomputed organization hierarchy transitive closure - refresh after org hierarchy changes'; - -CREATE MATERIALIZED VIEW morbac.mv_activity_closure AS -WITH RECURSIVE activity_closure AS ( - SELECT name as senior_activity, name as junior_activity, 0 as depth - FROM morbac.activities - UNION - SELECT ah.senior_activity, ah.junior_activity, 1 as depth - FROM morbac.activity_hierarchy ah - UNION - SELECT ac.senior_activity, ah.junior_activity, ac.depth + 1 - FROM activity_closure ac - INNER JOIN morbac.activity_hierarchy ah ON ah.senior_activity = ac.junior_activity - WHERE ac.depth < (SELECT value::integer FROM morbac.config WHERE key = 'hierarchy_max_depth') -) -SELECT DISTINCT senior_activity, junior_activity, MIN(depth) as depth -FROM activity_closure -GROUP BY senior_activity, junior_activity; - -CREATE UNIQUE INDEX idx_mv_activity_closure ON morbac.mv_activity_closure(senior_activity, junior_activity); -CREATE INDEX idx_mv_activity_closure_junior ON morbac.mv_activity_closure(junior_activity); - -COMMENT ON MATERIALIZED VIEW morbac.mv_activity_closure IS -'Precomputed activity hierarchy transitive closure - refresh after activity hierarchy changes'; - -CREATE MATERIALIZED VIEW morbac.mv_view_closure AS -WITH RECURSIVE view_closure AS ( - SELECT name as senior_view, name as junior_view, 0 as depth - FROM morbac.views - UNION - SELECT vh.senior_view, vh.junior_view, 1 as depth - FROM morbac.view_hierarchy vh - UNION - SELECT vc.senior_view, vh.junior_view, vc.depth + 1 - FROM view_closure vc - INNER JOIN morbac.view_hierarchy vh ON vh.senior_view = vc.junior_view - WHERE vc.depth < (SELECT value::integer FROM morbac.config WHERE key = 'hierarchy_max_depth') -) -SELECT DISTINCT senior_view, junior_view, MIN(depth) as depth -FROM view_closure -GROUP BY senior_view, junior_view; - -CREATE UNIQUE INDEX idx_mv_view_closure ON morbac.mv_view_closure(senior_view, junior_view); -CREATE INDEX idx_mv_view_closure_junior ON morbac.mv_view_closure(junior_view); - -COMMENT ON MATERIALIZED VIEW morbac.mv_view_closure IS -'Precomputed view hierarchy transitive closure - refresh after view hierarchy changes'; - -CREATE OR REPLACE FUNCTION morbac.refresh_hierarchy_cache() -RETURNS VOID -LANGUAGE plpgsql -AS $$ -BEGIN - REFRESH MATERIALIZED VIEW CONCURRENTLY morbac.mv_role_closure; - REFRESH MATERIALIZED VIEW CONCURRENTLY morbac.mv_org_closure; - REFRESH MATERIALIZED VIEW CONCURRENTLY morbac.mv_activity_closure; - REFRESH MATERIALIZED VIEW CONCURRENTLY morbac.mv_view_closure; -END; -$$; - -COMMENT ON FUNCTION morbac.refresh_hierarchy_cache() IS -'Refresh all materialized hierarchy views - call after modifying hierarchies'; - --- Contexts represent conditions under which rules apply, implemented as callable predicates - -CREATE TABLE morbac.contexts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - description TEXT, - evaluator REGPROC NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX idx_contexts_name ON morbac.contexts(name); - -COMMENT ON TABLE morbac.contexts IS 'Contexts - conditions under which rules apply (callable predicates)'; -COMMENT ON COLUMN morbac.contexts.evaluator IS 'Function that evaluates this context (returns boolean)'; - --- Default context that always evaluates to true - -CREATE OR REPLACE FUNCTION morbac.context_always() -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -BEGIN - RETURN TRUE; -END; -$$; - -COMMENT ON FUNCTION morbac.context_always() IS 'Default context evaluator - always returns true'; - -INSERT INTO morbac.contexts (name, description, evaluator) -VALUES ( - 'always', - 'Default context - always evaluates to true', - 'morbac.context_always'::regproc -); - -CREATE OR REPLACE FUNCTION morbac.eval_context(p_context_id UUID) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -DECLARE - v_evaluator REGPROC; - v_result BOOLEAN; -BEGIN - SELECT evaluator INTO v_evaluator - FROM morbac.contexts - WHERE id = p_context_id; - - IF v_evaluator IS NULL THEN - RAISE EXCEPTION 'Context % not found', p_context_id; - END IF; - - EXECUTE format('SELECT %s()', v_evaluator::text) INTO v_result; - - RETURN COALESCE(v_result, FALSE); -END; -$$; - -COMMENT ON FUNCTION morbac.eval_context(UUID) IS 'Evaluates a context by calling its evaluator function'; - --- 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 --- 'unattributed' - no-org objects only (org_id column above is the declaring authority) --- 'all' - every org, unattributed included --- Evaluated at query time via org_in_scope() - new orgs are picked up automatically. --- --- Org target vocabulary: a specific org (the tree scopes), 'unattributed', or 'all'. - -CREATE TABLE morbac.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, - activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, - view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, - context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, - modality morbac.modality NOT NULL, - 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, 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', 'unattributed', 'all')) -); - -CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id); -CREATE INDEX idx_rules_activity_view ON morbac.rules(activity, view); -CREATE INDEX idx_rules_modality ON morbac.rules(modality); -CREATE INDEX idx_rules_lookup ON morbac.rules(org_id, role_id, activity, view, modality); -CREATE INDEX idx_rules_fast_lookup ON morbac.rules(org_id, activity, modality, view) -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 'Object scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root, unattributed, all. Selects which objects (by org) the rule reaches: a specific org via the tree scopes, unattributed for no-org objects only, or all for every org including unattributed. 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.'; - --- Trigger to maintain is_active based on temporal validity - -CREATE OR REPLACE FUNCTION morbac.rules_set_is_active() -RETURNS TRIGGER AS $$ -BEGIN - NEW.is_active := ( - (NEW.valid_from IS NULL OR NEW.valid_from <= CURRENT_TIMESTAMP) - AND (NEW.valid_until IS NULL OR NEW.valid_until > CURRENT_TIMESTAMP) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_rules_set_is_active ON morbac.rules; -CREATE TRIGGER trg_rules_set_is_active -BEFORE INSERT OR UPDATE ON morbac.rules -FOR EACH ROW EXECUTE FUNCTION morbac.rules_set_is_active(); - --- Check if a rule is currently valid based on temporal constraints - -CREATE OR REPLACE FUNCTION morbac.is_rule_valid( - p_valid_from TIMESTAMPTZ, - p_valid_until TIMESTAMPTZ -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_now TIMESTAMPTZ := CURRENT_TIMESTAMP; -BEGIN - IF p_valid_from IS NOT NULL AND v_now < p_valid_from THEN - RETURN FALSE; - END IF; - - IF p_valid_until IS NOT NULL AND v_now >= p_valid_until THEN - RETURN FALSE; - END IF; - - RETURN TRUE; -END; -$$; - -COMMENT ON FUNCTION morbac.is_rule_valid(TIMESTAMPTZ, TIMESTAMPTZ) IS -'Check if a rule is currently valid based on temporal constraints'; - --- 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(), - source_org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, - target_org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE, - activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, - view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, - context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, - modality morbac.modality NOT NULL, - priority INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - valid_from TIMESTAMPTZ, - valid_until TIMESTAMPTZ, - metadata JSONB DEFAULT '{}'::jsonb, - CHECK (source_org_id != target_org_id), - CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from), - UNIQUE(source_org_id, target_org_id, role_id, activity, view, context_id, modality) -); - -CREATE INDEX idx_cross_org_rules_source ON morbac.cross_org_rules(source_org_id, role_id); -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 'Org where user holds the role'; -COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Org where the resource resides'; - --- Direct user-level rules: Rule(user, org, activity, view, context, modality) --- --- Grants or prohibits access for a specific user in an org, bypassing the role system. --- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache(). --- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition. --- --- Org target: a specific org, or NULL for unattributed (no-org) objects. --- For the same user across every org, use morbac.global_rules. - -CREATE TABLE morbac.user_rules ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - org_id UUID REFERENCES morbac.orgs(id) ON DELETE CASCADE, - activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, - view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, - context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, - modality morbac.modality NOT NULL, - priority INTEGER, - valid_from TIMESTAMPTZ, - valid_until TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - metadata JSONB DEFAULT '{}'::jsonb, - CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from), - UNIQUE(user_id, org_id, activity, view, context_id, modality) -); - --- NULLs are distinct in UNIQUE, so the unattributed target needs its own index -CREATE UNIQUE INDEX idx_user_rules_unattributed_unique - ON morbac.user_rules(user_id, activity, view, context_id, modality) - WHERE org_id IS NULL; - -CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id); -CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view); -CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality); -CREATE INDEX idx_user_rules_lookup ON morbac.user_rules(user_id, org_id, activity, modality, view); - -COMMENT ON TABLE morbac.user_rules IS 'Direct user-level rules - grant or prohibit access for a specific user, bypassing the role system'; -COMMENT ON COLUMN morbac.user_rules.user_id IS 'User this rule applies to directly'; -COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides; NULL targets unattributed (no-org) objects'; -COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; -COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.'; - --- Global rules: Rule(user_id, activity, view, context, modality) --- --- No org_id or role_id - applies system-wide regardless of org membership or roles. --- user_id NULL = every user; non-NULL = specific user only. --- activity NULL = any activity; view NULL = any view. --- --- Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in is_allowed_nocache(). --- When activity/view are set, hierarchy resolution applies normally. - -CREATE TABLE morbac.global_rules ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID, - activity TEXT REFERENCES morbac.activities(name) ON DELETE CASCADE, - view TEXT REFERENCES morbac.views(name) ON DELETE CASCADE, - context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, - modality morbac.modality NOT NULL, - priority INTEGER, - valid_from TIMESTAMPTZ, - valid_until TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - is_active BOOLEAN NOT NULL DEFAULT FALSE, - metadata JSONB DEFAULT '{}'::jsonb, - CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from), - UNIQUE(user_id, activity, view, context_id, modality) -); - -CREATE INDEX idx_global_rules_user_id ON morbac.global_rules(user_id); -CREATE INDEX idx_global_rules_activity_view ON morbac.global_rules(activity, view); -CREATE INDEX idx_global_rules_modality ON morbac.global_rules(modality); -CREATE INDEX idx_global_rules_fast_lookup ON morbac.global_rules(modality) - INCLUDE (user_id, activity, view, context_id) - WHERE is_active = true; - -COMMENT ON TABLE morbac.global_rules IS -'System-wide rules with no org or role binding. user_id NULL = all users; activity/view NULL = any activity/view.'; -COMMENT ON COLUMN morbac.global_rules.user_id IS -'NULL = all users; non-NULL = this specific user only'; -COMMENT ON COLUMN morbac.global_rules.activity IS -'NULL = any activity; non-NULL = specific activity (hierarchy applies)'; -COMMENT ON COLUMN morbac.global_rules.view IS -'NULL = any view; non-NULL = specific view (hierarchy applies)'; - -CREATE OR REPLACE FUNCTION morbac.global_rules_set_is_active() -RETURNS TRIGGER AS $$ -BEGIN - NEW.is_active := ( - (NEW.valid_from IS NULL OR NEW.valid_from <= CURRENT_TIMESTAMP) - AND (NEW.valid_until IS NULL OR NEW.valid_until > CURRENT_TIMESTAMP) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_global_rules_set_is_active ON morbac.global_rules; -CREATE TRIGGER trg_global_rules_set_is_active -BEFORE INSERT OR UPDATE ON morbac.global_rules -FOR EACH ROW EXECUTE FUNCTION morbac.global_rules_set_is_active(); - --- System principals: backend service accounts defined at deploy time. --- --- Registered user_ids are fully protected at the trigger level: --- - No role assignments, revocations, or negative assignments --- - No user_rules (permissions or prohibitions) --- - No delegations involving them --- - No targeted global_rules prohibitions --- - All prohibitions are skipped in is_allowed_nocache() (see authorization.sql) --- --- This table has no INSERT/UPDATE/DELETE RLS policies - only the database owner --- can register or remove system principals (done in SQL at deploy time). --- SELECT is gated by is_allowed() like all other system tables. - -CREATE TABLE morbac.system_principals ( - user_id UUID PRIMARY KEY, - description TEXT -); - -COMMENT ON TABLE morbac.system_principals IS -'Registry of backend service accounts. Immutable at the trigger level - no policy can touch them.'; -COMMENT ON COLUMN morbac.system_principals.user_id IS -'External user UUID of the service account'; - --- Shared guard for tables with a single user_id column - -CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END; - IF EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = v_user_id) THEN - RAISE EXCEPTION 'operation blocked: % is a system principal', v_user_id; - END IF; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_system_principal_user_roles -BEFORE INSERT OR UPDATE OR DELETE ON morbac.user_roles -FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal(); - -CREATE TRIGGER trg_system_principal_user_rules -BEFORE INSERT OR UPDATE OR DELETE ON morbac.user_rules -FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal(); - -CREATE TRIGGER trg_system_principal_negative_roles -BEFORE INSERT OR UPDATE OR DELETE ON morbac.negative_role_assignments -FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal(); - --- Delegations have two user_id columns - -CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal_delegation() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM morbac.system_principals - WHERE user_id IN (NEW.delegator_id, NEW.delegatee_id) - ) THEN - RAISE EXCEPTION 'operation blocked: delegations cannot involve system principals'; - END IF; - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_system_principal_delegations -BEFORE INSERT OR UPDATE ON morbac.delegations -FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal_delegation(); - --- Global rules: all operations on rows belonging to a system principal are blocked. --- This protects both the permission rules defined for them and prevents prohibitions --- from being added against them. Define their rules at deploy time as the DB owner. - -CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal_global_rule() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END; - IF v_user_id IS NOT NULL - AND EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = v_user_id) - THEN - -- allow initial deploy-time insert when no rules exist yet for this principal - IF TG_OP = 'INSERT' - AND NOT EXISTS (SELECT 1 FROM morbac.global_rules WHERE user_id = v_user_id) - THEN - RETURN NEW; - END IF; - RAISE EXCEPTION 'operation blocked: global rules for system principal % are immutable', v_user_id; - END IF; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_system_principal_global_rules -BEFORE INSERT OR UPDATE OR DELETE ON morbac.global_rules -FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal_global_rule(); - --- Optional whitelist of valid (activity, view) pairs. --- If any binding exists for an activity, rules may only use listed views. --- Activities with no bindings are unconstrained. - -CREATE TABLE morbac.activity_view_bindings ( - activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, - view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, - PRIMARY KEY (activity, view) -); - -COMMENT ON TABLE morbac.activity_view_bindings IS -'Optional whitelist of valid (activity, view) pairs. Constrains rule creation on a per-activity basis.'; - -CREATE OR REPLACE FUNCTION morbac.trg_check_activity_view_binding() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - IF EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity) - AND NOT EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity AND view = NEW.view) - THEN - RAISE EXCEPTION 'Activity "%" is not allowed on view "%" - add a binding to morbac.activity_view_bindings to permit it', - NEW.activity, NEW.view; - END IF; - RETURN NEW; -END; -$$; - -COMMENT ON FUNCTION morbac.trg_check_activity_view_binding() IS -'Blocks rule creation when an activity has bindings but the target view is not among them.'; - -DROP TRIGGER IF EXISTS trg_activity_view_binding_check ON morbac.rules; -CREATE TRIGGER trg_activity_view_binding_check -BEFORE INSERT OR UPDATE ON morbac.rules -FOR EACH ROW EXECUTE FUNCTION morbac.trg_check_activity_view_binding(); - -DROP TRIGGER IF EXISTS trg_activity_view_binding_check ON morbac.cross_org_rules; -CREATE TRIGGER trg_activity_view_binding_check -BEFORE INSERT OR UPDATE ON morbac.cross_org_rules -FOR EACH ROW EXECUTE FUNCTION morbac.trg_check_activity_view_binding(); - --- Obligations and recommendations do NOT affect authorization decisions. --- They are queryable for informational purposes (task lists, compliance prompts, etc.). --- --- Conflict resolution (from Multi-OrBAC paper): --- - A prohibition voids any applicable obligation for the same (activity, view) --- - A prohibition or obligation voids any applicable recommendation for the same (activity, view) - --- Returns pending obligations not voided by an applicable prohibition -CREATE OR REPLACE FUNCTION morbac.pending_obligations( - p_user_id UUID, - p_org_id UUID -) -RETURNS TABLE( - rule_id UUID, - role_name TEXT, - activity TEXT, - view TEXT, - context_name TEXT, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql -STABLE -AS $$ -BEGIN - RETURN QUERY - SELECT - r.id, - ro.name, - r.activity, - r.view, - c.name, - r.created_at - FROM morbac.rules r - INNER JOIN morbac.roles ro ON ro.id = r.role_id - INNER JOIN morbac.contexts c ON c.id = r.context_id - WHERE r.org_id = p_org_id - AND r.modality = 'obligation' - AND r.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) - ) - AND morbac.is_rule_valid(r.valid_from, r.valid_until) - AND morbac.eval_context(r.context_id) - -- Prohibition voids obligation unless the obligation has strictly higher priority - AND NOT EXISTS ( - SELECT 1 - FROM morbac.rules p - WHERE p.org_id = p_org_id - AND p.modality = 'prohibition' - AND p.activity IN (SELECT ea.activity FROM morbac.get_effective_activities(r.activity) ea) - AND p.view IN (SELECT ev.view FROM morbac.get_effective_views(r.view) ev) - AND p.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) - ) - AND morbac.is_rule_valid(p.valid_from, p.valid_until) - AND morbac.eval_context(p.context_id) - AND COALESCE(p.priority, 0) >= COALESCE(r.priority, 0) - ) - ORDER BY r.created_at; -END; -$$; - -COMMENT ON FUNCTION morbac.pending_obligations(UUID, UUID) IS -'Returns pending obligations for a user in an organization (informational only). Prohibitions void applicable obligations per Multi-OrBAC conflict resolution.'; - --- Returns recommendations not voided by an applicable prohibition or obligation -CREATE OR REPLACE FUNCTION morbac.pending_recommendations( - p_user_id UUID, - p_org_id UUID -) -RETURNS TABLE( - rule_id UUID, - role_name TEXT, - activity TEXT, - view TEXT, - context_name TEXT, - created_at TIMESTAMPTZ -) -LANGUAGE plpgsql -STABLE -AS $$ -BEGIN - RETURN QUERY - SELECT - r.id, - ro.name, - r.activity, - r.view, - c.name, - r.created_at - FROM morbac.rules r - INNER JOIN morbac.roles ro ON ro.id = r.role_id - INNER JOIN morbac.contexts c ON c.id = r.context_id - WHERE r.org_id = p_org_id - AND r.modality = 'recommendation' - AND r.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) - ) - AND morbac.is_rule_valid(r.valid_from, r.valid_until) - AND morbac.eval_context(r.context_id) - -- Prohibition or obligation voids recommendation unless recommendation has strictly higher priority - AND NOT EXISTS ( - SELECT 1 - FROM morbac.rules p - WHERE p.org_id = p_org_id - AND p.modality IN ('prohibition', 'obligation') - AND p.activity IN (SELECT ea.activity FROM morbac.get_effective_activities(r.activity) ea) - AND p.view IN (SELECT ev.view FROM morbac.get_effective_views(r.view) ev) - AND p.role_id IN ( - SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) - ) - AND morbac.is_rule_valid(p.valid_from, p.valid_until) - AND morbac.eval_context(p.context_id) - AND COALESCE(p.priority, 0) >= COALESCE(r.priority, 0) - ) - ORDER BY r.created_at; -END; -$$; - -COMMENT ON FUNCTION morbac.pending_recommendations(UUID, UUID) IS -'Returns recommendations for a user in an organization (informational only). Prohibitions and obligations void applicable recommendations per Multi-OrBAC conflict resolution.'; - -CREATE OR REPLACE FUNCTION morbac.user_roles_in_org( - p_user_id UUID, - p_org_id UUID -) -RETURNS TABLE( - role_id UUID, - role_name TEXT -) -LANGUAGE plpgsql -STABLE -AS $$ -BEGIN - RETURN QUERY - SELECT r.id, r.name - FROM morbac.roles r - INNER JOIN morbac.user_roles ur ON ur.role_id = r.id - WHERE ur.user_id = p_user_id - AND ur.org_id = p_org_id - AND r.org_id = p_org_id; -END; -$$; - -COMMENT ON FUNCTION morbac.user_roles_in_org(UUID, UUID) IS -'Returns all roles for a user in an organization'; - -CREATE OR REPLACE FUNCTION morbac.user_has_role( - p_user_id UUID, - p_org_id UUID, - p_role_name TEXT -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_count INTEGER; -BEGIN - SELECT COUNT(*) INTO v_count - FROM morbac.roles r - INNER JOIN morbac.user_roles ur ON ur.role_id = r.id - WHERE ur.user_id = p_user_id - AND ur.org_id = p_org_id - AND r.org_id = p_org_id - AND r.name = p_role_name; - - RETURN v_count > 0; -END; -$$; - -COMMENT ON FUNCTION morbac.user_has_role(UUID, UUID, TEXT) IS -'Returns true if user has specific role in organization'; - --- Optional audit logging for tracking changes to security-critical tables. --- Enable/disable per table with morbac.enable_audit() / morbac.disable_audit(). - -CREATE TABLE morbac.audit_log ( - id BIGSERIAL PRIMARY KEY, - timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - user_id UUID, - org_id UUID, - table_name TEXT NOT NULL, - operation TEXT NOT NULL, - record_id UUID, - old_data JSONB, - new_data JSONB, - changed_fields TEXT[], - session_username TEXT DEFAULT SESSION_USER, - client_addr INET DEFAULT INET_CLIENT_ADDR(), - application_name TEXT DEFAULT CURRENT_SETTING('application_name', true), - metadata JSONB DEFAULT '{}'::jsonb -); - -CREATE INDEX idx_audit_log_timestamp ON morbac.audit_log(timestamp DESC); -CREATE INDEX idx_audit_log_user_id ON morbac.audit_log(user_id); -CREATE INDEX idx_audit_log_org_id ON morbac.audit_log(org_id); -CREATE INDEX idx_audit_log_table_operation ON morbac.audit_log(table_name, operation); -CREATE INDEX idx_audit_log_record_id ON morbac.audit_log(record_id); - -COMMENT ON TABLE morbac.audit_log IS 'Audit trail for security-critical operations'; -COMMENT ON COLUMN morbac.audit_log.user_id IS 'Application user (from morbac.current_user_id() if available)'; -COMMENT ON COLUMN morbac.audit_log.org_id IS 'Organization context (from morbac.current_org_id() if available)'; -COMMENT ON COLUMN morbac.audit_log.operation IS 'INSERT, UPDATE, DELETE, or custom operation name'; -COMMENT ON COLUMN morbac.audit_log.changed_fields IS 'Array of field names that changed (for UPDATE operations)'; -COMMENT ON COLUMN morbac.audit_log.session_username IS 'Database session user'; -COMMENT ON COLUMN morbac.audit_log.client_addr IS 'Client IP address'; - -CREATE OR REPLACE FUNCTION morbac.audit_trigger() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -DECLARE - v_old_data JSONB; - v_new_data JSONB; - v_changed_fields TEXT[]; - v_user_id UUID; - v_org_id UUID; - v_record_id UUID; -BEGIN - BEGIN - v_user_id := morbac.current_user_id(); - EXCEPTION WHEN OTHERS THEN - v_user_id := NULL; - END; - - BEGIN - v_org_id := morbac.current_org_id(); - EXCEPTION WHEN OTHERS THEN - v_org_id := NULL; - END; - - IF TG_OP = 'DELETE' THEN - v_old_data := row_to_json(OLD)::jsonb; - v_new_data := NULL; - v_record_id := (v_old_data->>'id')::uuid; - ELSIF TG_OP = 'INSERT' THEN - v_old_data := NULL; - v_new_data := row_to_json(NEW)::jsonb; - v_record_id := (v_new_data->>'id')::uuid; - ELSIF TG_OP = 'UPDATE' THEN - v_old_data := row_to_json(OLD)::jsonb; - v_new_data := row_to_json(NEW)::jsonb; - v_record_id := (v_new_data->>'id')::uuid; - - SELECT array_agg(key) - INTO v_changed_fields - FROM jsonb_each(v_old_data) - WHERE v_old_data->key IS DISTINCT FROM v_new_data->key; - END IF; - - IF v_org_id IS NULL THEN - IF v_new_data ? 'org_id' THEN - v_org_id := (v_new_data->>'org_id')::uuid; - ELSIF v_old_data ? 'org_id' THEN - v_org_id := (v_old_data->>'org_id')::uuid; - END IF; - END IF; - - INSERT INTO morbac.audit_log ( - user_id, - org_id, - table_name, - operation, - record_id, - old_data, - new_data, - changed_fields - ) VALUES ( - v_user_id, - v_org_id, - TG_TABLE_NAME, - TG_OP, - v_record_id, - v_old_data, - v_new_data, - v_changed_fields - ); - - IF TG_OP = 'DELETE' THEN - RETURN OLD; - ELSE - RETURN NEW; - END IF; -END; -$$; - -COMMENT ON FUNCTION morbac.audit_trigger() IS -'Generic audit trigger function - captures INSERT/UPDATE/DELETE operations'; - -CREATE OR REPLACE FUNCTION morbac.enable_audit(p_table_name TEXT) -RETURNS VOID -LANGUAGE plpgsql -AS $$ -DECLARE - v_trigger_name TEXT; -BEGIN - v_trigger_name := 'audit_' || p_table_name; - - EXECUTE format( - 'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON morbac.%I ' || - 'FOR EACH ROW EXECUTE FUNCTION morbac.audit_trigger()', - v_trigger_name, - p_table_name - ); - - RAISE NOTICE 'Audit logging enabled for morbac.%', p_table_name; -END; -$$; - -COMMENT ON FUNCTION morbac.enable_audit(TEXT) IS -'Enable audit logging on a morbac table - creates audit trigger'; - -CREATE OR REPLACE FUNCTION morbac.disable_audit(p_table_name TEXT) -RETURNS VOID -LANGUAGE plpgsql -AS $$ -DECLARE - v_trigger_name TEXT; -BEGIN - v_trigger_name := 'audit_' || p_table_name; - - EXECUTE format( - 'DROP TRIGGER IF EXISTS %I ON morbac.%I', - v_trigger_name, - p_table_name - ); - - RAISE NOTICE 'Audit logging disabled for morbac.%', p_table_name; -END; -$$; - -COMMENT ON FUNCTION morbac.disable_audit(TEXT) IS -'Disable audit logging on a morbac table - removes audit trigger'; - --- Row-Level Security helpers, compatible with PostgREST - -CREATE OR REPLACE FUNCTION morbac.current_user_id() -RETURNS UUID -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_user_id TEXT; -BEGIN - v_user_id := current_setting('morbac.user_id', TRUE); - - IF v_user_id IS NULL OR v_user_id = '' THEN - RETURN NULL; - END IF; - - RETURN v_user_id::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$; - -COMMENT ON FUNCTION morbac.current_user_id() IS -'Returns current user ID from morbac.user_id session variable'; - -CREATE OR REPLACE FUNCTION morbac.current_org_id() -RETURNS UUID -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_org_id TEXT; -BEGIN - v_org_id := current_setting('morbac.org_id', TRUE); - - IF v_org_id IS NULL OR v_org_id = '' THEN - RETURN NULL; - END IF; - - RETURN v_org_id::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$; - -COMMENT ON FUNCTION morbac.current_org_id() IS -'Returns current organization ID from morbac.org_id session variable'; - -CREATE OR REPLACE FUNCTION morbac.current_target_user_id() -RETURNS UUID -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_user_id TEXT; -BEGIN - v_user_id := current_setting('morbac.target_user_id', TRUE); - - IF v_user_id IS NULL OR v_user_id = '' THEN - RETURN NULL; - END IF; - - RETURN v_user_id::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$; - -COMMENT ON FUNCTION morbac.current_target_user_id() IS -'Returns target user ID filter from morbac.target_user_id session variable'; - --- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' or '["uuid1", null]' or '[null]'. --- A JSON null element names the no-org (unattributed) bucket, distinct from --- the real org UUIDs which populate org_ids. -CREATE OR REPLACE FUNCTION morbac.current_org_filter( - OUT org_ids UUID[], - OUT include_unattributed BOOLEAN -) -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_raw TEXT := current_setting('morbac.org_ids', TRUE); - v_json JSONB; -BEGIN - org_ids := NULL; - include_unattributed := FALSE; - - IF v_raw IS NULL OR v_raw = '' THEN - RETURN; - END IF; - - v_json := v_raw::jsonb; - org_ids := ARRAY( - SELECT x::UUID - FROM jsonb_array_elements_text(v_json) x - WHERE x IS NOT NULL - ); - include_unattributed := EXISTS ( - SELECT 1 FROM jsonb_array_elements(v_json) e WHERE e = 'null'::jsonb - ); -EXCEPTION - WHEN OTHERS THEN - org_ids := NULL; - include_unattributed := FALSE; -END; -$$; - -COMMENT ON FUNCTION morbac.current_org_filter() IS -'Parses morbac.org_ids (JSON array) once into real org UUIDs plus a flag for whether the no-org bucket (JSON null element) was requested.'; - -CREATE OR REPLACE FUNCTION morbac.current_org_ids() -RETURNS UUID[] -LANGUAGE sql -STABLE -AS $$ - SELECT org_ids FROM morbac.current_org_filter(); -$$; - -COMMENT ON FUNCTION morbac.current_org_ids() IS -'Returns the real org UUIDs from morbac.org_ids (JSON null elements excluded). See current_org_filter() for the no-org bucket flag.'; - -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 - WHERE ur.user_id = p_user_id - UNION - SELECT DISTINCT d.org_id - FROM morbac.delegations d - WHERE d.delegatee_id = p_user_id - AND NOT d.revoked - AND now() BETWEEN d.valid_from AND d.valid_until; -$$; - -COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS -'Returns all org IDs the user has any direct role or active delegation in'; - --- rls_check has two forms, distinguished by arity so a NULL never carries two --- meanings: --- --- rls_check(activity, view) -- table has no org column --- rls_check(activity, view, row_org_id[, row_user_id]) -- row-scoped by org --- --- In the 3/4-arg form row_org_id is the record's org, and a NULL value means the --- record is unattributed (no org) -- never "no org dimension". The 2-arg form --- carries no org dimension and evaluates against the session org context. - -DROP FUNCTION IF EXISTS morbac.rls_check(TEXT, TEXT, UUID, UUID); - -CREATE OR REPLACE FUNCTION morbac.rls_check( - p_activity TEXT, - p_view TEXT -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_user_id UUID; - v_org_id UUID; -BEGIN - v_user_id := morbac.current_user_id(); - IF v_user_id IS NULL THEN - RETURN FALSE; - END IF; - - v_org_id := morbac.current_org_id(); - IF v_org_id IS NOT NULL THEN - RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view); - END IF; - - RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view); -END; -$$; - -COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT) IS -'RLS helper for tables without an org column. Uses the session org context (morbac.org_id), else org-independent rules. For row-scoped tables use the 3/4-arg form.'; - --- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs. --- User scoping: morbac.target_user_id filters rows to a specific user. -CREATE OR REPLACE FUNCTION morbac.rls_check( - p_activity TEXT, - p_view TEXT, - p_row_org_id UUID, - p_row_user_id UUID DEFAULT NULL -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_user_id UUID; - v_org_id UUID; - v_org_ids UUID[]; - v_include_unattr BOOLEAN; - v_target_user_id UUID; -BEGIN - v_user_id := morbac.current_user_id(); - - IF v_user_id IS NULL THEN - RETURN FALSE; - END IF; - - -- User filter: if morbac.target_user_id is set, only rows matching that user pass - IF p_row_user_id IS NOT NULL THEN - v_target_user_id := morbac.current_target_user_id(); - IF v_target_user_id IS NOT NULL AND p_row_user_id <> v_target_user_id THEN - RETURN FALSE; - END IF; - END IF; - - -- Single org selection: exact bucket only. An unattributed row (NULL org) is - -- DISTINCT from the pin and is filtered out (use the org_ids list for both). - v_org_id := morbac.current_org_id(); - - IF v_org_id IS NOT NULL THEN - IF p_row_org_id IS DISTINCT FROM v_org_id THEN - RETURN FALSE; - END IF; - RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view); - END IF; - - -- Org list selection: real orgs and/or the unattributed bucket (JSON null element). - SELECT f.org_ids, f.include_unattributed - INTO v_org_ids, v_include_unattr - FROM morbac.current_org_filter() f; - - IF v_org_ids IS NOT NULL OR v_include_unattr THEN - IF p_row_org_id IS NULL THEN - IF NOT v_include_unattr THEN - RETURN FALSE; - END IF; - RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view); - END IF; - IF v_org_ids IS NULL OR NOT (p_row_org_id = ANY(v_org_ids)) THEN - RETURN FALSE; - END IF; - RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); - END IF; - - -- No selection set: all authorized rows. The row's own org drives the decision - -- (unattributed row -> unattributed + org-independent rules). - RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); -END; -$$; - -COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS -'RLS helper for row-scoped tables. p_row_org_id is the record org; NULL means unattributed. Select records via morbac.org_id (single org, unattributed excluded) or morbac.org_ids (JSON array; a null element adds the unattributed bucket). No selection = all authorized rows including unattributed. Pass row user_id to filter by morbac.target_user_id.'; - --- 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.'; - --- Authorization decision cache to avoid repeated expensive computations. --- TTL is configurable via morbac.config (cache_ttl_seconds). - -CREATE TABLE morbac.auth_cache ( - user_id UUID NOT NULL, - org_id UUID NOT NULL, - activity TEXT NOT NULL, - view TEXT NOT NULL, - allowed BOOLEAN NOT NULL, - computed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '5 minutes'), - PRIMARY KEY (user_id, org_id, activity, view) -); - -CREATE INDEX idx_auth_cache_expires ON morbac.auth_cache(expires_at); -CREATE INDEX idx_auth_cache_user_org ON morbac.auth_cache(user_id, org_id); - -COMMENT ON TABLE morbac.auth_cache IS -'Authorization decision cache - expires after 5 minutes or when policies change'; - -CREATE OR REPLACE FUNCTION morbac.invalidate_cache(p_user_id UUID DEFAULT NULL, p_org_id UUID DEFAULT NULL) -RETURNS VOID -LANGUAGE plpgsql -AS $$ -BEGIN - IF p_user_id IS NOT NULL AND p_org_id IS NOT NULL THEN - DELETE FROM morbac.auth_cache WHERE user_id = p_user_id AND org_id = p_org_id; - ELSIF p_org_id IS NOT NULL THEN - DELETE FROM morbac.auth_cache WHERE org_id = p_org_id; - ELSIF p_user_id IS NOT NULL THEN - DELETE FROM morbac.auth_cache WHERE user_id = p_user_id; - ELSE - DELETE FROM morbac.auth_cache; - END IF; -END; -$$; - -COMMENT ON FUNCTION morbac.invalidate_cache(UUID, UUID) IS -'Invalidate auth cache for specific user/org or all entries'; - -CREATE OR REPLACE FUNCTION morbac.cleanup_auth_cache() -RETURNS INTEGER -LANGUAGE plpgsql -AS $$ -DECLARE - v_deleted INTEGER; -BEGIN - DELETE FROM morbac.auth_cache WHERE expires_at < CURRENT_TIMESTAMP; - GET DIAGNOSTICS v_deleted = ROW_COUNT; - RETURN v_deleted; -END; -$$; - -COMMENT ON FUNCTION morbac.cleanup_auth_cache() IS -'Remove expired cache entries - call periodically via cron'; - --- Invalidate cache on rule or role changes - -CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_org_id UUID; -BEGIN - IF TG_OP = 'DELETE' THEN - BEGIN - v_org_id := OLD.org_id; - EXCEPTION WHEN undefined_column THEN - v_org_id := NULL; - END; - ELSE - BEGIN - v_org_id := NEW.org_id; - EXCEPTION WHEN undefined_column THEN - v_org_id := NULL; - END; - END IF; - IF v_org_id IS NOT NULL THEN - DELETE FROM morbac.auth_cache WHERE org_id = v_org_id; - END IF; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_invalidate_cache_rules -AFTER INSERT OR UPDATE OR DELETE ON morbac.rules -FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); - -CREATE TRIGGER trg_invalidate_cache_user_roles -AFTER INSERT OR UPDATE OR DELETE ON morbac.user_roles -FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); - -CREATE TRIGGER trg_invalidate_cache_delegations -AFTER INSERT OR UPDATE OR DELETE ON morbac.delegations -FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); - -CREATE TRIGGER trg_invalidate_cache_cross_org -AFTER INSERT OR UPDATE OR DELETE ON morbac.cross_org_rules -FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); - -CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_user_rule_change() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; - v_org_id UUID; -BEGIN - IF TG_OP = 'DELETE' THEN - v_user_id := OLD.user_id; - v_org_id := OLD.org_id; - ELSE - v_user_id := NEW.user_id; - v_org_id := NEW.org_id; - END IF; - DELETE FROM morbac.auth_cache WHERE user_id = v_user_id AND org_id = v_org_id; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_invalidate_cache_user_rules -AFTER INSERT OR UPDATE OR DELETE ON morbac.user_rules -FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_user_rule_change(); - --- Global rules have no org scope - any change invalidates the entire cache -CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_global_rule_change() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - DELETE FROM morbac.auth_cache; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_invalidate_cache_global_rules -AFTER INSERT OR UPDATE OR DELETE ON morbac.global_rules -FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_global_rule_change(); - --- system_principals changes affect prohibition bypass - invalidate per user -CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_system_principal_change() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_user_id UUID; -BEGIN - v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END; - DELETE FROM morbac.auth_cache WHERE user_id = v_user_id; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_invalidate_cache_system_principals -AFTER INSERT OR UPDATE OR DELETE ON morbac.system_principals -FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_system_principal_change(); - --- Refresh materialized hierarchy views when hierarchies change - -CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - PERFORM morbac.refresh_hierarchy_cache(); - DELETE FROM morbac.auth_cache; - RETURN COALESCE(NEW, OLD); -END; -$$; - -CREATE TRIGGER trg_refresh_role_hierarchy -AFTER INSERT OR UPDATE OR DELETE ON morbac.role_hierarchy -FOR EACH STATEMENT EXECUTE FUNCTION morbac.refresh_on_hierarchy_change(); - -CREATE TRIGGER trg_refresh_activity_hierarchy -AFTER INSERT OR UPDATE OR DELETE ON morbac.activity_hierarchy -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(); - --- Validation functions for SoD, cardinality, and rule conflict detection. --- --- Rule conflict detection (from Multi-OrBAC paper): --- Conflicts exist when two rules share the same (org, role, activity, view, context) --- but their modalities create a situation where one permanently overrides the other: --- - prohibition + permission -> permission is dead (always overridden) --- - prohibition + obligation -> obligation is always voided --- - prohibition + recommendation -> recommendation is always voided --- - obligation + recommendation -> recommendation is always voided --- --- Note: hierarchy-based overlaps are intentional design, not flagged here. - -CREATE OR REPLACE FUNCTION morbac.check_sod_violation( - p_user_id UUID, - p_role_id UUID, - p_org_id UUID -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_conflict_exists BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM morbac.user_roles ur - INNER JOIN morbac.sod_conflicts sod ON ( - (sod.role_a_id = ur.role_id AND sod.role_b_id = p_role_id) - OR (sod.role_b_id = ur.role_id AND sod.role_a_id = p_role_id) - ) - WHERE ur.user_id = p_user_id - AND ur.org_id = p_org_id - AND sod.org_id = p_org_id - ) INTO v_conflict_exists; - - RETURN v_conflict_exists; -END; -$$; - -COMMENT ON FUNCTION morbac.check_sod_violation(UUID, UUID, UUID) IS -'Returns true if assigning role would violate Separation of Duty constraints'; - -CREATE OR REPLACE FUNCTION morbac.check_cardinality_violation( - p_role_id UUID, - p_adding BOOLEAN DEFAULT TRUE -) -RETURNS TEXT -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_current_count INTEGER; - v_min_users INTEGER; - v_max_users INTEGER; -BEGIN - SELECT - COUNT(DISTINCT ur.user_id), - rc.min_users, - rc.max_users - INTO v_current_count, v_min_users, v_max_users - FROM morbac.user_roles ur - LEFT JOIN morbac.role_cardinality rc ON rc.role_id = ur.role_id - WHERE ur.role_id = p_role_id - GROUP BY rc.min_users, rc.max_users; - - IF p_adding AND v_max_users IS NOT NULL THEN - IF v_current_count >= v_max_users THEN - RETURN format('Maximum users (%s) reached for role', v_max_users); - END IF; - END IF; - - IF NOT p_adding AND v_min_users IS NOT NULL THEN - IF v_current_count <= v_min_users THEN - RETURN format('Minimum users (%s) required for role', v_min_users); - END IF; - END IF; - - RETURN NULL; -END; -$$; - -COMMENT ON FUNCTION morbac.check_cardinality_violation(UUID, BOOLEAN) IS -'Returns error message if cardinality constraint would be violated, NULL otherwise'; - -CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts( - p_org_id UUID, - p_role_id UUID, - p_activity TEXT, - p_view TEXT, - p_context_id UUID, - p_modality morbac.modality, - p_exclude_id UUID DEFAULT NULL, - p_scope TEXT DEFAULT 'self' -) -RETURNS TABLE( - conflicting_rule_id UUID, - conflicting_modality morbac.modality, - description TEXT -) -LANGUAGE plpgsql -STABLE -AS $$ -BEGIN - RETURN QUERY - SELECT - r.id, - r.modality, - CASE - WHEN p_modality = 'prohibition' - THEN 'prohibition voids existing ' || r.modality::text - WHEN r.modality = 'prohibition' - THEN p_modality::text || ' will always be overridden by existing prohibition' - WHEN p_modality = 'obligation' AND r.modality = 'recommendation' - THEN 'obligation voids existing recommendation' - ELSE - 'recommendation will always be overridden by existing obligation' - END - FROM morbac.rules r - WHERE r.org_id = p_org_id - AND r.role_id = p_role_id - AND r.activity = p_activity - AND r.view = p_view - AND r.context_id = p_context_id - AND r.scope = p_scope - AND r.modality != p_modality - AND (p_exclude_id IS NULL OR r.id != p_exclude_id) - AND ( - (p_modality = 'prohibition' AND r.modality IN ('permission', 'obligation', 'recommendation')) - OR (r.modality = 'prohibition' AND p_modality IN ('permission', 'obligation', 'recommendation')) - OR (p_modality = 'obligation' AND r.modality = 'recommendation') - OR (p_modality = 'recommendation' AND r.modality = 'obligation') - ); -END; -$$; - -COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, morbac.modality, UUID, TEXT) IS -'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission). Conflicts are scoped: only rules sharing the same scope compete, since different scopes target different object sets.'; - --- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one - -CREATE OR REPLACE FUNCTION morbac.trg_warn_rule_conflicts() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -DECLARE - v_conflict RECORD; -BEGIN - FOR v_conflict IN - SELECT * FROM morbac.detect_rule_conflicts( - NEW.org_id, NEW.role_id, NEW.activity, NEW.view, NEW.context_id, NEW.modality, - CASE WHEN TG_OP = 'UPDATE' THEN NEW.id ELSE NULL END, - NEW.scope - ) - LOOP - RAISE WARNING 'Rule conflict: % (conflicts with rule %)', - v_conflict.description, v_conflict.conflicting_rule_id; - END LOOP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS trg_rules_conflict_check ON morbac.rules; -CREATE TRIGGER trg_rules_conflict_check -BEFORE INSERT OR UPDATE ON morbac.rules -FOR EACH ROW EXECUTE FUNCTION morbac.trg_warn_rule_conflicts(); - -COMMENT ON FUNCTION morbac.trg_warn_rule_conflicts() IS -'Trigger function: emits a WARNING (non-blocking) when a rule conflicts with an existing rule due to modality precedence.'; - --- Authorization decision function implementing canonical OrBAC semantics. --- --- Access is allowed if and only if: --- 1. At least one applicable permission exists --- 2. AND no applicable prohibition exists (or permission has strictly higher priority) --- Default deny (no permission = deny). --- Obligations and recommendations do NOT affect authorization. --- --- Priority resolution: --- - 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) --- --- Org target (p_org_id): a specific org, or NULL meaning the object is --- unattributed (has no org). NULL never means "any org". --- --- Scope: --- - rules.scope selects which objects a rule covers: a specific org --- (self/subtree/descendants/...), 'unattributed', or 'all'. --- Evaluated at query time via org_in_scope() - new orgs are covered automatically. --- - cross_org_rules.source_org_id is always required: user must hold the role there. --- - user_rules target a specific user directly (no role required); their org_id --- is a specific org, or NULL for unattributed objects. --- - global_rules apply to every org, unattributed included. - -CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( - p_user_id UUID, - p_org_id UUID, - p_activity TEXT, - p_view TEXT -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -DECLARE - v_rule RECORD; - v_max_prohibition_priority INTEGER := NULL; - v_max_permission_priority INTEGER := NULL; - v_is_system_principal BOOLEAN; -BEGIN - v_is_system_principal := EXISTS ( - SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id - ); - - -- STEPS 1-3.5: Prohibitions - skipped entirely for system principals - IF NOT v_is_system_principal THEN - - -- STEP 1: Local prohibitions - find the highest-priority applicable one - FOR v_rule IN - SELECT r.context_id, COALESCE(r.priority, 0) AS prio - FROM morbac.rules r - 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, r.org_id)) - AND morbac.is_rule_valid(r.valid_from, r.valid_until) - ORDER BY COALESCE(r.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - v_max_prohibition_priority := v_rule.prio; - EXIT; - END IF; - END LOOP; - - -- STEP 2: Cross-org prohibitions - update max if a higher priority is found - FOR v_rule IN - SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio - FROM morbac.cross_org_rules cr - WHERE cr.target_org_id = p_org_id - AND cr.modality = 'prohibition' - AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) - AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) - ORDER BY COALESCE(cr.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN - v_max_prohibition_priority := v_rule.prio; - END IF; - EXIT; - END IF; - END LOOP; - - -- STEP 3: User-level prohibitions - direct user rules, update max if higher - FOR v_rule IN - SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio - FROM morbac.user_rules ur - WHERE ur.user_id = p_user_id - AND ur.org_id IS NOT DISTINCT FROM p_org_id - AND ur.modality = 'prohibition' - AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) - ORDER BY COALESCE(ur.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN - v_max_prohibition_priority := v_rule.prio; - END IF; - EXIT; - END IF; - END LOOP; - - -- STEP 3.5: Global prohibitions - FOR v_rule IN - SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio - FROM morbac.global_rules gr - WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) - AND gr.modality = 'prohibition' - AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))) - AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view))) - AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) - ORDER BY COALESCE(gr.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN - v_max_prohibition_priority := v_rule.prio; - END IF; - EXIT; - END IF; - END LOOP; - - END IF; -- v_is_system_principal - - -- STEP 4: Local permissions - find the highest-priority applicable one - FOR v_rule IN - SELECT r.context_id, COALESCE(r.priority, 0) AS prio - FROM morbac.rules r - 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, r.org_id)) - AND morbac.is_rule_valid(r.valid_from, r.valid_until) - ORDER BY COALESCE(r.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - v_max_permission_priority := v_rule.prio; - EXIT; - END IF; - END LOOP; - - -- STEP 5: Cross-org permissions - update max if higher found - FOR v_rule IN - SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio - FROM morbac.cross_org_rules cr - WHERE cr.target_org_id = p_org_id - AND cr.modality = '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 morbac.is_rule_valid(cr.valid_from, cr.valid_until) - ORDER BY COALESCE(cr.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN - v_max_permission_priority := v_rule.prio; - END IF; - EXIT; - END IF; - END LOOP; - - -- STEP 6: User-level permissions - direct user rules, update max if higher - FOR v_rule IN - SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio - FROM morbac.user_rules ur - WHERE ur.user_id = p_user_id - AND ur.org_id IS NOT DISTINCT FROM p_org_id - AND ur.modality = 'permission' - AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) - ORDER BY COALESCE(ur.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN - v_max_permission_priority := v_rule.prio; - END IF; - EXIT; - END IF; - END LOOP; - - -- STEP 6.5: Global permissions - FOR v_rule IN - SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio - FROM morbac.global_rules gr - WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id) - AND gr.modality = 'permission' - AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))) - AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view))) - AND morbac.is_rule_valid(gr.valid_from, gr.valid_until) - ORDER BY COALESCE(gr.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN - v_max_permission_priority := v_rule.prio; - END IF; - EXIT; - END IF; - END LOOP; - - -- STEP 7: Priority resolution - IF v_max_prohibition_priority IS NULL THEN - RETURN v_max_permission_priority IS NOT NULL; - END IF; - - IF v_max_permission_priority IS NOT NULL - AND v_max_permission_priority > v_max_prohibition_priority THEN - RETURN TRUE; - END IF; - - RETURN FALSE; -END; -$$; - -COMMENT ON FUNCTION morbac.is_allowed_nocache(UUID, UUID, TEXT, TEXT) IS -'Authorization without cache - use for debugging or when cache must be bypassed'; - --- Cached authorization check (default, recommended for production use) -CREATE OR REPLACE FUNCTION morbac.is_allowed( - p_user_id UUID, - p_org_id UUID, - p_activity TEXT, - p_view TEXT -) -RETURNS BOOLEAN -LANGUAGE plpgsql -VOLATILE -SECURITY DEFINER -AS $$ -DECLARE - v_cached_result BOOLEAN; - v_computed_result BOOLEAN; - v_expires_at TIMESTAMPTZ; -BEGIN - SELECT allowed, expires_at INTO v_cached_result, v_expires_at - FROM morbac.auth_cache - WHERE user_id = p_user_id - AND org_id = p_org_id - AND activity = p_activity - AND view = p_view - AND expires_at > CURRENT_TIMESTAMP; - - IF FOUND THEN - RETURN v_cached_result; - END IF; - - v_computed_result := morbac.is_allowed_nocache(p_user_id, p_org_id, p_activity, p_view); - - -- NULL org_id (global-rules-only path) cannot be stored in auth_cache (org_id NOT NULL PK). - -- Global rule changes flush the entire cache anyway, so skipping is safe. - IF p_org_id IS NULL THEN - RETURN v_computed_result; - END IF; - - BEGIN - INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at) - VALUES ( - p_user_id, - p_org_id, - p_activity, - p_view, - v_computed_result, - CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer) - ) - ON CONFLICT (user_id, org_id, activity, view) DO UPDATE - SET allowed = v_computed_result, - computed_at = CURRENT_TIMESTAMP, - expires_at = CURRENT_TIMESTAMP + make_interval(secs => morbac.get_config('cache_ttl_seconds')::integer); - EXCEPTION WHEN read_only_sql_transaction THEN - NULL; - END; - - RETURN v_computed_result; -END; -$$; - -COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS -'Complete OrBAC authorization with caching (default) - use is_allowed_nocache() for debugging'; - --- Capability probe: does the user hold the permission in ANY context (any org --- they are a member of, or the no-org bucket, or via global rules)? Intended --- for UI feature gating, not object-level enforcement. Prohibitions are honored --- per context: a context counts only if is_allowed() returns true there. --- Pure cross-org grants into a non-member org are not counted. -CREATE OR REPLACE FUNCTION morbac.has_permission( - p_user_id UUID, - p_activity TEXT, - p_view TEXT -) -RETURNS BOOLEAN -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 - FROM morbac.get_user_orgs(p_user_id) o - WHERE morbac.is_allowed(p_user_id, o.org_id, p_activity, p_view) - ) OR morbac.is_allowed(p_user_id, NULL, p_activity, p_view); -END; -$$; - -COMMENT ON FUNCTION morbac.has_permission(UUID, TEXT, TEXT) IS -'Capability probe for UI gating: TRUE if the user is allowed the activity/view in any member org, the no-org bucket, or via global rules. Not a substitute for object-level is_allowed().'; - --- RLS policies for morbac system tables. --- --- is_allowed() and its internal callees are SECURITY DEFINER, so they run as --- the extension owner and bypass RLS. This prevents infinite recursion when --- these policies fire. --- --- View names used in rules are config-driven (system_view.*). --- Override with morbac.set_config('system_view.orgs', 'my_orgs') etc. --- The new name must exist in morbac.views and your rules must reference it. --- --- RLS is disabled for the superuser/extension owner by default (PostgreSQL --- behavior). Bootstrap the first organization and superuser role as the --- database owner before enabling this in production. - --- morbac.orgs --- Row's org context is the org itself. -ALTER TABLE morbac.orgs ENABLE ROW LEVEL SECURITY; - -CREATE POLICY orgs_select ON morbac.orgs FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), id, 'read', - morbac.get_config('system_view.orgs'))); - -CREATE POLICY orgs_insert ON morbac.orgs FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create', - morbac.get_config('system_view.orgs'))); - -CREATE POLICY orgs_update ON morbac.orgs FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), id, 'update', - morbac.get_config('system_view.orgs'))); - -CREATE POLICY orgs_delete ON morbac.orgs FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), id, 'delete', - morbac.get_config('system_view.orgs'))); - --- morbac.roles -ALTER TABLE morbac.roles ENABLE ROW LEVEL SECURITY; - -CREATE POLICY roles_select ON morbac.roles FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read', - morbac.get_config('system_view.roles'))); - -CREATE POLICY roles_insert ON morbac.roles FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create', - morbac.get_config('system_view.roles'))); - -CREATE POLICY roles_update ON morbac.roles FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update', - morbac.get_config('system_view.roles'))); - -CREATE POLICY roles_delete ON morbac.roles FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete', - morbac.get_config('system_view.roles'))); - --- morbac.rules -ALTER TABLE morbac.rules ENABLE ROW LEVEL SECURITY; - -CREATE POLICY rules_select ON morbac.rules FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read', - morbac.get_config('system_view.rules'))); - -CREATE POLICY rules_insert ON morbac.rules FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create', - morbac.get_config('system_view.rules'))); - -CREATE POLICY rules_update ON morbac.rules FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update', - morbac.get_config('system_view.rules'))); - -CREATE POLICY rules_delete ON morbac.rules FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete', - morbac.get_config('system_view.rules'))); - --- morbac.user_roles -ALTER TABLE morbac.user_roles ENABLE ROW LEVEL SECURITY; - -CREATE POLICY user_roles_select ON morbac.user_roles FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read', - morbac.get_config('system_view.user_roles'))); - -CREATE POLICY user_roles_insert ON morbac.user_roles FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create', - morbac.get_config('system_view.user_roles'))); - -CREATE POLICY user_roles_delete ON morbac.user_roles FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete', - morbac.get_config('system_view.user_roles'))); - --- morbac.contexts (global - use current session org for writes) -ALTER TABLE morbac.contexts ENABLE ROW LEVEL SECURITY; - -CREATE POLICY contexts_select ON morbac.contexts FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read', - morbac.get_config('system_view.contexts'))); - -CREATE POLICY contexts_insert ON morbac.contexts FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create', - morbac.get_config('system_view.contexts'))); - -CREATE POLICY contexts_update ON morbac.contexts FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update', - morbac.get_config('system_view.contexts'))); - -CREATE POLICY contexts_delete ON morbac.contexts FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete', - morbac.get_config('system_view.contexts'))); - --- morbac.activities (global - use current session org for writes) -ALTER TABLE morbac.activities ENABLE ROW LEVEL SECURITY; - -CREATE POLICY activities_select ON morbac.activities FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read', - morbac.get_config('system_view.activities'))); - -CREATE POLICY activities_insert ON morbac.activities FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create', - morbac.get_config('system_view.activities'))); - -CREATE POLICY activities_update ON morbac.activities FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update', - morbac.get_config('system_view.activities'))); - -CREATE POLICY activities_delete ON morbac.activities FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete', - morbac.get_config('system_view.activities'))); - --- morbac.views (global - use current session org for writes) -ALTER TABLE morbac.views ENABLE ROW LEVEL SECURITY; - -CREATE POLICY views_select ON morbac.views FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read', - morbac.get_config('system_view.views'))); - -CREATE POLICY views_insert ON morbac.views FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create', - morbac.get_config('system_view.views'))); - -CREATE POLICY views_update ON morbac.views FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update', - morbac.get_config('system_view.views'))); - -CREATE POLICY views_delete ON morbac.views FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete', - morbac.get_config('system_view.views'))); - --- morbac.delegations -ALTER TABLE morbac.delegations ENABLE ROW LEVEL SECURITY; - -CREATE POLICY delegations_select ON morbac.delegations FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read', - morbac.get_config('system_view.delegations'))); - -CREATE POLICY delegations_insert ON morbac.delegations FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create', - morbac.get_config('system_view.delegations'))); - -CREATE POLICY delegations_update ON morbac.delegations FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update', - morbac.get_config('system_view.delegations'))); - -CREATE POLICY delegations_delete ON morbac.delegations FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete', - morbac.get_config('system_view.delegations'))); - --- morbac.user_rules -ALTER TABLE morbac.user_rules ENABLE ROW LEVEL SECURITY; - -CREATE POLICY user_rules_select ON morbac.user_rules FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read', - morbac.get_config('system_view.user_rules'))); - -CREATE POLICY user_rules_insert ON morbac.user_rules FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create', - morbac.get_config('system_view.user_rules'))); - -CREATE POLICY user_rules_update ON morbac.user_rules FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update', - morbac.get_config('system_view.user_rules'))); - -CREATE POLICY user_rules_delete ON morbac.user_rules FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete', - morbac.get_config('system_view.user_rules'))); - --- morbac.system_principals (no org_id - SELECT only; INSERT/UPDATE/DELETE reserved for DB owner) -ALTER TABLE morbac.system_principals ENABLE ROW LEVEL SECURITY; - -CREATE POLICY system_principals_select ON morbac.system_principals FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read', - morbac.get_config('system_view.system_principals'))); - --- morbac.global_rules (no org_id - use current session org for write checks) -ALTER TABLE morbac.global_rules ENABLE ROW LEVEL SECURITY; - -CREATE POLICY global_rules_select ON morbac.global_rules FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read', - morbac.get_config('system_view.global_rules'))); - -CREATE POLICY global_rules_insert ON morbac.global_rules FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create', - morbac.get_config('system_view.global_rules'))); - -CREATE POLICY global_rules_update ON morbac.global_rules FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update', - morbac.get_config('system_view.global_rules'))); - -CREATE POLICY global_rules_delete ON morbac.global_rules FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete', - morbac.get_config('system_view.global_rules'))); - --- morbac.cross_org_rules -ALTER TABLE morbac.cross_org_rules ENABLE ROW LEVEL SECURITY; - -CREATE POLICY cross_org_rules_select ON morbac.cross_org_rules FOR SELECT - USING (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'read', - morbac.get_config('system_view.cross_org_rules'))); - -CREATE POLICY cross_org_rules_insert ON morbac.cross_org_rules FOR INSERT - WITH CHECK (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'create', - morbac.get_config('system_view.cross_org_rules'))); - -CREATE POLICY cross_org_rules_update ON morbac.cross_org_rules FOR UPDATE - USING (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'update', - morbac.get_config('system_view.cross_org_rules'))); - -CREATE POLICY cross_org_rules_delete ON morbac.cross_org_rules FOR DELETE - USING (morbac.is_allowed(morbac.current_user_id(), source_org_id, 'delete', - morbac.get_config('system_view.cross_org_rules'))); - --- Multi-org resource pattern (guidance): --- --- For resources that belong to multiple organizations: --- --- CREATE TABLE app.documents (id UUID PRIMARY KEY, content TEXT, ...); --- --- CREATE TABLE app.document_orgs ( --- document_id UUID REFERENCES app.documents(id), --- org_id UUID REFERENCES morbac.orgs(id), --- PRIMARY KEY (document_id, org_id) --- ); --- --- ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY; --- --- CREATE POLICY document_select ON app.documents FOR SELECT --- USING ( --- EXISTS ( --- SELECT 1 FROM app.document_orgs do --- WHERE do.document_id = app.documents.id --- AND do.org_id = morbac.current_org_id() --- ) --- AND morbac.rls_check('read', 'documents') --- ); --- --- This allows a resource to be visible in multiple organizations --- while enforcing OrBAC policy within each organization context. - --- Grant usage on schema to public (adjust based on your security requirements) --- GRANT USAGE ON SCHEMA morbac TO public; --- GRANT SELECT ON ALL TABLES IN SCHEMA morbac TO public; --- GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO public; - - --- Record the installed schema version (readable via --- morbac.get_config('schema_version'); upgrade scripts bump it). -INSERT INTO morbac.config (key, value, description) -VALUES ('schema_version', '1.0.0', 'Installed pgmorbac schema version') -ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP; diff --git a/src/api.ts b/src/api.ts index df66eb2..4613849 100644 --- a/src/api.ts +++ b/src/api.ts @@ -243,7 +243,10 @@ export interface MorbacApi { revokeDelegation(token: string, orgIds: string[], id: string): Promise; } -export function createMorbacApi(apiUrl: string): MorbacApi { +// apiUrl may be a function so a browser caller resolves the origin per request +// rather than when its module loads. +export function createMorbacApi(apiUrl: string | (() => string)): MorbacApi { + const base = typeof apiUrl === 'function' ? apiUrl : () => apiUrl; async function api(method: string, path: string, token: string, orgIds: string[], body?: unknown): Promise { const headers: Record = { Authorization: `Bearer ${token}`, @@ -254,7 +257,7 @@ export function createMorbacApi(apiUrl: string): MorbacApi { headers['X-Org-Ids'] = orgIds.join(','); } if (body !== undefined) headers['Content-Type'] = 'application/json'; - const res = await fetch(`${apiUrl}${path}`, { + const res = await fetch(`${base()}${path}`, { method, headers, credentials: 'include', @@ -279,7 +282,7 @@ export function createMorbacApi(apiUrl: string): MorbacApi { const PERM_TTL_MS = 30_000; async function fetchPermission(token: string, orgId: string, activity: string, view: string): Promise { - const res = await fetch(`${apiUrl}/me/permissions/check`, { + const res = await fetch(`${base()}/me/permissions/check`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, diff --git a/src/migrations.ts b/src/migrations.ts index b57d68f..b0e8aab 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -1,40 +1,28 @@ -import { readFileSync, readdirSync, existsSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const sqlDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'sql'); +import { extensionSql, upgradeScripts, version } from '@crudy/pgmorbac'; export interface MorbacMigration { filename: string; sql: string; } -// The morbac schema ships with this package as a PGXN-style extension script -// (sql/pgmorbac--.sql) plus upgrade scripts (sql/upgrades/). For the -// crudy migration runner they are exposed as plain migrations: the base script -// keeps the historical filename 0001_morbac.sql so schema_migrations rows on -// existing databases stay valid, and upgrade scripts follow in version order -// as 0001_morbac----.sql. +// The pgmorbac install and upgrade scripts (from @crudy/pgmorbac) as plain +// migrations for a migration runner. Without CREATE EXTENSION the morbac schema is +// created up front, and the installed version is recorded. The base keeps the +// historical filename 0001_morbac.sql so schema_migrations rows on existing +// databases stay valid; its bytes must not change for an unchanged extension +// version, or boot re-runs it. Upgrades follow as 0001_morbac----.sql. export function morbacMigrations(): MorbacMigration[] { - const base = readdirSync(sqlDir) - .filter(f => /^pgmorbac--\d+\.\d+\.\d+\.sql$/.test(f)) - .sort(); - if (base.length !== 1) { - throw new Error(`[pgmorbac] Expected exactly one base script in ${sqlDir}, found ${base.length}`); - } - const migrations: MorbacMigration[] = [{ - filename: '0001_morbac.sql', - sql: readFileSync(join(sqlDir, base[0]), 'utf8'), - }]; - - const upgradesDir = join(sqlDir, 'upgrades'); - if (existsSync(upgradesDir)) { - for (const f of readdirSync(upgradesDir).filter(n => n.endsWith('.sql')).sort()) { - migrations.push({ - filename: `0001_morbac${f.replace(/^pgmorbac/, '').replace(/\.sql$/, '')}.sql`, - sql: readFileSync(join(upgradesDir, f), 'utf8'), - }); - } - } - return migrations; + const lines = extensionSql().split('\n'); + lines.splice(lines.indexOf('') + 1, 0, 'CREATE SCHEMA IF NOT EXISTS morbac;'); + const sql = lines.join('\n') + ` +-- Record the installed schema version (readable via +-- morbac.get_config('schema_version'); upgrade scripts bump it). +INSERT INTO morbac.config (key, value, description) +VALUES ('schema_version', '${version}', 'Installed pgmorbac schema version') +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP; +`; + return [ + { filename: '0001_morbac.sql', sql }, + ...upgradeScripts().map(u => ({ filename: `0001_morbac--${u.from}--${u.to}.sql`, sql: u.sql })), + ]; }