From 80be5f913c2f407d296de7c7fff0ad89133e3a8a Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Sun, 29 Mar 2026 22:08:49 +0200 Subject: [PATCH] feat(rls): add multi-orgs filtering logic --- docs/DOCUMENTATION.md | 84 +++++++++++++++++++++++------------- src/rls.sql | 44 +++++++++++++++++-- tests/09_utilities.sql | 98 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 178 insertions(+), 48 deletions(-) diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 57c827b..8859aa4 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -815,14 +815,6 @@ morbac.user_has_role( ### PostgREST Integration -Configure PostgREST to pass user/org context via headers: - -```nginx -# Nginx config -proxy_set_header X-User-Id $user_id; -proxy_set_header X-Org-Id $org_id; -``` - Enable RLS and grant permissions: ```sql @@ -833,23 +825,70 @@ GRANT SELECT ON ALL TABLES IN SCHEMA morbac TO postgrest_role; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO postgrest_role; ``` -Create RLS policies: +Create RLS policies. Pass the row's `org_id` column to `rls_check` so org scoping works in all modes: ```sql CREATE POLICY document_read ON app.documents FOR SELECT -USING (morbac.rls_check('read', 'documents')); +USING (morbac.rls_check('read', 'documents', org_id)); CREATE POLICY document_write ON app.documents FOR INSERT -WITH CHECK (morbac.rls_check('write', 'documents')); +WITH CHECK (morbac.rls_check('write', 'documents', org_id)); ``` -Alternative method using session variables: +#### Org scoping modes + +`rls_check` resolves the org scope from session variables in priority order: + +| Session variable | Behaviour | +|---|---| +| `morbac.org_id` set | scoped to that single org | +| `morbac.org_ids` set | scoped to the provided list of orgs | +| neither set | all orgs the user belongs to | + +#### Setting context from HTTP headers + +Use a PostgREST [pre-request function](https://postgrest.org/en/stable/references/transactions.html#pre-request) to map headers to session variables: ```sql -SET morbac.user_id = '123e4567-e89b-12d3-a456-426614174000'; -SET morbac.org_id = '987fcdeb-51a2-43d7-9c6e-5a8b7c9d0e1f'; +CREATE OR REPLACE FUNCTION app.set_morbac_context() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_headers json := current_setting('request.headers', true)::json; + v_user_id text := v_headers->>'x-user-id'; + v_org_id text := v_headers->>'x-org-id'; + v_org_ids text := v_headers->>'x-org-ids'; +BEGIN + IF v_user_id IS NOT NULL THEN + PERFORM set_config('morbac.user_id', v_user_id, true); + END IF; + + IF v_org_id IS NOT NULL THEN + PERFORM set_config('morbac.org_id', v_org_id, true); + ELSIF v_org_ids IS NOT NULL THEN + -- x-org-ids is a comma-separated list: uuid1,uuid2,... + PERFORM set_config('morbac.org_ids', + (SELECT json_agg(trim(u))::text FROM unnest(string_to_array(v_org_ids, ',')) u), + true); + END IF; +END; +$$; +``` + +In `postgrest.conf`: + +```ini +db-pre-request = app.set_morbac_context +``` + +Header usage: + +``` +X-User-Id: +X-Org-Id: # single org +X-Org-Ids: ,, # multiple orgs, comma-separated + # omit both X-Org-Id and X-Org-Ids for all-orgs mode ``` ### Application Integration @@ -874,23 +913,6 @@ await client.query('SET morbac.org_id = $1', [orgId]); const res = await client.query('SELECT * FROM app.documents'); ``` -### Multi-Organization Resources - -Resources can belong to multiple organizations using a junction table: - -```sql --- Application defines document-org relationships --- morbac.rls_check() enforces access rules -CREATE POLICY doc_access ON app.documents FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM app.document_orgs - WHERE document_id = app.documents.id - AND org_id = morbac.current_org_id() - ) - AND morbac.rls_check('read', 'documents') -); -``` - ## Performance ### Optimization Strategies diff --git a/src/rls.sql b/src/rls.sql index 7a6dae7..665f72a 100644 --- a/src/rls.sql +++ b/src/rls.sql @@ -48,6 +48,31 @@ $$; COMMENT ON FUNCTION morbac.current_org_id() IS 'Returns current organization ID from morbac.org_id session variable'; +-- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' +CREATE OR REPLACE FUNCTION morbac.current_org_ids() +RETURNS UUID[] +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_raw TEXT; +BEGIN + v_raw := current_setting('morbac.org_ids', TRUE); + + IF v_raw IS NULL OR v_raw = '' THEN + RETURN NULL; + END IF; + + RETURN ARRAY(SELECT jsonb_array_elements_text(v_raw::jsonb)::UUID); +EXCEPTION + WHEN OTHERS THEN + RETURN NULL; +END; +$$; + +COMMENT ON FUNCTION morbac.current_org_ids() IS +'Returns org ID list from morbac.org_ids session variable (JSON array)'; + CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID) RETURNS TABLE(org_id UUID) LANGUAGE sql @@ -67,8 +92,8 @@ $$; COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS 'Returns all org IDs the user has any direct role or active delegation in'; --- When p_row_org_id is provided and session org is NULL, checks permission in the row org (multi-org mode). --- Usage: morbac.rls_check('read', 'documents', org_id) +-- 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) CREATE OR REPLACE FUNCTION morbac.rls_check( p_activity TEXT, p_view TEXT, @@ -81,14 +106,16 @@ AS $$ DECLARE v_user_id UUID; v_org_id UUID; + v_org_ids UUID[]; BEGIN v_user_id := morbac.current_user_id(); - v_org_id := morbac.current_org_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 IF p_row_org_id IS NOT NULL AND p_row_org_id <> v_org_id THEN RETURN FALSE; @@ -96,6 +123,15 @@ BEGIN RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view); END IF; + v_org_ids := morbac.current_org_ids(); + + IF v_org_ids IS NOT NULL THEN + IF p_row_org_id 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; + IF p_row_org_id IS NOT NULL THEN RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view); END IF; @@ -105,4 +141,4 @@ END; $$; COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID) IS -'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id to enable multi-org mode when no session org is set.'; +'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).'; diff --git a/tests/09_utilities.sql b/tests/09_utilities.sql index 7f650d2..5d935bd 100644 --- a/tests/09_utilities.sql +++ b/tests/09_utilities.sql @@ -13,7 +13,7 @@ -- 7. Policy DSL idempotency — compile_policy() is safe to run multiple times -- 8. Policy DSL error handling — reports errors for missing org/role -- 9. Derived roles — computed via evaluator function --- 10. RLS helper: rls_check() with session variables +-- 10. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping -- -- Prerequisites: 00_setup.sql -> 08_admin.sql -- ============================================================================= @@ -500,45 +500,117 @@ WHERE user_id = '30000000-0000-0000-0000-000000000004' -- Section 10: RLS helper functions -- --------------------------------------------------------------------------- \echo '' -\echo '--- 10. RLS helper: current_user_id, current_org_id, rls_check ---' +\echo '--- 10. RLS helpers: get_user_orgs, current_org_ids, rls_check ---' --- Without session variables set, current_user_id and current_org_id return NULL +-- Without session variables set, current_user_id / current_org_id / current_org_ids return NULL SELECT morbac.t_null('current_user_id() returns NULL without session var', morbac.current_user_id()::text); SELECT morbac.t_null('current_org_id() returns NULL without session var', morbac.current_org_id()::text); --- rls_check without session context returns FALSE (no user/org) +SELECT morbac.t_null('current_org_ids() returns NULL without session var', + morbac.current_org_ids()::text); + +-- rls_check without any session context returns FALSE SELECT morbac.t('rls_check without session context returns FALSE', morbac.rls_check('read', 'documents'), FALSE); --- Set session variables (Dave at GlobalTech) --- rls_check reads morbac.user_id and morbac.org_id session variables +-- get_user_orgs: Judy has roles in Engineering and Sales (2 orgs) +SELECT morbac.t_eq('get_user_orgs(Judy) returns 2 orgs', + (SELECT COUNT(*) FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000010'::uuid))::bigint, + 2); + +SELECT morbac.t('get_user_orgs(Judy) includes Engineering', + EXISTS(SELECT 1 FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000010'::uuid) + WHERE org_id = '10000000-0000-0000-0000-000000000002'), + TRUE); + +SELECT morbac.t('get_user_orgs(Judy) includes Sales', + EXISTS(SELECT 1 FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000010'::uuid) + WHERE org_id = '10000000-0000-0000-0000-000000000003'), + TRUE); + +-- get_user_orgs: Karl has no roles anywhere +SELECT morbac.t_eq('get_user_orgs(Karl) returns 0 orgs', + (SELECT COUNT(*) FROM morbac.get_user_orgs('30000000-0000-0000-0000-000000000011'::uuid))::bigint, + 0); + +-- Single-org mode: morbac.org_id set SET morbac.user_id = '30000000-0000-0000-0000-000000000004'; SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; --- rls_check uses morbac.user_id / morbac.org_id session vars SELECT morbac.t('rls_check(read, documents) as Dave at GlobalTech', morbac.rls_check('read', 'documents'), TRUE); --- rls_check for activity Dave cannot do SELECT morbac.t('rls_check(approve, documents) as Dave at GlobalTech [no permission]', morbac.rls_check('approve', 'documents'), FALSE); --- Switch to Karl (no role) -SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; +-- Row org matches session org -> allowed +SELECT morbac.t('rls_check with matching row org_id allows access', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'), + TRUE); -SELECT morbac.t('rls_check(read, documents) as Karl (no role) [denied]', +-- Row org does not match session org -> denied regardless of permissions +SELECT morbac.t('rls_check with non-matching row org_id denies access', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'), + FALSE); + +RESET morbac.org_id; + +-- Org-list mode: morbac.org_ids set (Judy in Engineering + Sales) +SET morbac.user_id = '30000000-0000-0000-0000-000000000010'; +SET morbac.org_ids = '["10000000-0000-0000-0000-000000000002","10000000-0000-0000-0000-000000000003"]'; + +-- Row in Engineering: Judy is engineer there -> can read documents +SELECT morbac.t('rls_check org_ids mode: Judy reads documents in Engineering [allowed]', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'), + TRUE); + +-- Row in Sales: Judy is sales_rep -> can read documents there too +SELECT morbac.t('rls_check org_ids mode: Judy reads documents in Sales [allowed]', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000003'), + TRUE); + +-- Row in GlobalTech: not in org_ids list -> denied +SELECT morbac.t('rls_check org_ids mode: row org not in list [denied]', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'), + FALSE); + +-- No row org provided with org_ids set -> denied +SELECT morbac.t('rls_check org_ids mode: no row org_id [denied]', morbac.rls_check('read', 'documents'), FALSE); --- Reset session to avoid polluting other queries +RESET morbac.org_ids; + +-- All-orgs mode: no org_id / org_ids set, row org_id provided +-- Judy can read documents in Engineering +SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in Engineering [allowed]', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'), + TRUE); + +-- Judy has no role at GlobalTech -> denied even in all-orgs mode +SELECT morbac.t('rls_check all-orgs mode: Judy reads documents in GlobalTech [no role, denied]', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'), + FALSE); + +-- No row org and no session org -> denied +SELECT morbac.t('rls_check all-orgs mode: no row org_id [denied]', + morbac.rls_check('read', 'documents'), + FALSE); + +-- Karl has no role anywhere -> denied in all modes +SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; + +SELECT morbac.t('rls_check(read, documents) as Karl (no role) [denied]', + morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'), + FALSE); + RESET morbac.user_id; -RESET morbac.org_id; \echo '' \echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ==='