release / release (push) Canceled after 0s
Steps 1, 2, 4 and 5 called get_comprehensive_roles() for every row of morbac.rules (and org_in_scope() per row), so a decision cost grew with every tenant's rules: ~240 ms with 2376 rules across 27 orgs. Roles are now resolved once per org where the user can hold one and that has a rule for the request, rules are read through those (org, role) pairs, and org_in_scope() runs once per (org, scope) pair. Decisions are unchanged. Release 1.0.1 with the 1.0.0 -> 1.0.1 upgrade script. Test 17 compares every decision with the 1.0.0 body on a seeded random fixture and checks the cost no longer follows unrelated tenants' rules. bump_version.sh no longer rewrites the PGXN meta-spec version.
384 lines
15 KiB
PL/PgSQL
384 lines
15 KiB
PL/PgSQL
-- 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().';
|