fa7567f5f0
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>
257 lines
7.4 KiB
PL/PgSQL
257 lines
7.4 KiB
PL/PgSQL
-- Row-Level Security helpers, compatible with PostgREST
|
|
|
|
CREATE OR REPLACE FUNCTION morbac.current_user_id()
|
|
RETURNS UUID
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
AS $$
|
|
DECLARE
|
|
v_user_id TEXT;
|
|
BEGIN
|
|
v_user_id := current_setting('morbac.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_user_id() IS
|
|
'Returns current user ID from morbac.user_id session variable';
|
|
|
|
CREATE OR REPLACE FUNCTION morbac.current_org_id()
|
|
RETURNS UUID
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
AS $$
|
|
DECLARE
|
|
v_org_id TEXT;
|
|
BEGIN
|
|
v_org_id := current_setting('morbac.org_id', TRUE);
|
|
|
|
IF v_org_id IS NULL OR v_org_id = '' THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
RETURN v_org_id::UUID;
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
RETURN NULL;
|
|
END;
|
|
$$;
|
|
|
|
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"]' 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 := current_setting('morbac.org_ids', TRUE);
|
|
v_json JSONB;
|
|
BEGIN
|
|
org_ids := NULL;
|
|
include_unattributed := FALSE;
|
|
|
|
IF v_raw IS NULL OR v_raw = '' THEN
|
|
RETURN;
|
|
END IF;
|
|
|
|
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
|
|
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 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)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
AS $$
|
|
SELECT DISTINCT ur.org_id
|
|
FROM morbac.user_roles ur
|
|
WHERE ur.user_id = p_user_id
|
|
UNION
|
|
SELECT DISTINCT d.org_id
|
|
FROM morbac.delegations d
|
|
WHERE d.delegatee_id = p_user_id
|
|
AND NOT d.revoked
|
|
AND now() BETWEEN d.valid_from AND d.valid_until;
|
|
$$;
|
|
|
|
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.
|
|
CREATE OR REPLACE FUNCTION morbac.rls_check(
|
|
p_activity TEXT,
|
|
p_view TEXT,
|
|
p_row_org_id UUID,
|
|
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_include_unattr BOOLEAN;
|
|
v_target_user_id UUID;
|
|
BEGIN
|
|
v_user_id := morbac.current_user_id();
|
|
|
|
IF v_user_id IS NULL THEN
|
|
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;
|
|
|
|
-- 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 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;
|
|
|
|
-- 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 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;
|
|
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
|
|
END IF;
|
|
|
|
-- 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 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.';
|