feat(rls): add multi-orgs filtering logic
This commit is contained in:
+53
-31
@@ -815,14 +815,6 @@ morbac.user_has_role(
|
|||||||
|
|
||||||
### PostgREST Integration
|
### 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:
|
Enable RLS and grant permissions:
|
||||||
|
|
||||||
```sql
|
```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;
|
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
|
```sql
|
||||||
CREATE POLICY document_read ON app.documents
|
CREATE POLICY document_read ON app.documents
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (morbac.rls_check('read', 'documents'));
|
USING (morbac.rls_check('read', 'documents', org_id));
|
||||||
|
|
||||||
CREATE POLICY document_write ON app.documents
|
CREATE POLICY document_write ON app.documents
|
||||||
FOR INSERT
|
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
|
```sql
|
||||||
SET morbac.user_id = '123e4567-e89b-12d3-a456-426614174000';
|
CREATE OR REPLACE FUNCTION app.set_morbac_context()
|
||||||
SET morbac.org_id = '987fcdeb-51a2-43d7-9c6e-5a8b7c9d0e1f';
|
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: <user-uuid>
|
||||||
|
X-Org-Id: <org-uuid> # single org
|
||||||
|
X-Org-Ids: <uuid1>,<uuid2>,<uuid3> # multiple orgs, comma-separated
|
||||||
|
# omit both X-Org-Id and X-Org-Ids for all-orgs mode
|
||||||
```
|
```
|
||||||
|
|
||||||
### Application Integration
|
### Application Integration
|
||||||
@@ -874,23 +913,6 @@ await client.query('SET morbac.org_id = $1', [orgId]);
|
|||||||
const res = await client.query('SELECT * FROM app.documents');
|
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
|
## Performance
|
||||||
|
|
||||||
### Optimization Strategies
|
### Optimization Strategies
|
||||||
|
|||||||
+40
-4
@@ -48,6 +48,31 @@ $$;
|
|||||||
COMMENT ON FUNCTION morbac.current_org_id() IS
|
COMMENT ON FUNCTION morbac.current_org_id() IS
|
||||||
'Returns current organization ID from morbac.org_id session variable';
|
'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)
|
CREATE OR REPLACE FUNCTION morbac.get_user_orgs(p_user_id UUID)
|
||||||
RETURNS TABLE(org_id UUID)
|
RETURNS TABLE(org_id UUID)
|
||||||
LANGUAGE sql
|
LANGUAGE sql
|
||||||
@@ -67,8 +92,8 @@ $$;
|
|||||||
COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
|
COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS
|
||||||
'Returns all org IDs the user has any direct role or active delegation in';
|
'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).
|
-- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs.
|
||||||
-- Usage: morbac.rls_check('read', 'documents', org_id)
|
-- Pass the row org_id column to enable scoping: rls_check('read', 'docs', org_id)
|
||||||
CREATE OR REPLACE FUNCTION morbac.rls_check(
|
CREATE OR REPLACE FUNCTION morbac.rls_check(
|
||||||
p_activity TEXT,
|
p_activity TEXT,
|
||||||
p_view TEXT,
|
p_view TEXT,
|
||||||
@@ -81,14 +106,16 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
v_user_id UUID;
|
v_user_id UUID;
|
||||||
v_org_id UUID;
|
v_org_id UUID;
|
||||||
|
v_org_ids UUID[];
|
||||||
BEGIN
|
BEGIN
|
||||||
v_user_id := morbac.current_user_id();
|
v_user_id := morbac.current_user_id();
|
||||||
v_org_id := morbac.current_org_id();
|
|
||||||
|
|
||||||
IF v_user_id IS NULL THEN
|
IF v_user_id IS NULL THEN
|
||||||
RETURN FALSE;
|
RETURN FALSE;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
v_org_id := morbac.current_org_id();
|
||||||
|
|
||||||
IF v_org_id IS NOT NULL THEN
|
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 NOT NULL AND p_row_org_id <> v_org_id THEN
|
||||||
RETURN FALSE;
|
RETURN FALSE;
|
||||||
@@ -96,6 +123,15 @@ BEGIN
|
|||||||
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
|
RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
|
||||||
END IF;
|
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
|
IF p_row_org_id IS NOT NULL THEN
|
||||||
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
|
RETURN morbac.is_allowed(v_user_id, p_row_org_id, p_activity, p_view);
|
||||||
END IF;
|
END IF;
|
||||||
@@ -105,4 +141,4 @@ END;
|
|||||||
$$;
|
$$;
|
||||||
|
|
||||||
COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID) IS
|
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).';
|
||||||
|
|||||||
+85
-13
@@ -13,7 +13,7 @@
|
|||||||
-- 7. Policy DSL idempotency — compile_policy() is safe to run multiple times
|
-- 7. Policy DSL idempotency — compile_policy() is safe to run multiple times
|
||||||
-- 8. Policy DSL error handling — reports errors for missing org/role
|
-- 8. Policy DSL error handling — reports errors for missing org/role
|
||||||
-- 9. Derived roles — computed via evaluator function
|
-- 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
|
-- Prerequisites: 00_setup.sql -> 08_admin.sql
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
@@ -500,45 +500,117 @@ WHERE user_id = '30000000-0000-0000-0000-000000000004'
|
|||||||
-- Section 10: RLS helper functions
|
-- Section 10: RLS helper functions
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
\echo ''
|
\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',
|
SELECT morbac.t_null('current_user_id() returns NULL without session var',
|
||||||
morbac.current_user_id()::text);
|
morbac.current_user_id()::text);
|
||||||
|
|
||||||
SELECT morbac.t_null('current_org_id() returns NULL without session var',
|
SELECT morbac.t_null('current_org_id() returns NULL without session var',
|
||||||
morbac.current_org_id()::text);
|
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',
|
SELECT morbac.t('rls_check without session context returns FALSE',
|
||||||
morbac.rls_check('read', 'documents'),
|
morbac.rls_check('read', 'documents'),
|
||||||
FALSE);
|
FALSE);
|
||||||
|
|
||||||
-- Set session variables (Dave at GlobalTech)
|
-- get_user_orgs: Judy has roles in Engineering and Sales (2 orgs)
|
||||||
-- rls_check reads morbac.user_id and morbac.org_id session variables
|
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.user_id = '30000000-0000-0000-0000-000000000004';
|
||||||
SET morbac.org_id = '10000000-0000-0000-0000-000000000001';
|
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',
|
SELECT morbac.t('rls_check(read, documents) as Dave at GlobalTech',
|
||||||
morbac.rls_check('read', 'documents'),
|
morbac.rls_check('read', 'documents'),
|
||||||
TRUE);
|
TRUE);
|
||||||
|
|
||||||
-- rls_check for activity Dave cannot do
|
|
||||||
SELECT morbac.t('rls_check(approve, documents) as Dave at GlobalTech [no permission]',
|
SELECT morbac.t('rls_check(approve, documents) as Dave at GlobalTech [no permission]',
|
||||||
morbac.rls_check('approve', 'documents'),
|
morbac.rls_check('approve', 'documents'),
|
||||||
FALSE);
|
FALSE);
|
||||||
|
|
||||||
-- Switch to Karl (no role)
|
-- Row org matches session org -> allowed
|
||||||
SET morbac.user_id = '30000000-0000-0000-0000-000000000011';
|
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'),
|
morbac.rls_check('read', 'documents'),
|
||||||
FALSE);
|
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.user_id;
|
||||||
RESET morbac.org_id;
|
|
||||||
|
|
||||||
\echo ''
|
\echo ''
|
||||||
\echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ==='
|
\echo '=== Utilities, Derived Roles, RLS, and Policy DSL Tests Completed ==='
|
||||||
|
|||||||
Reference in New Issue
Block a user