feat(system): introduce global rules and system principals for easier system account handling

This commit is contained in:
2026-04-04 09:23:46 +02:00
parent f64cd73159
commit 0e3b90af22
11 changed files with 1129 additions and 64 deletions
+35
View File
@@ -125,6 +125,41 @@ 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()
+100 -53
View File
@@ -32,64 +32,92 @@ DECLARE
v_rule RECORD;
v_max_prohibition_priority INTEGER := NULL;
v_max_permission_priority INTEGER := NULL;
v_is_system_principal BOOLEAN := FALSE;
BEGIN
-- 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;
SELECT TRUE INTO v_is_system_principal
FROM morbac.system_principals WHERE user_id = p_user_id;
-- 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;
-- STEPS 1-3.5: Prohibitions — skipped entirely for system principals
IF NOT v_is_system_principal THEN
-- 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 = 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
-- 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;
EXIT;
END IF;
END LOOP;
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 = 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
@@ -149,6 +177,25 @@ BEGIN
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;
+3 -1
View File
@@ -23,7 +23,9 @@ INSERT INTO morbac.config (key, value, description) VALUES
('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.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
+57
View File
@@ -0,0 +1,57 @@
-- 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();
+100
View File
@@ -0,0 +1,100 @@
-- 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
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();
+26
View File
@@ -180,6 +180,32 @@ 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;