feat(scope): unattributed and all org targets
Adds a first-class org target vocabulary shared by every rule kind: a specific organization, unattributed (objects whose org is NULL), or all. A role can now be granted the unassigned pile without a global rule. - rules.scope gains 'unattributed' and 'all' - user_rules.org_id accepts NULL to target unattributed objects - org_in_scope partitions the classes: 'unattributed' matches only a NULL target, tree scopes never match one - has_permission(user, activity, view) capability probe for UI gating - current_org_filter() parses morbac.org_ids once into org UUIDs plus the unattributed-bucket flag (a JSON null element requests it) - rls_check split by arity so NULL never carries two meanings: rls_check(activity, view) for tables with no org column, rls_check(activity, view, row_org_id[, row_user_id]) where a NULL row_org_id means the record is unattributed - detect_rule_conflicts is scope-aware, so rules targeting different object sets no longer collide Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+100
-22
@@ -72,30 +72,56 @@ $$;
|
||||
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[]
|
||||
-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' or '["uuid1", null]' or '[null]'.
|
||||
-- A JSON null element names the no-org (unattributed) bucket, distinct from
|
||||
-- the real org UUIDs which populate org_ids.
|
||||
CREATE OR REPLACE FUNCTION morbac.current_org_filter(
|
||||
OUT org_ids UUID[],
|
||||
OUT include_unattributed BOOLEAN
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw TEXT;
|
||||
v_raw TEXT := current_setting('morbac.org_ids', TRUE);
|
||||
v_json JSONB;
|
||||
BEGIN
|
||||
v_raw := current_setting('morbac.org_ids', TRUE);
|
||||
org_ids := NULL;
|
||||
include_unattributed := FALSE;
|
||||
|
||||
IF v_raw IS NULL OR v_raw = '' THEN
|
||||
RETURN NULL;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RETURN ARRAY(SELECT jsonb_array_elements_text(v_raw::jsonb)::UUID);
|
||||
v_json := v_raw::jsonb;
|
||||
org_ids := ARRAY(
|
||||
SELECT x::UUID
|
||||
FROM jsonb_array_elements_text(v_json) x
|
||||
WHERE x IS NOT NULL
|
||||
);
|
||||
include_unattributed := EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(v_json) e WHERE e = 'null'::jsonb
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RETURN NULL;
|
||||
org_ids := NULL;
|
||||
include_unattributed := FALSE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION morbac.current_org_filter() IS
|
||||
'Parses morbac.org_ids (JSON array) once into real org UUIDs plus a flag for whether the no-org bucket (JSON null element) was requested.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION morbac.current_org_ids()
|
||||
RETURNS UUID[]
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT org_ids FROM morbac.current_org_filter();
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION morbac.current_org_ids() IS
|
||||
'Returns org ID list from morbac.org_ids session variable (JSON array)';
|
||||
'Returns the real org UUIDs from morbac.org_ids (JSON null elements excluded). See current_org_filter() for the no-org bucket flag.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID)
|
||||
RETURNS TABLE(org_id UUID)
|
||||
@@ -117,13 +143,53 @@ $$;
|
||||
COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
|
||||
'Returns all org IDs the user has any direct role or active delegation in';
|
||||
|
||||
-- rls_check has two forms, distinguished by arity so a NULL never carries two
|
||||
-- meanings:
|
||||
--
|
||||
-- rls_check(activity, view) -- table has no org column
|
||||
-- rls_check(activity, view, row_org_id[, row_user_id]) -- row-scoped by org
|
||||
--
|
||||
-- In the 3/4-arg form row_org_id is the record's org, and a NULL value means the
|
||||
-- record is unattributed (no org) -- never "no org dimension". The 2-arg form
|
||||
-- carries no org dimension and evaluates against the session org context.
|
||||
|
||||
DROP FUNCTION IF EXISTS morbac.rls_check(TEXT, TEXT, UUID, UUID);
|
||||
|
||||
CREATE OR REPLACE FUNCTION morbac.rls_check(
|
||||
p_activity TEXT,
|
||||
p_view TEXT
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_org_id UUID;
|
||||
BEGIN
|
||||
v_user_id := morbac.current_user_id();
|
||||
IF v_user_id IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
v_org_id := morbac.current_org_id();
|
||||
IF v_org_id IS NOT NULL THEN
|
||||
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
|
||||
END IF;
|
||||
|
||||
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT) IS
|
||||
'RLS helper for tables without an org column. Uses the session org context (morbac.org_id), else org-independent rules. For row-scoped tables use the 3/4-arg form.';
|
||||
|
||||
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
|
||||
-- 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_row_org_id UUID,
|
||||
p_row_user_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
@@ -131,10 +197,11 @@ LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_org_id UUID;
|
||||
v_org_ids UUID[];
|
||||
v_target_user_id UUID;
|
||||
v_user_id UUID;
|
||||
v_org_id UUID;
|
||||
v_org_ids UUID[];
|
||||
v_include_unattr BOOLEAN;
|
||||
v_target_user_id UUID;
|
||||
BEGIN
|
||||
v_user_id := morbac.current_user_id();
|
||||
|
||||
@@ -150,29 +217,40 @@ BEGIN
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Single org selection: exact bucket only. An unattributed row (NULL org) is
|
||||
-- DISTINCT from the pin and is filtered out (use the org_ids list for both).
|
||||
v_org_id := morbac.current_org_id();
|
||||
|
||||
IF v_org_id IS NOT NULL THEN
|
||||
IF p_row_org_id IS NOT NULL AND p_row_org_id <> v_org_id THEN
|
||||
IF p_row_org_id IS DISTINCT FROM v_org_id THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
|
||||
END IF;
|
||||
|
||||
v_org_ids := morbac.current_org_ids();
|
||||
-- Org list selection: real orgs and/or the unattributed bucket (JSON null element).
|
||||
SELECT f.org_ids, f.include_unattributed
|
||||
INTO v_org_ids, v_include_unattr
|
||||
FROM morbac.current_org_filter() f;
|
||||
|
||||
IF v_org_ids IS NOT NULL THEN
|
||||
IF p_row_org_id IS NOT NULL AND NOT (p_row_org_id = ANY(v_org_ids)) THEN
|
||||
IF v_org_ids IS NOT NULL OR v_include_unattr THEN
|
||||
IF p_row_org_id IS NULL THEN
|
||||
IF NOT v_include_unattr THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
RETURN morbac.is_allowed(v_user_id, NULL, p_activity, p_view);
|
||||
END IF;
|
||||
IF v_org_ids IS NULL OR NOT (p_row_org_id = ANY(v_org_ids)) THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
-- p_row_org_id NULL: global row — is_allowed(NULL) checks global_rules only
|
||||
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
|
||||
END IF;
|
||||
|
||||
-- No org context: use row's org (or NULL for global rows — global_rules only)
|
||||
-- No selection set: all authorized rows. The row's own org drives the decision
|
||||
-- (unattributed row -> unattributed + org-independent rules).
|
||||
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
|
||||
END;
|
||||
$$;
|
||||
|
||||
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.';
|
||||
'RLS helper for row-scoped tables. p_row_org_id is the record org; NULL means unattributed. Select records via morbac.org_id (single org, unattributed excluded) or morbac.org_ids (JSON array; a null element adds the unattributed bucket). No selection = all authorized rows including unattributed. Pass row user_id to filter by morbac.target_user_id.';
|
||||
|
||||
Reference in New Issue
Block a user