feat(rules): add rules for users
This commit is contained in:
@@ -100,6 +100,31 @@ CREATE TRIGGER trg_invalidate_cache_cross_org
|
|||||||
AFTER INSERT OR UPDATE OR DELETE ON morbac.cross_org_rules
|
AFTER INSERT OR UPDATE OR DELETE ON morbac.cross_org_rules
|
||||||
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change();
|
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
|
-- Refresh materialized hierarchy views when hierarchies change
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change()
|
CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change()
|
||||||
|
|||||||
+44
-3
@@ -15,6 +15,7 @@
|
|||||||
-- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...).
|
-- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...).
|
||||||
-- Evaluated at query time via org_in_scope() — new orgs are covered automatically.
|
-- 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.
|
-- - 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(
|
CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache(
|
||||||
p_user_id UUID,
|
p_user_id UUID,
|
||||||
@@ -70,7 +71,27 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
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
|
FOR v_rule IN
|
||||||
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
|
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
|
||||||
FROM morbac.rules r
|
FROM morbac.rules r
|
||||||
@@ -88,7 +109,7 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
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
|
FOR v_rule IN
|
||||||
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
|
SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio
|
||||||
FROM morbac.cross_org_rules cr
|
FROM morbac.cross_org_rules cr
|
||||||
@@ -108,7 +129,27 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
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
|
IF v_max_prohibition_priority IS NULL THEN
|
||||||
RETURN v_max_permission_priority IS NOT NULL;
|
RETURN v_max_permission_priority IS NOT NULL;
|
||||||
END IF;
|
END IF;
|
||||||
|
|||||||
+2
-1
@@ -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.activities', 'activities', 'View name for morbac.activities table access control'),
|
||||||
('system_view.views', 'views', 'View name for morbac.views 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.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)
|
CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT)
|
||||||
RETURNS TEXT
|
RETURNS TEXT
|
||||||
|
|||||||
+39
-4
@@ -48,6 +48,30 @@ $$;
|
|||||||
COMMENT ON FUNCTION morbac.current_org_id() IS
|
COMMENT ON FUNCTION morbac.current_org_id() IS
|
||||||
'Returns current organization ID from morbac.org_id session variable';
|
'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"]'
|
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]'
|
||||||
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
|
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
|
||||||
RETURNS UUID[]
|
RETURNS UUID[]
|
||||||
@@ -94,11 +118,13 @@ COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
|
|||||||
'Returns all org IDs the user has any direct role or active delegation in';
|
'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.
|
-- 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(
|
CREATE OR REPLACE FUNCTION morbac.rls_check(
|
||||||
p_activity TEXT,
|
p_activity TEXT,
|
||||||
p_view TEXT,
|
p_view TEXT,
|
||||||
p_row_org_id UUID DEFAULT NULL
|
p_row_org_id UUID DEFAULT NULL,
|
||||||
|
p_row_user_id UUID DEFAULT NULL
|
||||||
)
|
)
|
||||||
RETURNS BOOLEAN
|
RETURNS BOOLEAN
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@@ -108,6 +134,7 @@ DECLARE
|
|||||||
v_user_id UUID;
|
v_user_id UUID;
|
||||||
v_org_id UUID;
|
v_org_id UUID;
|
||||||
v_org_ids UUID[];
|
v_org_ids UUID[];
|
||||||
|
v_target_user_id UUID;
|
||||||
BEGIN
|
BEGIN
|
||||||
v_user_id := morbac.current_user_id();
|
v_user_id := morbac.current_user_id();
|
||||||
|
|
||||||
@@ -115,6 +142,14 @@ BEGIN
|
|||||||
RETURN FALSE;
|
RETURN FALSE;
|
||||||
END IF;
|
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();
|
v_org_id := morbac.current_org_id();
|
||||||
|
|
||||||
IF v_org_id IS NOT NULL THEN
|
IF v_org_id IS NOT NULL THEN
|
||||||
@@ -141,5 +176,5 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID) IS
|
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 to enable org scoping (single org, org list, or all orgs).';
|
'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.';
|
||||||
|
|||||||
@@ -161,6 +161,25 @@ CREATE POLICY delegations_delete ON morbac.delegations FOR DELETE
|
|||||||
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
|
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
|
||||||
morbac.get_config('system_view.delegations')));
|
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
|
-- morbac.cross_org_rules
|
||||||
ALTER TABLE morbac.cross_org_rules ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE morbac.cross_org_rules ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|||||||
@@ -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.';
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- User Rules Tests
|
||||||
|
-- =============================================================================
|
||||||
|
-- Tests direct user-level rules via morbac.user_rules.
|
||||||
|
--
|
||||||
|
-- Scenarios:
|
||||||
|
-- 1. No user rule: Karl (no role) is denied by default
|
||||||
|
-- 2. Direct user permission: Karl gets access without any role assignment
|
||||||
|
-- 3. User rule covers activity/view hierarchy
|
||||||
|
-- 4. User-level prohibition overrides a role-based permission
|
||||||
|
-- 5. Priority: user permission with higher priority overrides user prohibition
|
||||||
|
-- 6. Temporal user rules (valid_from / valid_until)
|
||||||
|
-- 7. rls_check user filter: morbac.target_user_id scopes rows to a specific user
|
||||||
|
--
|
||||||
|
-- Prerequisites: 00_setup.sql -> 11_scope_rules.sql
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
\echo ''
|
||||||
|
\echo '================================================================'
|
||||||
|
\echo '12 — USER RULES'
|
||||||
|
\echo '================================================================'
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Section 1: No user rule — Karl (no role) is denied
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
\echo ''
|
||||||
|
\echo '--- 1. No user rule: access denied ---'
|
||||||
|
|
||||||
|
SELECT morbac.t('Karl (no role) reads GlobalTech documents [no user rule]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'documents'
|
||||||
|
), FALSE);
|
||||||
|
|
||||||
|
SELECT morbac.t('Karl (no role) reads GlobalTech financial_data [no user rule]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'financial_data'
|
||||||
|
), FALSE);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Section 2: Direct user permission — Karl gets access without a role
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
\echo ''
|
||||||
|
\echo '--- 2. Direct user permission ---'
|
||||||
|
|
||||||
|
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
|
||||||
|
VALUES (
|
||||||
|
'30000000-0000-0000-0000-000000000011', -- Karl
|
||||||
|
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
|
||||||
|
'read', 'documents',
|
||||||
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
||||||
|
'permission'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT morbac.t('Karl (no role) reads GlobalTech documents [user rule grants access]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'documents'
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- Rule only covers GlobalTech, not Engineering
|
||||||
|
SELECT morbac.t('Karl reads Engineering documents [no user rule for Engineering]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000002'::uuid,
|
||||||
|
'read', 'documents'
|
||||||
|
), FALSE);
|
||||||
|
|
||||||
|
-- financial_data is a subtype of documents (view hierarchy), so the documents rule covers it
|
||||||
|
SELECT morbac.t('Karl reads GlobalTech financial_data [documents rule covers it via view hierarchy]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'financial_data'
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- contracts has no hierarchy relationship — documents rule does not cover it
|
||||||
|
SELECT morbac.t('Karl reads GlobalTech contracts [no user rule, no hierarchy coverage]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'contracts'
|
||||||
|
), FALSE);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Section 3: User rule + activity/view hierarchy
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
\echo ''
|
||||||
|
\echo '--- 3. User rule with activity/view hierarchy ---'
|
||||||
|
|
||||||
|
-- Add a user rule for 'read reports' (parent of financial_data via view hierarchy)
|
||||||
|
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
|
||||||
|
VALUES (
|
||||||
|
'30000000-0000-0000-0000-000000000011', -- Karl
|
||||||
|
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
|
||||||
|
'read', 'reports',
|
||||||
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
||||||
|
'permission'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- get_effective_activities('write') includes 'read', so read permission covers write requests
|
||||||
|
SELECT morbac.t('Karl writes GlobalTech documents [user rule read covers write via activity hierarchy]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'write', 'documents'
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Section 4: User-level prohibition overrides role-based permission
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
\echo ''
|
||||||
|
\echo '--- 4. User prohibition overrides role permission ---'
|
||||||
|
|
||||||
|
-- Eve (intern) can read public_data via role
|
||||||
|
SELECT morbac.t('Eve (intern) reads GlobalTech public_data [role permission]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'public_data'
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- Add a user prohibition for Eve on public_data
|
||||||
|
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
|
||||||
|
VALUES (
|
||||||
|
'30000000-0000-0000-0000-000000000005', -- Eve
|
||||||
|
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
|
||||||
|
'read', 'public_data',
|
||||||
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
||||||
|
'prohibition'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT morbac.t('Eve (intern) reads GlobalTech public_data [user prohibition blocks role permission]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'public_data'
|
||||||
|
), FALSE);
|
||||||
|
|
||||||
|
-- Dave (employee) is unaffected — only Eve has the prohibition
|
||||||
|
SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibition]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000004'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'public_data'
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Section 5: Priority — higher-priority user permission overrides prohibition
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
\echo ''
|
||||||
|
\echo '--- 5. Priority: user permission overrides user prohibition ---'
|
||||||
|
|
||||||
|
-- Eve has a prohibition (priority 0) on public_data; add a higher-priority permission
|
||||||
|
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, priority)
|
||||||
|
VALUES (
|
||||||
|
'30000000-0000-0000-0000-000000000005', -- Eve
|
||||||
|
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
|
||||||
|
'read', 'public_data',
|
||||||
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
||||||
|
'permission',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT morbac.t('Eve reads GlobalTech public_data [priority-10 user permission beats priority-0 prohibition]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000005'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'public_data'
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- Clean up the priority override for the temporal test
|
||||||
|
DELETE FROM morbac.user_rules
|
||||||
|
WHERE user_id = '30000000-0000-0000-0000-000000000005'
|
||||||
|
AND modality = 'permission'
|
||||||
|
AND priority = 10;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Section 6: Temporal user rules
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
\echo ''
|
||||||
|
\echo '--- 6. Temporal user rules ---'
|
||||||
|
|
||||||
|
-- Use contracts: Karl has no other rules covering it, so expiry is conclusive
|
||||||
|
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, valid_from, valid_until)
|
||||||
|
VALUES (
|
||||||
|
'30000000-0000-0000-0000-000000000011', -- Karl
|
||||||
|
'10000000-0000-0000-0000-000000000001', -- GlobalTech HQ
|
||||||
|
'read', 'contracts',
|
||||||
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
||||||
|
'permission',
|
||||||
|
now() - interval '1 hour',
|
||||||
|
now() + interval '1 day'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT morbac.t('Karl reads GlobalTech contracts [temporal user rule, active]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'contracts'
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- Expire the rule
|
||||||
|
UPDATE morbac.user_rules
|
||||||
|
SET valid_until = now() - interval '1 second'
|
||||||
|
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
||||||
|
AND org_id = '10000000-0000-0000-0000-000000000001'
|
||||||
|
AND activity = 'read' AND view = 'contracts'
|
||||||
|
AND modality = 'permission';
|
||||||
|
|
||||||
|
SELECT morbac.t('Karl reads GlobalTech contracts [temporal user rule, expired]',
|
||||||
|
morbac.is_allowed_nocache(
|
||||||
|
'30000000-0000-0000-0000-000000000011'::uuid,
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'read', 'contracts'
|
||||||
|
), FALSE);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Section 7: rls_check user filter via morbac.target_user_id
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
\echo ''
|
||||||
|
\echo '--- 7. rls_check user filter ---'
|
||||||
|
|
||||||
|
SET morbac.user_id = '30000000-0000-0000-0000-000000000004'; -- Dave (employee)
|
||||||
|
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ
|
||||||
|
|
||||||
|
-- No target_user_id set: row with any user_id passes the user filter
|
||||||
|
SELECT morbac.t('rls_check passes without target_user_id filter',
|
||||||
|
morbac.rls_check(
|
||||||
|
'read', 'documents',
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'30000000-0000-0000-0000-000000000004'::uuid
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- Set target_user_id to Dave — rows belonging to Dave pass
|
||||||
|
SET morbac.target_user_id = '30000000-0000-0000-0000-000000000004';
|
||||||
|
|
||||||
|
SELECT morbac.t('rls_check passes when row user_id matches target_user_id',
|
||||||
|
morbac.rls_check(
|
||||||
|
'read', 'documents',
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'30000000-0000-0000-0000-000000000004'::uuid
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
-- Row belonging to Alice is filtered out
|
||||||
|
SELECT morbac.t('rls_check blocked when row user_id differs from target_user_id',
|
||||||
|
morbac.rls_check(
|
||||||
|
'read', 'documents',
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'30000000-0000-0000-0000-000000000001'::uuid
|
||||||
|
), FALSE);
|
||||||
|
|
||||||
|
-- No p_row_user_id passed — user filter does not apply
|
||||||
|
SELECT morbac.t('rls_check passes when no row user_id passed (filter skipped)',
|
||||||
|
morbac.rls_check(
|
||||||
|
'read', 'documents',
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid
|
||||||
|
), TRUE);
|
||||||
|
|
||||||
|
RESET morbac.target_user_id;
|
||||||
|
RESET morbac.user_id;
|
||||||
|
RESET morbac.org_id;
|
||||||
|
|
||||||
|
\echo ''
|
||||||
|
\echo '=== User Rules Tests Completed ==='
|
||||||
@@ -29,6 +29,7 @@ BUILD_FILES=(
|
|||||||
"contexts.sql"
|
"contexts.sql"
|
||||||
"rules.sql"
|
"rules.sql"
|
||||||
"cross_org_rules.sql"
|
"cross_org_rules.sql"
|
||||||
|
"user_rules.sql"
|
||||||
"activity_view_bindings.sql"
|
"activity_view_bindings.sql"
|
||||||
"obligations.sql"
|
"obligations.sql"
|
||||||
"audit.sql"
|
"audit.sql"
|
||||||
|
|||||||
Reference in New Issue
Block a user