2842 lines
104 KiB
PL/PgSQL
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;
|