Files
pgmorbac-fastify/sql/pgmorbac--1.0.0.sql
T
2026-10-02 22:48:16 +02:00

2842 lines
104 KiB
PL/PgSQL

-- 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;