diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dcbe60..f01d128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,21 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.0] - 2026-07-22 +## [1.0.0] - 2026-07-25 ### Added -- Initial release of the Node.js companion to the `pgmorbac` PostgreSQL extension. +- First stable release of the Node.js companion to the `pgmorbac` PostgreSQL extension. - `hasPermission` and org-scope helpers backed by `morbac.is_allowed`. +- `withMorbacContext` for running queries under the acting user so RLS applies. - PostgREST data proxy with per-route permissions and a hard page-size cap. +- `morbacMigrations()` loader that ships the bundled extension SQL. - Fastify management routes for orgs, roles, rules, users, global rules, activities, and delegations. +- PostgREST-style list-query parsing with allowlisted sort/filter columns. - JSON schema response fragments and shared domain types. +- Unattributed org target: `UNATTRIBUTED`, `OrgTarget` and `OrgSelection` express + a specific organisation, records with no org, or every org at once. +- `hasAnyPermission` wraps the `morbac.has_permission` capability probe for UI gating. +- `withMorbacContext` accepts an org selection, serialised to `morbac.org_ids` + where a JSON null element names the unattributed bucket. -[0.1.0]: https://git.villains.fr/crudy/pgmorbac-node/releases/tag/v0.1.0 +[1.0.0]: https://git.villains.fr/crudy/pgmorbac-node/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 2065719..8c38d67 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![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://git.villains.fr/crudy/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, a PostgREST data proxy, management routes, and the JSON schemas and domain types that go with them. +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. > The SQL extension is a separate project under the MIT license. This helper package is Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). @@ -23,12 +23,14 @@ The package is split into focused entry points so you only pull in what you need | Import | Purpose | |--------|---------| -| `@crudy/pgmorbac` | Shared domain types (`DataPermission`, `Org`, `Role`, `Rule`, ...) | +| `@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/api` | Domain type definitions | +| `@crudy/pgmorbac/migrations` | Loads the bundled `morbac` extension SQL as migrations | +| `@crudy/pgmorbac/api` | Domain type definitions (`Org`, `Role`, `Rule`, ...) | ## Usage @@ -46,6 +48,19 @@ app.get('/documents', async (req, reply) => { }); ``` +### RLS session context + +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'; + +const rows = await withMorbacContext(pool, { userId, orgId }, async (client) => { + const { rows } = await client.query('SELECT * FROM app.orgs'); + return rows; +}); +``` + ### Data proxy Expose an `app`-schema table through the backend with an explicit permission. A path that is never registered does not exist (fail closed). @@ -59,6 +74,20 @@ proxy.proxyDataRoute(app, 'GET', '/data/documents', 'documents', { activity: 're Pass `null` as the permission for any-authenticated-user access with no org check. Page size is capped at `MAX_PAGE_LIMIT` (1000); over-large `limit` values are rejected, never silently clamped. +### Migrations + +The extension SQL ships inside this package (`sql/pgmorbac--.sql`, plus `sql/upgrades/`). Feed it to your migration runner before your own DDL. + +```ts +import { morbacMigrations } from '@crudy/pgmorbac/migrations'; + +for (const { filename, sql } of morbacMigrations()) { + await runMigration(filename, sql); +} +``` + +The base script keeps the historical filename `0001_morbac.sql` so `schema_migrations` rows on existing databases stay valid; upgrade scripts follow in version order. + ### Management routes Register CRUD routes for orgs, roles, rules, users, global rules, activities, and delegations. Supply optional hooks for license-limit enforcement and event emission. @@ -77,12 +106,13 @@ createMgmtRoutes({ - **Deny by default.** Unregistered routes do not exist; unauthorized reads return `404` over `403` to avoid enumeration. - **Reads are checked too.** List and detail routes require the same permission checks as writes. -- **Allowlisted queries.** Values are parameterized and columns allowlisted; user input is never interpolated. +- **Two gates stacked.** `hasPermission` (application) plus `morbac` RLS (database) when running under a non-`BYPASSRLS` role. +- **Allowlisted queries.** `list-query` parameterizes values and allowlists sortable/filterable columns; user input is never interpolated. ## Related -- [pgmorbac](https://git.villains.fr/crudy/pgmorbac) — the PostgreSQL extension (MIT) -- [Documentation](https://pgmorbac.villains.fr) — model, architecture, integration guides +- [pgmorbac](https://github.com/crudylabs/pgmorbac) — the PostgreSQL extension (MIT) +- [Documentation](https://pgmorbac.crudy.fr) — model, architecture, integration guides ## License diff --git a/package.json b/package.json index d7cb938..b8e2c0a 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,8 @@ { "name": "@crudy/pgmorbac", - "version": "0.1.0", + "version": "1.0.0", "type": "module", "description": "Crudy pgmorbac - Multi-OrBAC permission helpers for Fastify and PostgreSQL", - "license": "Apache-2.0", "keywords": [ "morbac", "orbac", @@ -16,28 +15,77 @@ "multi-tenant" ], "author": "Marc Villain", - "homepage": "https://pgmorbac.villains.fr", + "homepage": "https://pgmorbac.crudy.fr", "repository": { "type": "git", - "url": "https://git.villains.fr/crudy/pgmorbac-node.git" + "url": "https://github.com/crudylabs/pgmorbac-node.git" }, "bugs": { - "url": "https://git.villains.fr/crudy/pgmorbac-node/issues" + "url": "https://github.com/crudylabs/pgmorbac-node/issues" }, "exports": { - ".": { "types": "./dist/index.d.ts", "source": "./src/index.ts", "default": "./dist/index.js" }, - "./permissions": { "types": "./dist/permissions.d.ts", "source": "./src/permissions.ts", "default": "./dist/permissions.js" }, - "./proxy": { "types": "./dist/proxy.d.ts", "source": "./src/proxy.ts", "default": "./dist/proxy.js" }, - "./plugin": { "types": "./dist/plugin.d.ts", "source": "./src/plugin.ts", "default": "./dist/plugin.js" }, - "./schemas": { "types": "./dist/schemas.d.ts", "source": "./src/schemas.ts", "default": "./dist/schemas.js" }, - "./api": { "types": "./dist/api.d.ts", "source": "./src/api.ts", "default": "./dist/api.js" } + ".": { + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./permissions": { + "types": "./dist/permissions.d.ts", + "source": "./src/permissions.ts", + "default": "./dist/permissions.js" + }, + "./proxy": { + "types": "./dist/proxy.d.ts", + "source": "./src/proxy.ts", + "default": "./dist/proxy.js" + }, + "./list-query": { + "types": "./dist/list-query.d.ts", + "source": "./src/list-query.ts", + "default": "./dist/list-query.js" + }, + "./rls-context": { + "types": "./dist/rls-context.d.ts", + "source": "./src/rls-context.ts", + "default": "./dist/rls-context.js" + }, + "./plugin": { + "types": "./dist/plugin.d.ts", + "source": "./src/plugin.ts", + "default": "./dist/plugin.js" + }, + "./schemas": { + "types": "./dist/schemas.d.ts", + "source": "./src/schemas.ts", + "default": "./dist/schemas.js" + }, + "./api": { + "types": "./dist/api.d.ts", + "source": "./src/api.ts", + "default": "./dist/api.js" + }, + "./migrations": { + "types": "./dist/migrations.d.ts", + "source": "./src/migrations.ts", + "default": "./dist/migrations.js" + } }, "main": "./dist/index.js", "types": "./dist/index.d.ts", - "files": ["dist", "package.json", "README.md", "CHANGELOG.md", "LICENSE", "NOTICE"], + "files": [ + "dist", + "sql", + "package.json", + "src", + "README.md", + "CHANGELOG.md", + "LICENSE", + "NOTICE" + ], "scripts": { "build": "tsc", - "prepack": "npm run build" + "prepack": "npm run build", + "check": "tsc --noEmit" }, "publishConfig": { "registry": "http://localhost:4873", @@ -51,8 +99,12 @@ "pg": ">=8" }, "peerDependenciesMeta": { - "fastify": { "optional": true }, - "pg": { "optional": true } + "fastify": { + "optional": true + }, + "pg": { + "optional": true + } }, "devDependencies": { "@types/bcryptjs": "^2", @@ -60,5 +112,6 @@ "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 new file mode 100644 index 0000000..3d0aa18 --- /dev/null +++ b/sql/pgmorbac--1.0.0.sql @@ -0,0 +1,2841 @@ +-- 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 7f31c56..df66eb2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -3,6 +3,8 @@ export interface Org { name: string; parent_id: string | null; metadata: Record; + /** Names from the nearest visible ancestor down to this org. Server-computed. */ + path?: string[]; } export interface Role { @@ -18,6 +20,23 @@ export interface RoleDetail { org_name: string; name: string; description: string | null; + profile_module_id?: string | null; + profile_key?: string | null; + profile_synced?: boolean | null; +} + +export interface PermissionProfileRule { + activity: string; + view: string; + modality: 'permission' | 'prohibition'; + scope: string; +} + +export interface PermissionProfile { + module_id: string; + key: string; + content_hash: string; + rules: PermissionProfileRule[]; } export interface Activity { @@ -28,6 +47,10 @@ export interface Activity { export interface View { name: string; description: string | null; + /** Rows in this view carry no org, so grant it with an unattributed (or all) + * scope rule, a user rule with no org, or a global rule. A tree-scoped rule + * could never match. */ + no_org?: boolean; } export interface Rule { @@ -41,6 +64,9 @@ export interface Rule { modality: 'permission' | 'prohibition'; priority: number | null; scope: string; + valid_from: string | null; + valid_until: string | null; + is_active: boolean; } export interface CrossOrgRule { @@ -60,10 +86,11 @@ export interface CrossOrgRule { export interface UserRule { id: string; user_id: string; - org_id: string; + /** null = unattributed: the rule targets objects with no org. */ + org_id: string | null; user_name: string; user_email: string | null; - org_name: string; + org_name: string | null; activity: string; view: string; modality: 'permission' | 'prohibition'; @@ -165,6 +192,7 @@ export type ActivityViewBinding = { activity: string; view: string }; export interface MorbacApi { checkPermission(token: string, orgId: string, activity: string, view: string): Promise; + clearPermissionCache(): void; getOrgs(token: string, orgIds: string[]): Promise; createOrg(token: string, orgIds: string[], data: { name: string; parent_id?: string }): Promise; updateOrg(token: string, orgIds: string[], id: string, data: { name?: string; parent_id?: string | null }): Promise; @@ -173,6 +201,10 @@ export interface MorbacApi { createRole(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string }): Promise; updateRole(token: string, orgIds: string[], id: string, data: { name?: string; description?: string }): Promise; deleteRole(token: string, orgIds: string[], id: string): Promise; + getPermissionProfiles(token: string, orgIds: string[], moduleId?: string): Promise; + createRoleFromProfile(token: string, orgIds: string[], data: { org_id: string; name: string; description?: string; sync?: boolean; profile: { module_id: string; key: string } }): Promise; + attachRoleProfile(token: string, orgIds: string[], roleId: string, data: { profile: { module_id: string; key: string }; sync?: boolean }): Promise<{ role_id: string; module_id: string; profile_key: string; synced: boolean; applied_hash: string }>; + detachRoleProfile(token: string, orgIds: string[], roleId: string): Promise; getRoleHierarchy(token: string, orgIds: string[]): Promise; addRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise; removeRoleHierarchy(token: string, orgIds: string[], data: { senior_role_id: string; junior_role_id: string }): Promise; @@ -186,19 +218,21 @@ export interface MorbacApi { createCrossOrgRule(token: string, orgIds: string[], data: { source_org_id: string; source_role_id: string; target_org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise; deleteCrossOrgRule(token: string, orgIds: string[], id: string): Promise; getUserRules(token: string, orgIds: string[]): Promise; - createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise; + createUserRule(token: string, orgIds: string[], data: { user_id: string; org_id: string | null; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number }): Promise; deleteUserRule(token: string, orgIds: string[], id: string): Promise; getGlobalRules(token: string, orgIds: string[]): Promise; createGlobalRule(token: string, orgIds: string[], data: { user_id?: string | null; activity?: string | null; view?: string | null; modality: 'permission' | 'prohibition'; priority?: number }): Promise; deleteGlobalRule(token: string, orgIds: string[], id: string): Promise; getUsers(token: string, orgIds: string[]): Promise; - createUser(token: string, orgIds: string[], data: { email: string; name: string; password?: string; account_type?: 'user' | 'service' }): Promise; + createUser(token: string, orgIds: string[], data: { email: string; name: string; account_type?: 'user' | 'service'; placement?: { role_id: string; org_id: string } }): Promise; updateUser(token: string, orgIds: string[], id: string, data: { name?: string; email?: string; is_active?: boolean; reset_password?: boolean }): Promise; deleteUser(token: string, orgIds: string[], id: string): Promise; getUserKeys(token: string, orgIds: string[], userId: string): Promise; getUserPermissionOptions(token: string, orgIds: string[], userId: string): Promise>; createUserKey(token: string, orgIds: string[], userId: string, data: { name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: 'inherit' | Array<{ activity: string; view: string }> }): Promise; deleteUserKey(token: string, orgIds: string[], userId: string, keyId: string): Promise; + getUserOrgFilter(token: string, orgIds: string[], userId: string): Promise<{ org_filter: string[] }>; + updateUserOrgFilter(token: string, orgIds: string[], userId: string, orgFilter: string[]): Promise<{ org_filter: string[] }>; assignRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise; removeRole(token: string, orgIds: string[], data: { user_id: string; role_id: string; org_id: string }): Promise; getActivityUsers(token: string, orgIds: string[], name: string): Promise; @@ -228,24 +262,64 @@ export function createMorbacApi(apiUrl: string): MorbacApi { }); if (res.status === 204) return null; const data = await res.json(); - if (!res.ok) throw new Error((data as { error?: string }).error || 'Request failed'); + if (!res.ok) { + const d = data as { error?: string; message?: string; errorId?: string }; + const err = new Error(d.message || d.error || `Request failed (HTTP ${res.status})`) as + Error & { status: number; code?: string; errorId?: string }; + err.status = res.status; + err.code = d.error; + err.errorId = d.errorId; + throw err; + } return data; } + const permCache = new Map(); + const permInflight = new Map>(); + 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`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-Org-Id': orgId, + }, + credentials: 'include', + body: JSON.stringify({ p_activity: activity, p_view: view }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})) as { error?: string; message?: string; errorId?: string }; + const err = new Error(d.message || d.error || `Permission check failed (HTTP ${res.status})`) as + Error & { status: number; code?: string; errorId?: string }; + err.status = res.status; + err.code = d.error; + err.errorId = d.errorId; + throw err; + } + return res.json() as Promise; + } + return { - checkPermission: async (token, orgId, activity, view) => { - const res = await fetch(`${apiUrl}/me/permissions/check`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'X-Org-Id': orgId, - }, - credentials: 'include', - body: JSON.stringify({ p_activity: activity, p_view: view }), + checkPermission: (token, orgId, activity, view) => { + const key = `${token}\x00${orgId}\x00${activity}\x00${view}`; + const cached = permCache.get(key); + if (cached && cached.expires > Date.now()) return Promise.resolve(cached.value); + const existing = permInflight.get(key); + if (existing) return existing; + const p = fetchPermission(token, orgId, activity, view).then((value) => { + permCache.set(key, { value, expires: Date.now() + PERM_TTL_MS }); + return value; }); - if (!res.ok) return false; - return res.json() as Promise; + permInflight.set(key, p); + void p.finally(() => permInflight.delete(key)).catch(() => {}); + return p; + }, + + clearPermissionCache: () => { + permCache.clear(); + permInflight.clear(); }, getOrgs: (t, orgIds) => api('GET', '/orgs', t, orgIds) as Promise, @@ -258,6 +332,13 @@ export function createMorbacApi(apiUrl: string): MorbacApi { updateRole: (t, orgIds, id, d) => api('PATCH', `/roles/${id}`, t, orgIds, d) as Promise, deleteRole: (t, orgIds, id) => api('DELETE', `/roles/${id}`, t, orgIds) as Promise, + getPermissionProfiles: (t, orgIds, moduleId) => + api('GET', moduleId ? `/permission-profiles?module_id=${encodeURIComponent(moduleId)}` : '/permission-profiles', t, orgIds) as Promise, + createRoleFromProfile: (t, orgIds, d) => api('POST', '/roles/from-profile', t, orgIds, d) as Promise, + attachRoleProfile: (t, orgIds, roleId, d) => + api('POST', `/roles/${roleId}/profile-sync`, t, orgIds, d) as Promise<{ role_id: string; module_id: string; profile_key: string; synced: boolean; applied_hash: string }>, + detachRoleProfile: (t, orgIds, roleId) => api('DELETE', `/roles/${roleId}/profile-sync`, t, orgIds) as Promise, + getRoleHierarchy: (t, orgIds) => api('GET', '/role-hierarchy', t, orgIds) as Promise, addRoleHierarchy: (t, orgIds, d) => api('POST', '/role-hierarchy', t, orgIds, d), removeRoleHierarchy: (t, orgIds, d) => api('DELETE', '/role-hierarchy', t, orgIds, d), @@ -292,6 +373,9 @@ export function createMorbacApi(apiUrl: string): MorbacApi { createUserKey: (t, orgIds, userId, d) => api('POST', `/users/${userId}/keys`, t, orgIds, d) as Promise, deleteUserKey: (t, orgIds, userId, keyId) => api('DELETE', `/users/${userId}/keys/${keyId}`, t, orgIds) as Promise, + getUserOrgFilter: (t, orgIds, userId) => api('GET', `/users/${encodeURIComponent(userId)}/org-filter`, t, orgIds) as Promise<{ org_filter: string[] }>, + updateUserOrgFilter: (t, orgIds, userId, orgFilter) => api('PATCH', `/users/${encodeURIComponent(userId)}/org-filter`, t, orgIds, { org_filter: orgFilter }) as Promise<{ org_filter: string[] }>, + assignRole: (t, orgIds, d) => api('POST', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }), removeRole: (t, orgIds, d) => api('DELETE', `/users/${d.user_id}/roles`, t, orgIds, { role_id: d.role_id, org_id: d.org_id }), diff --git a/src/index.ts b/src/index.ts index 47d7f95..ff7b07a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,14 @@ +export { withOrgPaths, readableOrgPaths } from './org-paths.js'; +export { withMorbacContext, type MorbacContext } from './rls-context.js'; +export { + UNATTRIBUTED, + UNATTRIBUTED_ORG_TOKEN, + orgSelectionToSetting, + orgTargetsToHeader, + hasAnyPermission, + type OrgTarget, + type OrgSelection, +} from './permissions.js'; export type { DataPermission } from './proxy.js'; export type { Org, diff --git a/src/list-query.ts b/src/list-query.ts new file mode 100644 index 0000000..da058ec --- /dev/null +++ b/src/list-query.ts @@ -0,0 +1,164 @@ +import type { FastifyReply } from 'fastify'; + +/** + * Shared list-query handling for management routes, so every endpoint that + * lists things offers the same sort, filter and pagination the data tables + * expect. The frontend speaks a PostgREST-style querystring + * (`?name=ilike.*ada*&order=created_at.desc&limit=25&offset=0`); this parses + * that into SQL fragments the route composes into its own, already + * permission-scoped, query. + * + * Columns are allowlisted, values are parameterised, and the caller supplies + * the parameter start index so the filter params slot after its own. + */ + +/** + * Maps an API column name (what the client sends) to its SQL expression (what + * the query uses, e.g. 'r.name' in a joined query). Values are developer + * authored and trusted; keys are matched against the request and never + * interpolated. + */ +export type ColumnMap = Record; + +export interface ListParamsConfig { + /** Columns allowed in ORDER BY, mapped to their SQL expression. */ + sortable: ColumnMap; + /** Columns allowed to filter on, mapped to their SQL expression. */ + filterable: ColumnMap; + /** Applied when the request sets no order, e.g. 'name.asc'. */ + defaultOrder?: string; + /** Largest page a caller may request. Default 200. */ + maxLimit?: number; +} + +export interface ParsedListParams { + /** True when the request asked to paginate (sent limit or offset). */ + paginated: boolean; + /** The page size, or the returned-row count when unpaginated. */ + limit: number; + offset: number; + /** 'ORDER BY "col" ASC' or ''. */ + orderSql: string; + /** Conditions joined by AND, without a WHERE or leading AND, or ''. The route + * composes these with its own scope conditions. */ + filterSql: string; + /** Filter values, numbered from startIndex. */ + params: unknown[]; + /** ' LIMIT $n OFFSET $m', or '' when the request did not ask to paginate. */ + pageSql: string; + /** All params in order: filters, then limit and offset when paginating. */ + allParams: unknown[]; +} + +export class ListQueryError extends Error { + constructor(message: string) { super(message); this.name = 'ListQueryError'; } +} + +const RESERVED = new Set(['order', 'limit', 'offset', 'select', 'include_archived']); + +const OPERATORS: Record { sql: string; wildcard?: boolean }> = { + eq: (c, i) => ({ sql: `${c} = $${i}` }), + neq: (c, i) => ({ sql: `${c} <> $${i}` }), + gt: (c, i) => ({ sql: `${c} > $${i}` }), + gte: (c, i) => ({ sql: `${c} >= $${i}` }), + lt: (c, i) => ({ sql: `${c} < $${i}` }), + lte: (c, i) => ({ sql: `${c} <= $${i}` }), + like: (c, i) => ({ sql: `${c} LIKE $${i}`, wildcard: true }), + ilike: (c, i) => ({ sql: `${c} ILIKE $${i}`, wildcard: true }), +}; + +function parseInteger(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const n = Number(value); + if (!Number.isInteger(n) || n < 0) throw new ListQueryError(`invalid_integer:${value}`); + return n; +} + +function buildOrder(raw: string | undefined, sortable: ColumnMap, fallback: string | undefined): string { + const spec = raw ?? fallback; + if (!spec) return ''; + const parts: string[] = []; + for (const segment of spec.split(',')) { + const [col, dir = 'asc'] = segment.trim().split('.'); + const expr = sortable[col]; + if (!expr) throw new ListQueryError(`unsortable_column:${col}`); + const direction = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + parts.push(`${expr} ${direction}`); + } + return parts.length ? `ORDER BY ${parts.join(', ')}` : ''; +} + +/** + * Parses the querystring into SQL fragments. `qs` is req.query. `startIndex` is + * one past the count of params the route already uses, so filter params do not + * collide with the route's own. + */ +export function parseListParams( + qs: Record, + config: ListParamsConfig, + startIndex = 1, +): ParsedListParams { + const maxLimit = config.maxLimit ?? 200; + + const first = (v: unknown): string | undefined => + Array.isArray(v) ? (v[0] as string | undefined) : (v as string | undefined); + + // Pagination is opt-in: a request that sends neither limit nor offset gets + // every matching row, so existing clients that expect the full list are + // unchanged. A request that paginates is clamped to the max page. + const paginated = first(qs.limit) !== undefined || first(qs.offset) !== undefined; + const requestedLimit = parseInteger(first(qs.limit), maxLimit); + const limit = Math.min(requestedLimit, maxLimit); + const offset = parseInteger(first(qs.offset), 0); + + const orderSql = buildOrder(first(qs.order), config.sortable, config.defaultOrder); + + const conditions: string[] = []; + const params: unknown[] = []; + let index = startIndex; + + for (const [key, rawValue] of Object.entries(qs)) { + if (RESERVED.has(key)) continue; + const value = first(rawValue); + if (value === undefined || value === '') continue; + const expr = config.filterable[key]; + if (!expr) throw new ListQueryError(`unfilterable_column:${key}`); + + const dot = value.indexOf('.'); + const op = dot === -1 ? 'eq' : value.slice(0, dot).toLowerCase(); + const raw = dot === -1 ? value : value.slice(dot + 1); + const build = OPERATORS[op]; + if (!build) throw new ListQueryError(`unsupported_operator:${op}`); + + const { sql, wildcard } = build(expr, index); + conditions.push(sql); + params.push(wildcard ? raw.replace(/\*/g, '%') : raw); + index += 1; + } + + const filterSql = conditions.join(' AND '); + const pageSql = paginated ? ` LIMIT $${index} OFFSET $${index + 1}` : ''; + + return { + paginated, + limit, + offset, + orderSql, + filterSql, + params, + pageSql, + allParams: paginated ? [...params, limit, offset] : [...params], + }; +} + +/** Joins scope conditions with the parsed filter conditions into a WHERE, or ''. */ +export function composeWhere(...conditions: Array): string { + const kept = conditions.filter((c): c is string => Boolean(c && c.trim())); + return kept.length ? `WHERE ${kept.join(' AND ')}` : ''; +} + +/** Sets the Content-Range header the frontend reads for the total row count. */ +export function setContentRange(reply: FastifyReply, offset: number, returned: number, total: number): void { + const end = returned > 0 ? offset + returned - 1 : offset; + reply.header('Content-Range', `${offset}-${end}/${total}`); +} diff --git a/src/migrations.ts b/src/migrations.ts new file mode 100644 index 0000000..b57d68f --- /dev/null +++ b/src/migrations.ts @@ -0,0 +1,40 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const sqlDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'sql'); + +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. +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; +} diff --git a/src/org-paths.ts b/src/org-paths.ts new file mode 100644 index 0000000..8c5e689 --- /dev/null +++ b/src/org-paths.ts @@ -0,0 +1,35 @@ +import type { Pool } from 'pg'; + +/** + * Wraps a query selecting the visible orgs (id, name, parent_id, metadata) and + * computes each org's path as the names from the nearest visible ancestor down + * to it. Recursion is seeded from orgs whose parent is absent from the visible + * set, so a path can never name an ancestor the caller cannot read. Rows come + * back parent-first (ORDER BY path). + */ +export function withOrgPaths(visSql: string): string { + return `WITH RECURSIVE vis AS (${visSql}), + tree AS ( + SELECT v.id, v.name, v.parent_id, v.metadata, ARRAY[v.name]::text[] AS path + FROM vis v + WHERE v.parent_id IS NULL OR NOT EXISTS (SELECT 1 FROM vis p WHERE p.id = v.parent_id) + UNION ALL + SELECT v.id, v.name, v.parent_id, v.metadata, t.path || v.name + FROM vis v JOIN tree t ON v.parent_id = t.id + ) + SELECT id, name, parent_id, metadata, path FROM tree ORDER BY path`; +} + +/** + * Ancestor-path names, keyed by org id, for every org the user may read under + * `view`. The truncation is the point: a caller who cannot read a parent sees + * the path start at the topmost org they can read, never above it. + */ +export async function readableOrgPaths(db: Pool, userId: string, view: string): Promise> { + const { rows } = await db.query<{ id: string; path: string[] }>( + withOrgPaths( + `SELECT id, name, parent_id, NULL::jsonb AS metadata + FROM morbac.orgs WHERE morbac.is_allowed($1::UUID, id, 'read', $2)`), + [userId, view]); + return new Map(rows.map((r) => [r.id, r.path])); +} diff --git a/src/permissions.ts b/src/permissions.ts index 15ab5dc..99135cd 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -3,6 +3,38 @@ import type { Pool } from 'pg'; export const SYSTEM_ORG_ID = '00000000-0000-0000-0000-000000000000'; +/** The no-org bucket. An object whose org is UNATTRIBUTED belongs to no + * organisation (typically awaiting attribution). Mirrors the SQL model where a + * NULL org means unattributed -- it never means "any org" or "all orgs". */ +export const UNATTRIBUTED = null; + +/** A single org target: an organisation id, or UNATTRIBUTED. */ +export type OrgTarget = string | typeof UNATTRIBUTED; + +/** Which records a listing should return. Either 'all' -- every organisation, + * unattributed included -- or an explicit list of targets, where an + * UNATTRIBUTED entry selects objects that have no org. */ +export type OrgSelection = 'all' | OrgTarget[]; + +/** Serialises an OrgSelection for the morbac.org_ids session variable. + * 'all' returns null: no restriction, which the extension reads as every + * authorized record including unattributed ones. */ +export function orgSelectionToSetting(selection: OrgSelection): string | null { + return selection === 'all' ? null : JSON.stringify(selection); +} + +/** The X-Org-Ids token naming the no-org bucket on a data request. An org id is + * always a UUID, so the token cannot collide with one. The pre-request hook + * turns it into the JSON null element of morbac.org_ids. */ +export const UNATTRIBUTED_ORG_TOKEN = 'unattributed'; + +/** Builds an X-Org-Ids value from org targets, naming the no-org bucket with + * UNATTRIBUTED_ORG_TOKEN. Data requests only: the management routes below are + * org-scoped listings and reject the token. */ +export function orgTargetsToHeader(targets: OrgTarget[]): string { + return targets.map((t) => t ?? UNATTRIBUTED_ORG_TOKEN).join(','); +} + export function getUser(req: FastifyRequest): { id: string } | null { return ((req as unknown) as Record).user as { id: string } | null ?? null; } @@ -16,6 +48,12 @@ export function getOrgScope(req: FastifyRequest, reply: FastifyReply, required = const multiVal = Array.isArray(multi) ? multi[0] : (multi ?? null); if (multiVal) { const ids = multiVal.split(',').map((s) => s.trim()).filter(Boolean); + // These routes enumerate orgs via get_org_scope, which the no-org bucket + // is not part of. Rejecting beats letting the token reach a UUID cast. + if (ids.some((id) => id.toLowerCase() === UNATTRIBUTED_ORG_TOKEN)) { + reply.code(400).send({ error: `${UNATTRIBUTED_ORG_TOKEN} is only valid on data requests, not management routes` }); + return null; + } if (ids.length > 0) return ids; } @@ -56,6 +94,171 @@ function scopeAllows(scope: ApiKeyScope | undefined, activity: string, target: s return scope.some((p) => p.activity === activity && p.view === target); } +/** True if the caller may exercise (activity, view) over the target user. + * Authorizes against the target's ACTUAL membership orgs (or a global rule), + * never a caller-supplied header. A target with no membership can only be + * managed by a global admin. Prevents cross-org account takeover. */ +/** Enforces an API key's permission_scope for management routes that no longer + * route through hasPermission (they authorize against target ownership instead). + * Sends 403 and returns false when the key's scope excludes (activity, view). */ +export function checkApiKeyScope( + req: FastifyRequest, + reply: FastifyReply, + activity: string, + view: string, +): boolean { + const apiKeyScope = ((req as unknown) as Record).apiKeyScope as ApiKeyScope | undefined; + if (!scopeAllows(apiKeyScope, activity, view)) { + reply.code(403).send({ error: 'Insufficient permissions' }); + return false; + } + return true; +} + +export async function canActOnUser( + db: Pool, + callerId: string, + targetUserId: string, + activity: string, + view: string, +): Promise { + const { rows } = await db.query<{ ok: boolean }>( + `SELECT ( + morbac.is_allowed($1::UUID, NULL::UUID, $3, $4) + OR EXISTS ( + SELECT 1 FROM morbac.user_roles ur + WHERE ur.user_id = $2::UUID + AND morbac.is_allowed($1::UUID, ur.org_id, $3, $4) + ) + ) AS ok`, + [callerId, targetUserId, activity, view], + ); + return rows[0]?.ok ?? false; +} + +/** True if the caller may reach org (activity, view) at every org that a rule + * with (ruleOrgId, scope) would cover. Enforces that a rule can never grant a + * permission the caller does not already hold on the affected orgs, killing + * scope-based vertical privilege escalation. + * + * The 'unattributed' and 'all' scopes reach objects with no org, which + * get_org_scope does not enumerate (unattributed is not an organisation). + * Those scopes additionally require the caller to hold the permission on + * unattributed objects, otherwise 'unattributed' would pass vacuously. */ +export async function canGrantRule( + db: Pool, + callerId: string, + ruleOrgId: string, + scope: string, + activity: string, + view: string, +): Promise { + const { rows } = await db.query<{ ok: boolean }>( + `SELECT ( + NOT EXISTS ( + SELECT 1 FROM morbac.get_org_scope($2::UUID, $3) AS s(org_id) + WHERE NOT morbac.is_allowed($1::UUID, s.org_id, $4, $5) + ) + AND ( + $3 NOT IN ('unattributed', 'all') + OR morbac.is_allowed($1::UUID, NULL::UUID, $4, $5) + ) + ) AS ok`, + [callerId, ruleOrgId, scope, activity, view], + ); + return rows[0]?.ok ?? false; +} + +/** True iff (activity, view) has an explicit row in morbac.activity_view_bindings. + * The DB trigger only restricts an activity once it has at least one binding + * (empty = unconstrained); this closes that gap at the application layer so an + * activity/view pair with no binding is rejected rather than silently allowed. */ +export async function isActivityViewBound(db: Pool, activity: string, view: string): Promise { + const { rows } = await db.query<{ ok: boolean }>( + `SELECT EXISTS(SELECT 1 FROM morbac.activity_view_bindings WHERE activity = $1 AND view = $2) AS ok`, + [activity, view], + ); + return rows[0]?.ok ?? false; +} + +/** True iff the caller holds the global wildcard rule (activity IS NULL, view IS NULL). + * Used by framework-level surfaces (audit, monitoring) that are not org-scoped. */ +export async function isGlobalAdmin(db: Pool, userId: string): Promise { + const { rows } = await db.query<{ is_admin: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM morbac.global_rules + WHERE user_id = $1::UUID AND activity IS NULL AND view IS NULL AND modality = 'permission' + ) AS is_admin`, + [userId], + ); + return rows[0]?.is_admin === true; +} + +/** Sends 401/403 and returns false unless the caller is authenticated and holds + * the global wildcard rule. Use for routes with no org concept (e.g. monitoring). */ +export async function requireGlobalAdmin(db: Pool, req: FastifyRequest, reply: FastifyReply): Promise { + const user = getUser(req); + if (!user) { + reply.code(401).send({ error: 'Authentication required' }); + return false; + } + if (!(await isGlobalAdmin(db, user.id))) { + reply.code(403).send({ error: 'Insufficient permissions' }); + return false; + } + return true; +} + +/** True if the user may perform (activity, view) in ANY context: any org they + * belong to, unattributed objects, or via a global rule. For deciding whether + * to expose a feature at all -- never a substitute for the per-object check. */ +export async function hasAnyPermission( + db: Pool, + userId: string, + activity: string, + view: string, +): Promise { + const { rows } = await db.query<{ allowed: boolean }>( + 'SELECT morbac.has_permission($1::UUID, $2, $3) AS allowed', + [userId, activity, view], + ); + return rows[0]?.allowed === true; +} + +/** + * The same decision `hasPermission` makes, without touching the reply. + * + * For callers that must ask about many permissions and decide what to do with + * the answers: a report fan-out drops the metrics the runner cannot read + * rather than failing, so it needs the boolean, not a 403 on the wire. Honours + * an API key's scope exactly as `hasPermission` does, so a restricted key never + * reads more through this door than through that one. + */ +export async function canPerform( + db: Pool, + req: FastifyRequest, + orgId: string | null, + activity: string, + target: string, +): Promise { + const user = getUser(req); + if (!user) return false; + const apiKeyScope = ((req as unknown) as Record).apiKeyScope as ApiKeyScope | undefined; + if (!scopeAllows(apiKeyScope, activity, target)) return false; + const { rows } = await db.query<{ allowed: boolean }>( + 'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', + [user.id, orgId, activity, target], + ); + return rows[0]?.allowed === true; +} + +/** Reads an API key's scope off a request, for callers that must reason about it. */ +export function apiKeyScopeOf(req: FastifyRequest): ApiKeyScope | undefined { + return ((req as unknown) as Record).apiKeyScope as ApiKeyScope | undefined; +} + +/** Authorization for a single object. Pass the object's org, or UNATTRIBUTED + * when the object has no org. */ export async function hasPermission( db: Pool, req: FastifyRequest, @@ -84,3 +287,40 @@ export async function hasPermission( } return true; } + +/** + * A coarse gate for a multi-org list read (e.g. the "all organizations" filter): + * the caller may proceed if allowed in at least one of the requested orgs. RLS + * still filters each row by its own org, so a row the caller cannot see in one + * of the listed orgs is dropped regardless. + */ +export async function hasPermissionAny( + db: Pool, + req: FastifyRequest, + reply: FastifyReply, + orgIds: string[], + activity: string, + target: string, +): Promise { + const user = getUser(req); + if (!user) { + reply.code(401).send({ error: 'Authentication required' }); + return false; + } + const apiKeyScope = ((req as unknown) as Record).apiKeyScope as ApiKeyScope | undefined; + if (!scopeAllows(apiKeyScope, activity, target)) { + reply.code(403).send({ error: 'Insufficient permissions' }); + return false; + } + const real = orgIds.filter((v) => v && v.toLowerCase() !== 'unattributed'); + const { rows } = await db.query<{ allowed: boolean }>( + `SELECT bool_or(morbac.is_allowed($1::UUID, o::UUID, $3, $4)) AS allowed + FROM unnest($2::UUID[]) o`, + [user.id, real, activity, target], + ); + if (!rows[0]?.allowed) { + reply.code(403).send({ error: 'Insufficient permissions' }); + return false; + } + return true; +} diff --git a/src/plugin.ts b/src/plugin.ts index 02de0bf..76489be 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,6 +7,7 @@ import { createUserRoutes } from './routes/users.js'; import { createGlobalRoutes } from './routes/globals.js'; import { createActivityRoutes } from './routes/activities.js'; import { createDelegationRoutes } from './routes/delegations.js'; +import { createPermissionProfileRoutes } from './routes/permission-profiles.js'; export type CheckLimitFn = ( reply: FastifyReply, @@ -43,6 +44,7 @@ export function createMgmtRoutes(opts: MgmtPluginOptions) { return async function mgmtRoutes(app: FastifyInstance) { await app.register(createOrgRoutes(db, checkLimit, emitOrgEvent)); await app.register(createRoleRoutes(db, checkLimit, emitRoleEvent)); + await app.register(createPermissionProfileRoutes(db, checkLimit, emitRoleEvent)); await app.register(createRuleRoutes(db)); await app.register(createUserRoutes(db, checkLimit)); await app.register(createGlobalRoutes(db)); diff --git a/src/proxy.ts b/src/proxy.ts index 9759034..7722dfa 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,11 +1,38 @@ import type { FastifyRequest, FastifyReply } from 'fastify'; import type { Pool } from 'pg'; +import { hasPermission, hasPermissionAny } from './permissions.js'; export interface DataPermission { activity: string; view: string; } +/** Rows returned by a GET that does not ask for a page size. */ +export const DEFAULT_PAGE_LIMIT = 100; + +/** + * Largest page a caller may ask for. Deliberately well above the table's + * page-size options; bulk reads belong on the export routes, which stream + * from a cursor instead of buffering a PostgREST response. + * + * Not configurable: an env override would be an escape hatch around the cap. + */ +export const MAX_PAGE_LIMIT = 1000; + +/** + * Rejects rather than clamps. A clamp answers 200 with a short Content-Range + * that the caller reads as authoritative, so a truncated read looks like a + * complete one and surfaces later as wrong numbers. A 400 surfaces at the + * call site immediately. + */ +function checkLimit(rawQs: string): { ok: true } | { ok: false } { + for (const value of new URLSearchParams(rawQs).getAll('limit')) { + const n = Number(value); + if (!Number.isInteger(n) || n < 0 || n > MAX_PAGE_LIMIT) return { ok: false }; + } + return { ok: true }; +} + export type ProxyDataRouteFn = ( app: import('fastify').FastifyInstance, method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', @@ -44,6 +71,7 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist method, url: publicPath, schema: { hide: true } as Record, + config: { permission }, handler: makeHandler(upstreamResource, permission), }); } @@ -56,16 +84,42 @@ export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegist if (permission !== null) { const orgId = req.headers['x-org-id']; const orgIdVal = Array.isArray(orgId) ? orgId[0] : orgId; - if (!orgIdVal) return reply.code(400).send({ error: 'X-Org-Id header is required' }); + const orgIds = req.headers['x-org-ids']; + const orgIdsVal = Array.isArray(orgIds) ? orgIds[0] : orgIds; - const { rows } = await db.query<{ allowed: boolean }>( - 'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', - [user.id, orgIdVal, permission.activity, permission.view], - ); - if (!rows[0]?.allowed) return reply.code(403).send({ error: 'Forbidden' }); + // Goes through hasPermission rather than is_allowed directly: it is + // the only place an API key's permission_scope is applied, so an + // inline check would let a narrowly scoped key act as its full user. + if (orgIdVal) { + if (!await hasPermission(db, req, reply, orgIdVal, permission.activity, permission.view)) { + return reply; + } + } else if (orgIdsVal && orgIdsVal.trim()) { + // A multi-org list read (e.g. "all organizations"): scoped to the + // set via X-Org-Ids. RLS filters each row, so the gate only needs + // permission in one of them. + const ids = orgIdsVal.split(',').map(s => s.trim()).filter(Boolean); + if (!await hasPermissionAny(db, req, reply, ids, permission.activity, permission.view)) { + return reply; + } + } else { + return reply.code(400).send({ error: 'X-Org-Id header is required' }); + } + } + + const qsIndex = req.url.indexOf('?'); + const rawQs = qsIndex === -1 ? '' : req.url.slice(qsIndex + 1); + + if (!checkLimit(rawQs).ok) return reply.code(400).send({ error: 'limit_exceeded' }); + + // Only reads get a default page. On PATCH and DELETE, PostgREST reads + // limit as a bound on the affected rows, so injecting one would + // silently truncate a bulk mutation. + let qs = rawQs ? `?${rawQs}` : ''; + if (req.method === 'GET' && !new URLSearchParams(rawQs).has('limit')) { + qs = rawQs ? `?${rawQs}&limit=${DEFAULT_PAGE_LIMIT}` : `?limit=${DEFAULT_PAGE_LIMIT}`; } - const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''; const url = `${postgrestUrl}/${upstreamResource}${qs}`; const headers: Record = {}; diff --git a/src/rls-context.ts b/src/rls-context.ts new file mode 100644 index 0000000..9b361ec --- /dev/null +++ b/src/rls-context.ts @@ -0,0 +1,55 @@ +import type { Pool, PoolClient } from 'pg'; +import { orgSelectionToSetting, type OrgSelection } from './permissions.js'; + +export interface MorbacContext { + userId: string; + /** Pin to a single organisation. Unattributed records are excluded by a + * pin; use `orgs` to select them. */ + orgId?: string | null; + /** Which records to return: 'all' (every org, unattributed included), or a + * list of targets where an UNATTRIBUTED (null) entry selects records that + * have no org. Ignored when `orgId` is set. */ + orgs?: OrgSelection; +} + +/** + * Runs fn on a dedicated client inside a transaction with the morbac session + * GUCs set, so morbac RLS policies see the acting user. + * + * Whether that is load bearing depends on which pool it is given. Under + * app_runtime, the role without BYPASSRLS that the metrics read path uses, it + * turns RLS into a second authorization gate behind hasPermission. Under + * app_backend, which every other caller still uses, the role attribute exempts + * it and the GUCs only feed the audit triggers. + * + * Routes not yet converted fail closed under app_runtime: with no GUC set, + * morbac.current_user_id() is NULL and the policies return no rows. + */ +export async function withMorbacContext( + pool: Pool, + ctx: MorbacContext, + fn: (client: PoolClient) => Promise, +): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + `SELECT set_config('morbac.user_id', $1, true), + set_config('morbac.org_id', $2, true), + set_config('morbac.org_ids', $3, true)`, + [ + ctx.userId, + ctx.orgId ?? '', + (ctx.orgs === undefined ? null : orgSelectionToSetting(ctx.orgs)) ?? '', + ], + ); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK').catch(() => undefined); + throw err; + } finally { + client.release(); + } +} diff --git a/src/routes/activities.ts b/src/routes/activities.ts index 66b72e9..677405c 100644 --- a/src/routes/activities.ts +++ b/src/routes/activities.ts @@ -9,7 +9,7 @@ export function createActivityRoutes(db: Pool) { schema: { tags: ['Management'], summary: 'List activities', - description: 'Read-only list of permission verbs (read, create, update, delete). Managed at the database level.', + description: 'Read-only list of permission verbs (read, create, update, delete). Managed at the database level. Requires `read`/`rules`.', security: [{ oauth2: [] }], response: { 200: { @@ -23,11 +23,15 @@ export function createActivityRoutes(db: Pool) { }, }, 401: unauthorized, + 403: forbidden, }, }, }, async (req, reply) => { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); + const orgIds = getOrgScope(req, reply, false); + if (orgIds === null) return reply; + if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply; const { rows } = await db.query('SELECT name, description FROM morbac.activities ORDER BY name'); return rows; }); @@ -115,16 +119,28 @@ export function createActivityRoutes(db: Pool) { properties: { name: { type: 'string' }, description: { type: 'string', nullable: true }, + no_org: { type: 'boolean' }, }, }, }, 401: unauthorized, + 403: forbidden, }, }, }, async (req, reply) => { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); - const { rows } = await db.query('SELECT name, description FROM morbac.views ORDER BY name'); + const orgIds = getOrgScope(req, reply, false); + if (orgIds === null) return reply; + if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply; + // no_org marks a view whose rows carry no org, so callers can offer the + // unattributed grant instead of a tree-scoped rule that could never match. + const { rows } = await db.query( + `SELECT v.name, v.description, COALESCE(f.no_org, FALSE) AS no_org + FROM morbac.views v + LEFT JOIN app.view_flags f ON f.view = v.name + ORDER BY v.name`, + ); return rows; }); @@ -215,11 +231,15 @@ export function createActivityRoutes(db: Pool) { }, }, 401: unauthorized, + 403: forbidden, }, }, }, async (req, reply) => { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); + const orgIds = getOrgScope(req, reply, false); + if (orgIds === null) return reply; + if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'rules')) return reply; const { rows } = await db.query( 'SELECT activity, view FROM morbac.activity_view_bindings ORDER BY activity, view', ); diff --git a/src/routes/delegations.ts b/src/routes/delegations.ts index 51e4b7c..04b22f4 100644 --- a/src/routes/delegations.ts +++ b/src/routes/delegations.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; -import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; +import { getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, SYSTEM_ORG_ID } from '../permissions.js'; import { delegationResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; const SELECT_COLUMNS = ` @@ -84,6 +84,14 @@ export function createDelegationRoutes(db: Pool) { if (!await hasPermission(db, req, reply, org_id, 'create', 'delegations')) return reply; + // The caller must be the delegator, or hold authority over them. Otherwise + // any create/delegations holder could self-grant a powerful role held by + // another principal by naming them as delegator. + if (delegator_id !== user.id + && !await canActOnUser(db, user.id, delegator_id, 'create', 'user_roles')) { + return reply.code(403).send({ error: 'Cannot delegate on behalf of this user' }); + } + const from = valid_from ? new Date(valid_from) : new Date(); const until = new Date(valid_until); if (Number.isNaN(until.getTime()) || until <= from) { diff --git a/src/routes/globals.ts b/src/routes/globals.ts index ad8ddbc..e0b7bd0 100644 --- a/src/routes/globals.ts +++ b/src/routes/globals.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; -import { getUser, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; -import { globalRuleResponse, notFound, forbidden, unauthorized } from '../schemas.js'; +import { getUser, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js'; +import { globalRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; export function createGlobalRoutes(db: Pool) { return async function globalRoutes(app: FastifyInstance) { @@ -57,7 +57,7 @@ export function createGlobalRoutes(db: Pool) { }, response: { 201: globalRuleResponse, - 401: unauthorized, 403: forbidden, + 400: badRequest, 401: unauthorized, 403: forbidden, 409: { description: 'Rule already exists.' }, }, }, @@ -79,6 +79,12 @@ export function createGlobalRoutes(db: Pool) { if (!await hasPermission(db, req, reply, SYSTEM_ORG_ID, 'create', 'rules')) return reply; + // activity/view null = wildcard (e.g. the global admin rule) - not a concrete + // pair to validate against the bindings whitelist. + if (activity && view && !await isActivityViewBound(db, activity, view)) { + return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` }); + } + try { const { rows } = await db.query( `INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority) @@ -93,7 +99,9 @@ export function createGlobalRoutes(db: Pool) { ); return reply.code(201).send(rows[0]); } catch (e: unknown) { - if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + const code = (e as { code?: string }).code; + if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' }); throw e; } }); diff --git a/src/routes/orgs.ts b/src/routes/orgs.ts index 25dc2a0..02969f0 100644 --- a/src/routes/orgs.ts +++ b/src/routes/orgs.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from 'fastify'; +import { withMorbacContext } from '../rls-context.js'; import type { Pool } from 'pg'; import type { CheckLimitFn, OrgEventSink } from '../plugin.js'; import { @@ -6,8 +7,11 @@ import { } from '../permissions.js'; import { orgResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; +import { withOrgPaths } from '../org-paths.js'; + interface OrgRow { id: string; name: string; parent_id: string | null; metadata?: Record } + export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEvent?: OrgEventSink) { return async function orgRoutes(app: FastifyInstance) { app.get('/orgs', { @@ -29,22 +33,23 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven if (orgIds === null) return reply; if (orgIds.length === 0) { - const { rows: adminCheck } = await db.query<{ allowed: boolean }>( - 'SELECT morbac.is_allowed($1::UUID, NULL::UUID, $2, $3) AS allowed', - [user.id, 'read', 'orgs'], - ); - if (adminCheck[0]?.allowed) { - const { rows } = await db.query( - 'SELECT id, name, parent_id, metadata FROM morbac.orgs ORDER BY name', - ); - return rows; - } - const { rows } = await db.query( - `SELECT DISTINCT o.id, o.name, o.parent_id, o.metadata - FROM morbac.orgs o - INNER JOIN morbac.user_roles ur ON ur.org_id = o.id AND ur.user_id = $1 - ORDER BY o.name`, - [user.id], + // Unpinned: the caller's whole perimeter. Two ways in, and both + // are needed. A rule scoped to a subtree, a user rule or a + // cross-org rule reaches orgs the caller holds no role in, and + // those belong in the org filter. A role in an org puts the + // caller in it even with no read/orgs rule, and the org filter is + // what supplies X-Org-Id, so dropping those orgs leaves such a + // user with an empty perimeter and an app that does nothing. + const { rows } = await withMorbacContext(db, { userId: user.id }, (client) => + client.query( + withOrgPaths( + `SELECT id, name, parent_id, metadata FROM morbac.orgs o + WHERE morbac.is_allowed($1::UUID, o.id, 'read', 'orgs') + OR EXISTS (SELECT 1 FROM morbac.user_roles ur + WHERE ur.org_id = o.id AND ur.user_id = $1)`, + ), + [user.id], + ), ); return rows; } @@ -53,9 +58,11 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven const isSystemScope = orgIds.includes(SYSTEM_ORG_ID); const { expr, params } = isSystemScope ? { expr: '', params: [] } : orgScopeExpr(orgIds, 'id'); - const { rows } = await db.query( - `SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''} ORDER BY name`, - params, + const { rows } = await withMorbacContext(db, { userId: user.id, orgId: orgIds[0] }, (client) => + client.query( + withOrgPaths(`SELECT id, name, parent_id, metadata FROM morbac.orgs ${expr ? `WHERE ${expr}` : ''}`), + params, + ), ); return rows; }); @@ -140,6 +147,25 @@ export function createOrgRoutes(db: Pool, checkLimit?: CheckLimitFn, emitOrgEven if (!await hasPermission(db, req, reply, id, 'update', 'orgs')) return reply; const body = req.body as { name?: string; parent_id?: string | null }; + + // Reparenting must be authorized against the destination, not just the + // moved org, and must not create a cycle (new parent within own subtree). + if ('parent_id' in body) { + if (body.parent_id === null) { + if (!await hasPermission(db, req, reply, id, 'create', 'root_orgs')) return reply; + } else if (body.parent_id) { + if (body.parent_id === id) return reply.code(400).send({ error: 'invalid_parent' }); + const { rows: [cycle] } = await db.query<{ inside: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM morbac.get_org_scope($1::UUID, 'subtree') AS s(org_id) + WHERE s.org_id = $2::UUID + ) AS inside`, + [id, body.parent_id], + ); + if (cycle?.inside) return reply.code(400).send({ error: 'invalid_parent' }); + if (!await hasPermission(db, req, reply, body.parent_id, 'create', 'orgs')) return reply; + } + } const { rows } = await db.query( `WITH prev AS (SELECT parent_id AS old_parent FROM morbac.orgs WHERE id = $4) UPDATE morbac.orgs o diff --git a/src/routes/permission-profiles.ts b/src/routes/permission-profiles.ts new file mode 100644 index 0000000..1990701 --- /dev/null +++ b/src/routes/permission-profiles.ts @@ -0,0 +1,254 @@ +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import type { CheckLimitFn, RoleEventSink } from '../plugin.js'; +import { getUser, getOrgScope, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; +import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; +import { loadProfile, validateGrantable, applyProfileRules } from './profile-apply.js'; + +const profileResponse = { + type: 'object', + properties: { + module_id: { type: 'string' }, + key: { type: 'string' }, + content_hash: { type: 'string' }, + rules: { + type: 'array', + items: { + type: 'object', + properties: { + activity: { type: 'string' }, + view: { type: 'string' }, + modality: { type: 'string' }, + scope: { type: 'string' }, + }, + }, + }, + }, +}; + +const profileRef = { + type: 'object', + required: ['module_id', 'key'], + properties: { + module_id: { type: 'string', maxLength: 128 }, + key: { type: 'string', maxLength: 128 }, + }, +}; + +export function createPermissionProfileRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) { + return async function permissionProfileRoutes(app: FastifyInstance) { + app.get('/permission-profiles', { + schema: { + tags: ['Management'], + summary: 'List permission profiles', + description: 'Returns role presets declared by modules. Requires `read`/`roles`.', + security: [{ oauth2: [] }], + querystring: { + type: 'object', + properties: { module_id: { type: 'string', maxLength: 128 } }, + }, + response: { + 200: { type: 'array', items: profileResponse }, + 400: badRequest, 401: unauthorized, 403: forbidden, + }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + + const orgIds = getOrgScope(req, reply, false); + if (orgIds === null) return reply; + if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply; + + const { module_id } = req.query as { module_id?: string }; + const { rows } = await db.query( + `SELECT module_id, key, content_hash, rules + FROM app.permission_profiles + ${module_id ? 'WHERE module_id = $1' : ''} + ORDER BY module_id, key`, + module_id ? [module_id] : [], + ); + return rows; + }); + + app.post('/roles/from-profile', { + schema: { + tags: ['Management'], + summary: 'Create a role from a profile', + description: + 'Creates a role in the target org and seeds its rules from a module profile. ' + + 'Requires `create`/`roles`, and the caller must already hold every granted permission. ' + + 'When `sync` is true the role tracks future profile changes.', + security: [{ oauth2: [] }], + body: { + type: 'object', + required: ['org_id', 'name', 'profile'], + properties: { + org_id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1, maxLength: 200 }, + description: { type: 'string', maxLength: 500 }, + sync: { type: 'boolean', default: false }, + profile: profileRef, + }, + }, + response: { + 201: roleResponse, + 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound, + }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + + const { org_id, name, description, sync, profile } = req.body as { + org_id: string; name: string; description?: string; sync?: boolean; + profile: { module_id: string; key: string }; + }; + + if (org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); + if (!await hasPermission(db, req, reply, org_id, 'create', 'roles')) return reply; + + const prof = await loadProfile(db, profile.module_id, profile.key); + if (!prof) return reply.code(404).send({ error: 'Not found' }); + + if (checkLimit) { + const ok = await checkLimit(reply, 'max_roles', 'SELECT count(*)::TEXT AS c FROM morbac.roles'); + if (!ok) return reply; + } + if (!await validateGrantable(db, reply, user.id, org_id, prof.rules)) return reply; + + const client = await db.connect(); + try { + await client.query('BEGIN'); + const { rows: [role] } = await client.query( + `INSERT INTO morbac.roles (org_id, name, description) VALUES ($1, $2, $3) + RETURNING id, org_id, name, description`, + [org_id, name, description ?? null], + ); + const nonSelf = await applyProfileRules(client, org_id, role.id, profile.module_id, profile.key, prof.rules); + if (sync) { + await client.query( + `INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash) + VALUES ($1, $2, $3, TRUE, $4)`, + [role.id, profile.module_id, profile.key, prof.content_hash], + ); + } + await client.query('COMMIT'); + if (nonSelf) await db.query('SELECT morbac.invalidate_cache()'); + + const org = await db.query('SELECT name FROM morbac.orgs WHERE id = $1', [org_id]); + emitRoleEvent?.({ action: 'created', role, actorId: user.id }); + return reply.code(201).send({ ...role, org_name: org.rows[0]?.name }); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + }); + + app.post('/roles/:id/profile-sync', { + schema: { + tags: ['Management'], + summary: 'Attach a profile to an existing role', + description: + 'Applies a module profile to an existing role and records the link. Requires ' + + '`update`/`roles`, and the caller must already hold every granted permission. ' + + 'When `sync` is true the role tracks future profile changes.', + security: [{ oauth2: [] }], + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + body: { + type: 'object', + required: ['profile'], + properties: { + profile: profileRef, + sync: { type: 'boolean', default: true }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + role_id: { type: 'string', format: 'uuid' }, + module_id: { type: 'string' }, + profile_key: { type: 'string' }, + synced: { type: 'boolean' }, + applied_hash: { type: 'string' }, + }, + }, + 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound, + }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + + const { id } = req.params as { id: string }; + const { profile, sync } = req.body as { profile: { module_id: string; key: string }; sync?: boolean }; + const synced = sync !== false; + + const { rows: [roleRow] } = await db.query<{ org_id: string }>( + 'SELECT org_id FROM morbac.roles WHERE id = $1', [id], + ); + if (!roleRow) return reply.code(404).send({ error: 'Not found' }); + if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); + if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply; + + const prof = await loadProfile(db, profile.module_id, profile.key); + if (!prof) return reply.code(404).send({ error: 'Not found' }); + if (!await validateGrantable(db, reply, user.id, roleRow.org_id, prof.rules)) return reply; + + const client = await db.connect(); + try { + await client.query('BEGIN'); + const nonSelf = await applyProfileRules(client, roleRow.org_id, id, profile.module_id, profile.key, prof.rules); + await client.query( + `INSERT INTO app.role_profile_links (role_id, module_id, profile_key, synced, applied_hash, applied_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (role_id) + DO UPDATE SET module_id = EXCLUDED.module_id, profile_key = EXCLUDED.profile_key, + synced = EXCLUDED.synced, applied_hash = EXCLUDED.applied_hash, applied_at = now()`, + [id, profile.module_id, profile.key, synced, prof.content_hash], + ); + await client.query('COMMIT'); + if (nonSelf) await db.query('SELECT morbac.invalidate_cache()'); + return reply.send({ role_id: id, module_id: profile.module_id, profile_key: profile.key, synced, applied_hash: prof.content_hash }); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + }); + + app.delete('/roles/:id/profile-sync', { + schema: { + tags: ['Management'], + summary: 'Detach a role from its profile', + description: + "Removes the profile link so the role stops tracking the profile. Requires " + + "`update`/`roles`. Already-applied rules are left in place.", + security: [{ oauth2: [] }], + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + response: { + 204: { type: 'null', description: 'Link removed.' }, + 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound, + }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + + const { id } = req.params as { id: string }; + const { rows: [roleRow] } = await db.query<{ org_id: string }>( + 'SELECT org_id FROM morbac.roles WHERE id = $1', [id], + ); + if (!roleRow) return reply.code(404).send({ error: 'Not found' }); + if (roleRow.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); + if (!await hasPermission(db, req, reply, roleRow.org_id, 'update', 'roles')) return reply; + + await db.query('DELETE FROM app.role_profile_links WHERE role_id = $1', [id]); + return reply.code(204).send(); + }); + }; +} diff --git a/src/routes/profile-apply.ts b/src/routes/profile-apply.ts new file mode 100644 index 0000000..09b5435 --- /dev/null +++ b/src/routes/profile-apply.ts @@ -0,0 +1,75 @@ +import type { Pool, PoolClient } from 'pg'; +import type { FastifyReply } from 'fastify'; +import { canGrantRule, isActivityViewBound } from '../permissions.js'; + +export interface ProfileRule { + activity: string; + view: string; + modality: 'permission' | 'prohibition'; + scope: string; +} + +export async function loadProfile( + db: Pool, moduleId: string, key: string, +): Promise<{ rules: ProfileRule[]; content_hash: string } | null> { + const { rows: [row] } = await db.query<{ rules: ProfileRule[]; content_hash: string }>( + 'SELECT rules, content_hash FROM app.permission_profiles WHERE module_id = $1 AND key = $2', + [moduleId, key], + ); + return row ?? null; +} + +/** Anti-escalation gate for the caller-driven apply: every rule must be a valid + * binding, and the caller must already hold every permission it would grant. + * Sends the reply and returns false on the first failure. */ +export async function validateGrantable( + db: Pool, reply: FastifyReply, userId: string, orgId: string, rules: ProfileRule[], +): Promise { + for (const r of rules) { + if (!await isActivityViewBound(db, r.activity, r.view)) { + reply.code(400).send({ error: `Activity "${r.activity}" is not valid for view "${r.view}"` }); + return false; + } + if (r.modality === 'permission' + && !await canGrantRule(db, userId, orgId, r.scope, r.activity, r.view)) { + reply.code(403).send({ error: 'Cannot grant a permission you do not hold on the target orgs' }); + return false; + } + } + return true; +} + +/** Insert/adopt every profile rule (tagged) and revoke this profile's tagged rules + * no longer in the set. Manual (untagged) rules are never touched. Returns whether + * any touched rule was non-self, so the caller can flush the morbac cache. */ +export async function applyProfileRules( + client: PoolClient, orgId: string, roleId: string, moduleId: string, key: string, rules: ProfileRule[], +): Promise { + const tag = JSON.stringify({ profile: { module_id: moduleId, key } }); + let touchedNonSelf = false; + for (const r of rules) { + await client.query( + `INSERT INTO morbac.rules + (org_id, role_id, activity, view, context_id, modality, scope, is_active, metadata) + VALUES ($1, $2, $3, $4, + (SELECT id FROM morbac.contexts WHERE name = 'always'), + $5::morbac.modality, $6, TRUE, $7::jsonb) + ON CONFLICT (org_id, role_id, activity, view, context_id, modality, scope) + DO UPDATE SET metadata = EXCLUDED.metadata, is_active = TRUE`, + [orgId, roleId, r.activity, r.view, r.modality, r.scope, tag], + ); + if (r.scope !== 'self') touchedNonSelf = true; + } + const keep = rules.map(r => `${r.activity}|${r.view}|${r.modality}|${r.scope}`); + const { rows: removed } = await client.query<{ scope: string }>( + `DELETE FROM morbac.rules + WHERE role_id = $1 + AND metadata->'profile'->>'module_id' = $2 + AND metadata->'profile'->>'key' = $3 + AND (activity || '|' || view || '|' || modality::text || '|' || scope) <> ALL($4::text[]) + RETURNING scope`, + [roleId, moduleId, key, keep], + ); + if (removed.some(row => row.scope !== 'self')) touchedNonSelf = true; + return touchedNonSelf; +} diff --git a/src/routes/role-authority.ts b/src/routes/role-authority.ts new file mode 100644 index 0000000..0f14097 --- /dev/null +++ b/src/routes/role-authority.ts @@ -0,0 +1,18 @@ +import type { Pool } from 'pg'; + +/** + * Whether the caller may hand out this role at all: assigning it is exercising + * authority over the organization the role lives in, not over the user. + */ +export async function callerControlsRoleOrg( + db: Pool, callerId: string, roleId: string, activity: string, +): Promise { + const { rows } = await db.query<{ ok: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM morbac.roles r + WHERE r.id = $2::UUID AND morbac.is_allowed($1::UUID, r.org_id, $3, 'user_roles') + ) AS ok`, + [callerId, roleId, activity], + ); + return rows[0]?.ok ?? false; +} diff --git a/src/routes/roles.ts b/src/routes/roles.ts index 92f6c0f..6d71a29 100644 --- a/src/routes/roles.ts +++ b/src/routes/roles.ts @@ -4,8 +4,12 @@ import type { CheckLimitFn, RoleEventSink } from '../plugin.js'; import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID, } from '../permissions.js'; +import { parseListParams, composeWhere, setContentRange, ListQueryError } from '../list-query.js'; import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; +const ROLE_SORTABLE = { name: 'r.name', org_name: 'o.name' }; +const ROLE_FILTERABLE = { name: 'r.name', description: 'r.description', org_id: 'r.org_id' }; + export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) { return async function roleRoutes(app: FastifyInstance) { app.get('/roles', { @@ -29,15 +33,35 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEv if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'roles')) return reply; const { expr, params } = orgScopeExpr(orgIds, 'r.org_id'); + let list; + try { + list = parseListParams(req.query as Record, { + sortable: ROLE_SORTABLE, + filterable: ROLE_FILTERABLE, + defaultOrder: 'org_name.asc,name.asc', + }, params.length + 1); + } catch (e) { + if (e instanceof ListQueryError) return reply.code(400).send({ error: 'invalid_query' }); + throw e; + } + + const where = composeWhere(expr, list.filterSql); const { rows } = await db.query( - `SELECT r.id, r.org_id, r.name, r.description, o.name AS org_name + `SELECT r.id, r.org_id, r.name, r.description, o.name AS org_name, + rpl.module_id AS profile_module_id, rpl.profile_key, rpl.synced AS profile_synced, + count(*) OVER() AS _total FROM morbac.roles r JOIN morbac.orgs o ON o.id = r.org_id - ${expr ? `WHERE ${expr}` : ''} - ORDER BY o.name, r.name`, - params, + LEFT JOIN app.role_profile_links rpl ON rpl.role_id = r.id + ${where} + ${list.orderSql || 'ORDER BY o.name, r.name'} + ${list.pageSql}`, + [...params, ...list.allParams], ); - return rows; + + const total = rows.length > 0 ? Number((rows[0] as { _total: string })._total) : 0; + setContentRange(reply, list.offset, rows.length, total); + return (rows as Array>).map(({ _total, ...rest }) => rest); }); app.post('/roles', { diff --git a/src/routes/rule-cross-org.ts b/src/routes/rule-cross-org.ts index 7a25255..88f4464 100644 --- a/src/routes/rule-cross-org.ts +++ b/src/routes/rule-cross-org.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; -import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; +import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js'; import { crossOrgRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; export function createCrossOrgRuleRoutes(db: Pool) { @@ -87,6 +87,10 @@ export function createCrossOrgRuleRoutes(db: Pool) { if (source_org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); if (!await hasPermission(db, req, reply, source_org_id, 'create', 'cross_org_rules')) return reply; + if (!await isActivityViewBound(db, activity, view)) { + return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` }); + } + try { const { rows } = await db.query( `INSERT INTO morbac.cross_org_rules @@ -106,7 +110,10 @@ export function createCrossOrgRuleRoutes(db: Pool) { ); return reply.code(201).send(rows[0]); } catch (e: unknown) { - if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + const code = (e as { code?: string }).code; + if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' }); + if (code === 'P0001') return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` }); throw e; } }); diff --git a/src/routes/rule-role.ts b/src/routes/rule-role.ts index b5abe3e..258a2e3 100644 --- a/src/routes/rule-role.ts +++ b/src/routes/rule-role.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; -import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID } from '../permissions.js'; +import { getUser, getOrgScope, orgScopeExpr, hasPermission, canGrantRule, isActivityViewBound, SYSTEM_ORG_ID } from '../permissions.js'; import { ruleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; export function createRoleRuleRoutes(db: Pool) { @@ -28,7 +28,10 @@ export function createRoleRuleRoutes(db: Pool) { const { expr, params } = orgScopeExpr(orgIds, 'r.org_id'); const { rows } = await db.query( `SELECT r.id, r.role_id, r.org_id, o.name AS org_name, ro.name AS role_name, - r.activity, r.view, r.modality, r.priority, r.scope + r.activity, r.view, r.modality, r.priority, r.scope, + r.valid_from, r.valid_until, + (r.valid_from IS NULL OR r.valid_from <= now()) + AND (r.valid_until IS NULL OR r.valid_until > now()) AS is_active FROM morbac.rules r JOIN morbac.orgs o ON o.id = r.org_id JOIN morbac.roles ro ON ro.id = r.role_id @@ -57,8 +60,11 @@ export function createRoleRuleRoutes(db: Pool) { priority: { type: 'integer' }, scope: { type: 'string', - enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root'], + enum: ['self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'], default: 'self', + description: + 'Which objects the rule reaches: a specific org via the tree scopes, ' + + 'unattributed for objects with no org, or all for every org including unattributed.', }, }, }, @@ -79,6 +85,23 @@ export function createRoleRuleRoutes(db: Pool) { if (!await hasPermission(db, req, reply, org_id, 'create', 'rules')) return reply; + if (!await isActivityViewBound(db, activity, view)) { + return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` }); + } + + const { rows: [roleRow] } = await db.query<{ org_id: string }>( + 'SELECT org_id FROM morbac.roles WHERE id = $1', [role_id], + ); + if (!roleRow) return reply.code(404).send({ error: 'Not found' }); + + // Anti-escalation: only permission rules are constrained (prohibitions + // only ever reduce access). A caller may not grant, via any scope, a + // permission they do not themselves hold on every org that scope covers. + if (modality === 'permission' + && !await canGrantRule(db, user.id, org_id, scope, activity, view)) { + return reply.code(403).send({ error: 'Cannot grant a permission you do not hold on the target orgs' }); + } + try { const { rows } = await db.query( `INSERT INTO morbac.rules @@ -91,12 +114,20 @@ export function createRoleRuleRoutes(db: Pool) { RETURNING id, $2::UUID AS role_id, $1::UUID AS org_id, (SELECT name FROM morbac.orgs WHERE id = $1) AS org_name, (SELECT name FROM morbac.roles WHERE id = $2) AS role_name, - activity, view, modality, priority, scope`, + activity, view, modality, priority, scope, + valid_from, valid_until, is_active`, [org_id, role_id, activity, view, modality, scope, priority ?? null], ); + // The morbac AFTER trigger only clears cache for the rule's own org. + // A non-self scope also affects other orgs (ancestors/descendants), so + // flush the whole cache to avoid stale grant/revocation decisions. + if (scope !== 'self') await db.query('SELECT morbac.invalidate_cache()'); return reply.code(201).send(rows[0]); } catch (e: unknown) { - if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + const code = (e as { code?: string }).code; + if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' }); + if (code === 'P0001') return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` }); throw e; } }); @@ -118,14 +149,17 @@ export function createRoleRuleRoutes(db: Pool) { if (!user) return reply.code(401).send({ error: 'Authentication required' }); const { id } = req.params as { id: string }; - const { rows: [rule] } = await db.query<{ org_id: string }>( - 'SELECT org_id FROM morbac.rules WHERE id = $1', [id], + const { rows: [rule] } = await db.query<{ org_id: string; scope: string }>( + 'SELECT org_id, scope FROM morbac.rules WHERE id = $1', [id], ); if (!rule) return reply.code(404).send({ error: 'Not found' }); if (rule.org_id === SYSTEM_ORG_ID) return reply.code(403).send({ error: 'System entities are immutable' }); if (!await hasPermission(db, req, reply, rule.org_id, 'delete', 'rules')) return reply; await db.query('DELETE FROM morbac.rules WHERE id = $1', [id]); + // See POST: a non-self scope revocation must flush cache for the + // affected orgs, which the morbac trigger does not cover. + if (rule.scope !== 'self') await db.query('SELECT morbac.invalidate_cache()'); return reply.code(204).send(); }); }; diff --git a/src/routes/rule-user.ts b/src/routes/rule-user.ts index c26af20..fc3efa6 100644 --- a/src/routes/rule-user.ts +++ b/src/routes/rule-user.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; -import { getUser, getOrgScope, orgScopeExpr, hasPermission } from '../permissions.js'; +import { getUser, getOrgScope, orgScopeExpr, hasPermission, isActivityViewBound } from '../permissions.js'; import { userRuleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; export function createUserRuleRoutes(db: Pool) { @@ -33,7 +33,7 @@ export function createUserRuleRoutes(db: Pool) { o.name AS org_name, ur.activity, ur.view, ur.modality, ur.priority FROM morbac.user_rules ur - JOIN morbac.orgs o ON o.id = ur.org_id + LEFT JOIN morbac.orgs o ON o.id = ur.org_id LEFT JOIN app.users u ON u.id = ur.user_id ${expr ? `WHERE ${expr}` : ''} ORDER BY o.name, ur.activity, ur.view`, @@ -53,7 +53,10 @@ export function createUserRuleRoutes(db: Pool) { required: ['user_id', 'org_id', 'activity', 'view', 'modality'], properties: { user_id: { type: 'string', format: 'uuid' }, - org_id: { type: 'string', format: 'uuid' }, + org_id: { + type: 'string', format: 'uuid', nullable: true, + description: 'Organisation the rule targets, or null for unattributed objects (those with no org).', + }, activity: { type: 'string' }, view: { type: 'string' }, modality: { type: 'string', enum: ['permission', 'prohibition'] }, @@ -71,12 +74,16 @@ export function createUserRuleRoutes(db: Pool) { if (!user) return reply.code(401).send({ error: 'Authentication required' }); const { user_id, org_id, activity, view, modality, priority } = req.body as { - user_id: string; org_id: string; activity: string; + user_id: string; org_id: string | null; activity: string; view: string; modality: 'permission' | 'prohibition'; priority?: number; }; if (!await hasPermission(db, req, reply, org_id, 'create', 'user_rules')) return reply; + if (!await isActivityViewBound(db, activity, view)) { + return reply.code(400).send({ error: `Activity "${activity}" is not valid for view "${view}"` }); + } + try { const { rows } = await db.query( `INSERT INTO morbac.user_rules @@ -95,7 +102,9 @@ export function createUserRuleRoutes(db: Pool) { ); return reply.code(201).send(rows[0]); } catch (e: unknown) { - if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + const code = (e as { code?: string }).code; + if (code === '23505') return reply.code(409).send({ error: 'Rule already exists' }); + if (code === '23503') return reply.code(400).send({ error: 'Unknown activity or view' }); throw e; } }); diff --git a/src/routes/user-crud.ts b/src/routes/user-crud.ts index 0de2ab7..b6715f7 100644 --- a/src/routes/user-crud.ts +++ b/src/routes/user-crud.ts @@ -3,10 +3,12 @@ import type { Pool } from 'pg'; import bcrypt from 'bcryptjs'; import type { CheckLimitFn } from '../plugin.js'; import { - getUser, getOrgScope, orgScopeExpr, hasPermission, getAccountType, viewForType, SYSTEM_ORG_ID, + getUser, getOrgScope, orgScopeExpr, hasPermission, canActOnUser, checkApiKeyScope, + getAccountType, viewForType, SYSTEM_ORG_ID, } from '../permissions.js'; import { userResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; import { generatePassword } from '../password.js'; +import { callerControlsRoleOrg } from './role-authority.js'; export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { return async function userCrudRoutes(app: FastifyInstance) { @@ -31,7 +33,11 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'users')) return reply; const checkOrg = orgIds[0] ?? null; - const [sysRes, svcRes, globalRes] = await Promise.all([ + // A user awaiting placement belongs to no org, so seeing one is the + // no-org perimeter (a NULL org), not a claim about every org. Any + // grant that reaches it answers: a role rule scoped unattributed or + // all, a user rule with no org, or a global rule. + const [sysRes, svcRes, noOrgRes] = await Promise.all([ db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', [user.id, checkOrg, 'read', 'system_users']), db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed', @@ -39,13 +45,13 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { db.query<{ allowed: boolean }>('SELECT morbac.is_allowed($1::UUID, NULL, $2, $3) AS allowed', [user.id, 'read', 'users']), ]); - const canSeeSystem = sysRes.rows[0]?.allowed ?? false; - const canSeeService = svcRes.rows[0]?.allowed ?? false; - const canReadGlobally = globalRes.rows[0]?.allowed ?? false; + const canSeeSystem = sysRes.rows[0]?.allowed ?? false; + const canSeeService = svcRes.rows[0]?.allowed ?? false; + const canReadUnattached = noOrgRes.rows[0]?.allowed ?? false; const { expr, params } = orgScopeExpr(orgIds, 'ur2.org_id'); const isSystemScope = orgIds.includes(SYSTEM_ORG_ID); - const placement = canReadGlobally ? ' OR u.needs_placement' : ''; + const placement = canReadUnattached ? ' OR u.needs_placement' : ''; const whereClause = expr && !isSystemScope ? `WHERE (u.id IN (SELECT ur2.user_id FROM morbac.user_roles ur2 WHERE ${expr})${placement} OR u.account_type IN ('system', 'service'))` : ''; @@ -80,7 +86,7 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { schema: { tags: ['Management'], summary: 'Create a user', - description: 'Creates a user account. For `user` accounts a strong password is auto-generated and returned once as `generated_password`; the account must change it on first login. `service` accounts have no password (API-only). Requires `create`/`users` (or `create`/`service_users`) permission.', + description: 'Creates a user account. For `user` accounts a strong password is auto-generated and returned once as `generated_password`; the account must change it on first login. `service` accounts have no password (API-only). Requires `create`/`users` (or `create`/`service_users`) permission. Pass `placement` to give the account a role in one go: an account created without one is unplaced, and only a holder of unattributed `read`/`users` can see it afterwards, so a manager who creates a user without placing it loses sight of it.', security: [{ oauth2: [] }], body: { type: 'object', @@ -90,6 +96,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { name: { type: 'string', minLength: 1, maxLength: 255 }, account_type: { type: 'string', enum: ['user', 'service'], default: 'user' }, is_active: { type: 'boolean', default: true }, + placement: { + type: 'object', + required: ['role_id', 'org_id'], + additionalProperties: false, + properties: { + role_id: { type: 'string', format: 'uuid' }, + org_id: { type: 'string', format: 'uuid' }, + }, + }, }, }, response: { @@ -106,14 +121,25 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (orgIds === null) return reply; const orgId = orgIds[0] ?? null; - const { email: rawEmail, name, account_type = 'user', is_active = true } = req.body as { + const { email: rawEmail, name, account_type = 'user', is_active = true, placement } = req.body as { email: string; name: string; account_type?: 'user' | 'service'; is_active?: boolean; + placement?: { role_id: string; org_id: string }; }; const email = rawEmail.trim().toLowerCase(); const view = viewForType(account_type); if (!await hasPermission(db, req, reply, orgId, 'create', view)) return reply; + // Placing the account is a second act of authority, checked before + // anything is written so a refused placement creates no account. + if (placement) { + const assignView = account_type === 'service' ? 'service_users' : 'user_roles'; + if (!await hasPermission(db, req, reply, placement.org_id, 'create', assignView)) return reply; + if (!await callerControlsRoleOrg(db, user.id, placement.role_id, 'create')) { + return reply.code(403).send({ error: 'Cannot assign a role outside your authority' }); + } + } + if (checkLimit) { const limitName = account_type === 'service' ? 'max_service_users' : 'max_users'; const ok = await checkLimit( @@ -129,17 +155,35 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { // accounts are API-only and have no password. const generated = account_type === 'user' ? generatePassword() : null; const hash = generated ? await bcrypt.hash(generated, 12) : null; + const tx = await db.connect(); try { - const { rows } = await db.query( + await tx.query('BEGIN'); + const { rows } = await tx.query( `INSERT INTO app.users (email, name, password_hash, account_type, is_active, must_change_password) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, name, account_type, is_active, needs_placement, created_at`, [email, name, hash, account_type, is_active, generated !== null], ); - return reply.code(201).send({ ...rows[0], roles: [], ...(generated && { generated_password: generated }) }); + const created = rows[0] as { id: string; needs_placement: boolean }; + const roles: { role_id: string; org_id: string }[] = []; + if (placement) { + await tx.query('SELECT morbac.assign_role($1::UUID, $2::UUID, $3::UUID)', + [created.id, placement.role_id, placement.org_id]); + await tx.query('UPDATE app.users SET needs_placement = FALSE WHERE id = $1', [created.id]); + created.needs_placement = false; + roles.push(placement); + } + await tx.query('COMMIT'); + return reply.code(201).send({ ...created, roles, ...(generated && { generated_password: generated }) }); } catch (e: unknown) { + await tx.query('ROLLBACK').catch(() => undefined); if ((e as { code?: string }).code === '23505') return reply.code(409).send({ error: 'Email already in use' }); + if ((e as { message?: string }).message?.includes('Separation of Duty')) { + return reply.code(409).send({ error: 'Role assignment would violate Separation of Duty constraints' }); + } throw e; + } finally { + tx.release(); } }); @@ -168,15 +212,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); - const orgIds = getOrgScope(req, reply, false); - if (orgIds === null) return reply; - const orgId = orgIds[0] ?? null; - const { id } = req.params as { id: string }; const type = await getAccountType(db, id); if (!type) return reply.code(404).send({ error: 'Not found' }); if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' }); - if (!await hasPermission(db, req, reply, orgId, 'update', viewForType(type))) return reply; + const view = viewForType(type); + if (!checkApiKeyScope(req, reply, 'update', view)) return reply; + if (!await canActOnUser(db, user.id, id, 'update', view)) { + return reply.code(404).send({ error: 'Not found' }); + } const { name, email, is_active, reset_password } = req.body as { name?: string; email?: string; is_active?: boolean; reset_password?: boolean; @@ -215,15 +259,15 @@ export function createUserCrudRoutes(db: Pool, checkLimit?: CheckLimitFn) { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); - const orgIds = getOrgScope(req, reply, false); - if (orgIds === null) return reply; - const orgId = orgIds[0] ?? null; - const { id } = req.params as { id: string }; const type = await getAccountType(db, id); if (!type) return reply.code(404).send({ error: 'Not found' }); if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' }); - if (!await hasPermission(db, req, reply, orgId, 'delete', viewForType(type))) return reply; + const view = viewForType(type); + if (!checkApiKeyScope(req, reply, 'delete', view)) return reply; + if (!await canActOnUser(db, user.id, id, 'delete', view)) { + return reply.code(404).send({ error: 'Not found' }); + } await db.query('DELETE FROM app.users WHERE id = $1', [id]); return reply.code(204).send(); diff --git a/src/routes/user-keys.ts b/src/routes/user-keys.ts index 2ff7432..ec71c75 100644 --- a/src/routes/user-keys.ts +++ b/src/routes/user-keys.ts @@ -1,7 +1,7 @@ import { randomBytes, createHash } from 'node:crypto'; import type { FastifyInstance, FastifyReply } from 'fastify'; import type { Pool } from 'pg'; -import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js'; +import { getUser, canActOnUser, checkApiKeyScope, getAccountType } from '../permissions.js'; import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; function generateKey(): { raw: string; hash: string; prefix: string } { @@ -78,11 +78,10 @@ export function createUserKeyRoutes(db: Pool) { }, async (req, reply) => { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); - const orgIds = getOrgScope(req, reply, false); - if (orgIds === null) return reply; const { id } = req.params as { id: string }; if (!await ensureService(id, reply)) return reply; - if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'service_users')) return reply; + if (!checkApiKeyScope(req, reply, 'read', 'service_users')) return reply; + if (!await canActOnUser(db, user.id, id, 'read', 'service_users')) return reply.code(404).send({ error: 'Not found' }); const { rows } = await db.query( `SELECT id, name, description, key_prefix, readonly, permission_scope, expires_at, last_used_at, created_at @@ -107,11 +106,10 @@ export function createUserKeyRoutes(db: Pool) { }, async (req, reply) => { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); - const orgIds = getOrgScope(req, reply, false); - if (orgIds === null) return reply; const { id } = req.params as { id: string }; if (!await ensureService(id, reply)) return reply; - if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'read', 'service_users')) return reply; + if (!checkApiKeyScope(req, reply, 'read', 'service_users')) return reply; + if (!await canActOnUser(db, user.id, id, 'read', 'service_users')) return reply.code(404).send({ error: 'Not found' }); return heldPerms(db, id); }); @@ -142,11 +140,10 @@ export function createUserKeyRoutes(db: Pool) { }, async (req, reply) => { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); - const orgIds = getOrgScope(req, reply, false); - if (orgIds === null) return reply; const { id } = req.params as { id: string }; if (!await ensureService(id, reply)) return reply; - if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'create', 'service_users')) return reply; + if (!checkApiKeyScope(req, reply, 'create', 'service_users')) return reply; + if (!await canActOnUser(db, user.id, id, 'create', 'service_users')) return reply.code(404).send({ error: 'Not found' }); const { name, description, readonly = false, expires_at, permission_scope } = req.body as { name: string; description?: string; readonly?: boolean; expires_at?: string | null; permission_scope?: Scope; @@ -201,11 +198,10 @@ export function createUserKeyRoutes(db: Pool) { }, async (req, reply) => { const user = getUser(req); if (!user) return reply.code(401).send({ error: 'Authentication required' }); - const orgIds = getOrgScope(req, reply, false); - if (orgIds === null) return reply; const { id, keyId } = req.params as { id: string; keyId: string }; if (!await ensureService(id, reply)) return reply; - if (!await hasPermission(db, req, reply, orgIds[0] ?? null, 'delete', 'service_users')) return reply; + if (!checkApiKeyScope(req, reply, 'delete', 'service_users')) return reply; + if (!await canActOnUser(db, user.id, id, 'delete', 'service_users')) return reply.code(404).send({ error: 'Not found' }); await db.query('DELETE FROM app.api_keys WHERE id = $1 AND user_id = $2', [keyId, id]); return reply.code(204).send(); diff --git a/src/routes/user-org-filter.ts b/src/routes/user-org-filter.ts new file mode 100644 index 0000000..37493d6 --- /dev/null +++ b/src/routes/user-org-filter.ts @@ -0,0 +1,132 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { Pool } from 'pg'; +import { getUser, canActOnUser, checkApiKeyScope, getAccountType, viewForType } from '../permissions.js'; +import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; + +const MAX_ORG_FILTER = 100; + +const orgFilterArray = { + type: 'array', + maxItems: MAX_ORG_FILTER, + items: { type: 'string', format: 'uuid' }, +} as const; + +const orgFilterResponse = { + type: 'object', + properties: { org_filter: orgFilterArray }, +} as const; + +/** Subset of `ids` that `userId` is allowed to read as orgs. Used both to filter + * what a caller may see and to validate what a caller may write, so neither + * direction can be used to probe orgs outside the caller's reach. */ +async function visibleOrgIds(db: Pool, userId: string, ids: string[]): Promise { + if (ids.length === 0) return []; + const { rows } = await db.query<{ id: string }>( + `SELECT o.id FROM morbac.orgs o + WHERE o.id = ANY($2::UUID[]) AND morbac.is_allowed($1::UUID, o.id, 'read', 'orgs')`, + [userId, ids], + ); + return rows.map((r) => r.id); +} + +async function storedOrgFilter(db: Pool, userId: string): Promise { + const { rows } = await db.query<{ org_filter: unknown }>( + `SELECT ui_preferences->'orgFilter' AS org_filter FROM app.users WHERE id = $1`, + [userId], + ); + const value = rows[0]?.org_filter; + if (!Array.isArray(value)) return []; + return value.filter((id): id is string => typeof id === 'string'); +} + +/** The org filter is a display preference, never an authorization input: every + * route still authorizes the org ids it receives on the wire. */ +export function createUserOrgFilterRoutes(db: Pool) { + return async function userOrgFilterRoutes(app: FastifyInstance) { + async function resolveView(id: string, reply: FastifyReply): Promise { + const type = await getAccountType(db, id); + if (type === null || type === 'system') { + await reply.code(404).send({ error: 'Not found' }); + return null; + } + return viewForType(type); + } + + app.get('/users/:id/org-filter', { + schema: { + tags: ['Management'], + summary: 'Read a user organization filter', + description: 'Organization filter stored in the user preferences, restricted to the orgs the caller can read. Requires `read`/`users` (or `service_users`).', + security: [{ oauth2: [] }], + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + response: { 200: orgFilterResponse, 401: unauthorized, 403: forbidden, 404: notFound }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + const { id } = req.params as { id: string }; + + const view = await resolveView(id, reply); + if (!view) return reply; + if (!checkApiKeyScope(req, reply, 'read', view)) return reply; + if (!await canActOnUser(db, user.id, id, 'read', view)) return reply.code(404).send({ error: 'Not found' }); + + const stored = await storedOrgFilter(db, id); + return { org_filter: await visibleOrgIds(db, user.id, stored) }; + }); + + app.patch('/users/:id/org-filter', { + schema: { + tags: ['Management'], + summary: 'Replace a user organization filter', + description: 'Replaces the slice of the organization filter the caller can see. Ids outside the caller reach are preserved untouched. Requires `update`/`users` (or `service_users`).', + security: [{ oauth2: [] }], + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + body: { + type: 'object', + required: ['org_filter'], + additionalProperties: false, + properties: { org_filter: orgFilterArray }, + }, + response: { 200: orgFilterResponse, 400: badRequest, 401: unauthorized, 403: forbidden, 404: notFound }, + }, + }, async (req, reply) => { + const user = getUser(req); + if (!user) return reply.code(401).send({ error: 'Authentication required' }); + const { id } = req.params as { id: string }; + + const type = await getAccountType(db, id); + if (type === null) return reply.code(404).send({ error: 'Not found' }); + if (type === 'system') return reply.code(403).send({ error: 'System entities are immutable' }); + const view = viewForType(type); + + if (!checkApiKeyScope(req, reply, 'update', view)) return reply; + if (!await canActOnUser(db, user.id, id, 'update', view)) return reply.code(404).send({ error: 'Not found' }); + + const submitted = [...new Set((req.body as { org_filter: string[] }).org_filter)]; + + // Only the caller's own reach is validated. The target's is deliberately + // not: the filter is intersected with what the target can see at render + // time, so an id they cannot see shows nothing and grants nothing, and + // forbidding it would forbid preserving the very ids below. + const visibleToCaller = await visibleOrgIds(db, user.id, submitted); + if (visibleToCaller.length !== submitted.length) { + return reply.code(400).send({ error: 'Invalid organization' }); + } + + const stored = await storedOrgFilter(db, id); + const callerVisibleStored = new Set(await visibleOrgIds(db, user.id, stored)); + const preserved = stored.filter((orgId) => !callerVisibleStored.has(orgId)); + const next = [...new Set([...submitted, ...preserved])]; + + await db.query( + `UPDATE app.users + SET ui_preferences = COALESCE(ui_preferences, '{}'::jsonb) || jsonb_build_object('orgFilter', $1::jsonb) + WHERE id = $2`, + [JSON.stringify(next), id], + ); + + return { org_filter: submitted }; + }); + }; +} diff --git a/src/routes/user-roles.ts b/src/routes/user-roles.ts index cbe1ebd..c0a6dfd 100644 --- a/src/routes/user-roles.ts +++ b/src/routes/user-roles.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; import { getUser, getOrgScope, hasPermission, getAccountType } from '../permissions.js'; + import { notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; +import { callerControlsRoleOrg } from './role-authority.js'; export function createUserRoleRoutes(db: Pool) { return async function userRoleRoutes(app: FastifyInstance) { @@ -44,6 +46,9 @@ export function createUserRoleRoutes(db: Pool) { if (assignType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' }); const assignView = assignType === 'service' ? 'service_users' : 'user_roles'; if (!await hasPermission(db, req, reply, org_id, 'create', assignView)) return reply; + if (!await callerControlsRoleOrg(db, caller.id, role_id, 'create')) { + return reply.code(403).send({ error: 'Cannot assign a role outside your authority' }); + } try { await db.query( @@ -172,6 +177,9 @@ export function createUserRoleRoutes(db: Pool) { if (revokeType === 'system') return reply.code(403).send({ error: 'System account roles are immutable' }); const revokeView = revokeType === 'service' ? 'service_users' : 'user_roles'; if (!await hasPermission(db, req, reply, org_id, 'delete', revokeView)) return reply; + if (!await callerControlsRoleOrg(db, caller.id, role_id, 'delete')) { + return reply.code(403).send({ error: 'Cannot revoke a role outside your authority' }); + } await db.query( 'SELECT morbac.revoke_role($1::UUID, $2::UUID, $3::UUID)', diff --git a/src/routes/users.ts b/src/routes/users.ts index b5dfb6d..fcab637 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -4,11 +4,13 @@ import type { CheckLimitFn } from '../plugin.js'; import { createUserCrudRoutes } from './user-crud.js'; import { createUserRoleRoutes } from './user-roles.js'; import { createUserKeyRoutes } from './user-keys.js'; +import { createUserOrgFilterRoutes } from './user-org-filter.js'; export function createUserRoutes(db: Pool, checkLimit?: CheckLimitFn) { return async function userRoutes(app: FastifyInstance) { await app.register(createUserCrudRoutes(db, checkLimit)); await app.register(createUserRoleRoutes(db)); await app.register(createUserKeyRoutes(db)); + await app.register(createUserOrgFilterRoutes(db)); }; } diff --git a/src/schemas.ts b/src/schemas.ts index 1d76c04..5e49506 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -5,17 +5,21 @@ export const orgResponse = { name: { type: 'string' }, parent_id: { type: 'string', format: 'uuid', nullable: true }, metadata: { type: 'object' }, + path: { type: 'array', items: { type: 'string' } }, }, }; export const roleResponse = { type: 'object', properties: { - id: { type: 'string', format: 'uuid' }, - org_id: { type: 'string', format: 'uuid' }, - org_name: { type: 'string' }, - name: { type: 'string' }, - description: { type: 'string', nullable: true }, + id: { type: 'string', format: 'uuid' }, + org_id: { type: 'string', format: 'uuid' }, + org_name: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string', nullable: true }, + profile_module_id: { type: 'string', nullable: true }, + profile_key: { type: 'string', nullable: true }, + profile_synced: { type: 'boolean', nullable: true }, }, }; @@ -32,6 +36,9 @@ export const ruleResponse = { modality: { type: 'string', enum: ['permission', 'prohibition'] }, priority: { type: 'integer', nullable: true }, scope: { type: 'string' }, + valid_from: { type: 'string', format: 'date-time', nullable: true }, + valid_until: { type: 'string', format: 'date-time', nullable: true }, + is_active: { type: 'boolean' }, }, }; @@ -82,10 +89,11 @@ export const userRuleResponse = { properties: { id: { type: 'string', format: 'uuid' }, user_id: { type: 'string', format: 'uuid' }, - org_id: { type: 'string', format: 'uuid' }, + // null org = unattributed (the rule targets objects with no org) + org_id: { type: 'string', format: 'uuid', nullable: true }, user_name: { type: 'string' }, user_email: { type: 'string', nullable: true }, - org_name: { type: 'string' }, + org_name: { type: 'string', nullable: true }, activity: { type: 'string' }, view: { type: 'string' }, modality: { type: 'string', enum: ['permission', 'prohibition'] },