-- 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. -- Role-bound rules (rules, cross_org_rules) apply only in orgs where the user -- holds a role. Those roles are resolved once per org that has a direct -- assignment, delegation or derived role for the user and a rule matching the -- request, then the matching rules are read through that (org, role) set. -- Resolving roles per rule row instead made the cost grow with every rule of -- every tenant. 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; v_activities TEXT[]; v_views TEXT[]; v_held_org UUID[]; v_held_role UUID[]; v_local_modality morbac.modality[]; v_local_context UUID[]; v_local_prio INTEGER[]; v_cross_modality morbac.modality[]; v_cross_context UUID[]; v_cross_prio INTEGER[]; BEGIN v_is_system_principal := EXISTS ( SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id ); v_activities := ARRAY(SELECT activity FROM morbac.get_effective_activities(p_activity)); v_views := ARRAY(SELECT view FROM morbac.get_effective_views(p_view)); -- Roles the user holds, per org. get_comprehensive_roles() returns nothing -- in an org without a direct assignment, delegation or derived role for the -- user, and its result only matters where a rule matches the request. WITH orgs AS MATERIALIZED ( SELECT o.org_id FROM ( SELECT ur.org_id FROM morbac.user_roles ur WHERE ur.user_id = p_user_id UNION SELECT d.org_id FROM morbac.delegations d WHERE d.delegatee_id = p_user_id UNION SELECT ro.org_id FROM morbac.derived_roles dr INNER JOIN morbac.roles ro ON ro.id = dr.role_id ) o WHERE EXISTS ( SELECT 1 FROM morbac.rules r WHERE r.org_id = o.org_id AND r.activity = ANY (v_activities) AND r.view = ANY (v_views) ) OR EXISTS ( SELECT 1 FROM morbac.cross_org_rules cr WHERE cr.source_org_id = o.org_id AND cr.target_org_id = p_org_id AND cr.activity = ANY (v_activities) AND cr.view = ANY (v_views) ) ), held AS ( SELECT DISTINCT c.org_id, g.role_id FROM orgs c CROSS JOIN LATERAL morbac.get_comprehensive_roles(p_user_id, c.org_id) g ) SELECT array_agg(h.org_id ORDER BY h.org_id, h.role_id), array_agg(h.role_id ORDER BY h.org_id, h.role_id) INTO v_held_org, v_held_role FROM held h; -- Local rules applicable at p_org_id (prohibitions are not collected for -- system principals, which skip them) WITH matched AS MATERIALIZED ( SELECT r.org_id, r.scope, r.modality, r.context_id, COALESCE(r.priority, 0) AS prio FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id) INNER JOIN morbac.rules r ON r.org_id = h.org_id AND r.role_id = h.role_id WHERE r.activity = ANY (v_activities) AND r.view = ANY (v_views) AND (r.modality = 'permission' OR (r.modality = 'prohibition' AND NOT v_is_system_principal)) AND morbac.is_rule_valid(r.valid_from, r.valid_until) ), in_scope AS MATERIALIZED ( SELECT s.org_id, s.scope FROM (SELECT DISTINCT m.org_id, m.scope FROM matched m) s WHERE morbac.org_in_scope(p_org_id, s.org_id, s.scope) ) SELECT array_agg(m.modality ORDER BY m.prio DESC, m.modality, m.context_id), array_agg(m.context_id ORDER BY m.prio DESC, m.modality, m.context_id), array_agg(m.prio ORDER BY m.prio DESC, m.modality, m.context_id) INTO v_local_modality, v_local_context, v_local_prio FROM matched m INNER JOIN in_scope s ON s.org_id = m.org_id AND s.scope = m.scope; -- Cross-org rules into p_org_id whose role the user holds in the source org SELECT array_agg(cr.modality ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id), array_agg(cr.context_id ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id), array_agg(COALESCE(cr.priority, 0) ORDER BY COALESCE(cr.priority, 0) DESC, cr.modality, cr.context_id) INTO v_cross_modality, v_cross_context, v_cross_prio FROM unnest(v_held_org, v_held_role) AS h(org_id, role_id) INNER JOIN morbac.cross_org_rules cr ON cr.source_org_id = h.org_id AND cr.role_id = h.role_id WHERE cr.target_org_id = p_org_id AND cr.activity = ANY (v_activities) AND cr.view = ANY (v_views) AND (cr.modality = 'permission' OR (cr.modality = 'prohibition' AND NOT v_is_system_principal)) AND morbac.is_rule_valid(cr.valid_from, cr.valid_until); -- 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 u.context_id, u.prio FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio) WHERE u.modality = 'prohibition' ORDER BY u.prio 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 u.context_id, u.prio FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio) WHERE u.modality = 'prohibition' ORDER BY u.prio 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 = ANY (v_activities) AND ur.view = ANY (v_views) 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 = ANY (v_activities)) AND (gr.view IS NULL OR gr.view = ANY (v_views)) 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 u.context_id, u.prio FROM unnest(v_local_modality, v_local_context, v_local_prio) AS u(modality, context_id, prio) WHERE u.modality = 'permission' ORDER BY u.prio 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 u.context_id, u.prio FROM unnest(v_cross_modality, v_cross_context, v_cross_prio) AS u(modality, context_id, prio) WHERE u.modality = 'permission' ORDER BY u.prio 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 = ANY (v_activities) AND ur.view = ANY (v_views) 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 = ANY (v_activities)) AND (gr.view IS NULL OR gr.view = ANY (v_views)) 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().';