feat(rules): add rules for users

This commit is contained in:
2026-04-01 23:18:01 +02:00
parent 5ef8bae1b4
commit f64cd73159
8 changed files with 437 additions and 13 deletions
+25
View File
@@ -100,6 +100,31 @@ 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();
-- Refresh materialized hierarchy views when hierarchies change
CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change()
+44 -3
View File
@@ -15,6 +15,7 @@
-- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...).
-- 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).
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
p_user_id UUID,
@@ -70,7 +71,27 @@ BEGIN
END IF;
END LOOP;
-- STEP 3: Local permissions — find the highest-priority applicable one
-- 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 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
@@ -88,7 +109,7 @@ BEGIN
END IF;
END LOOP;
-- STEP 4: Cross-org permissions — update max if higher found
-- 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
@@ -108,7 +129,27 @@ BEGIN
END IF;
END LOOP;
-- STEP 5: Priority resolution
-- 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 = 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 7: Priority resolution
IF v_max_prohibition_priority IS NULL THEN
RETURN v_max_permission_priority IS NOT NULL;
END IF;
+2 -1
View File
@@ -22,7 +22,8 @@ INSERT INTO morbac.config (key, value, description) VALUES
('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.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');
CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT)
RETURNS TEXT
+44 -9
View File
@@ -48,6 +48,30 @@ $$;
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"]'
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
RETURNS UUID[]
@@ -94,20 +118,23 @@ COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
'Returns all org IDs the user has any direct role or active delegation in';
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
-- Pass the row org_id column to enable scoping: rls_check('read', 'docs', org_id)
-- User scoping: morbac.target_user_id filters rows to a specific user.
-- Pass row columns to enable scoping: rls_check('read', 'docs', org_id, user_id)
CREATE OR REPLACE FUNCTION morbac.rls_check(
p_activity TEXT,
p_view TEXT,
p_row_org_id UUID DEFAULT NULL
p_activity TEXT,
p_view TEXT,
p_row_org_id UUID DEFAULT NULL,
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_user_id UUID;
v_org_id UUID;
v_org_ids UUID[];
v_target_user_id UUID;
BEGIN
v_user_id := morbac.current_user_id();
@@ -115,6 +142,14 @@ BEGIN
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;
v_org_id := morbac.current_org_id();
IF v_org_id IS NOT NULL THEN
@@ -141,5 +176,5 @@ BEGIN
END;
$$;
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID) IS
'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id to enable org scoping (single org, org list, or all orgs).';
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS
'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id for org scoping (single org, org list, or all orgs). Pass row user_id to filter by morbac.target_user_id session variable.';
+19
View File
@@ -161,6 +161,25 @@ 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.cross_org_rules
ALTER TABLE morbac.cross_org_rules ENABLE ROW LEVEL SECURITY;
+33
View File
@@ -0,0 +1,33 @@
-- 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.
CREATE TABLE morbac.user_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
org_id UUID NOT NULL 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)
);
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';
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.';