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>
225 lines
8.5 KiB
SQL
225 lines
8.5 KiB
SQL
-- =============================================================================
|
|
-- rls_check Tests
|
|
-- =============================================================================
|
|
-- Tests morbac.rls_check() with all session-org combinations. A NULL row org
|
|
-- is an unattributed object: an org filter (single pin, or org_ids without a
|
|
-- null marker) excludes it; it is reachable via no filter or a null marker.
|
|
--
|
|
-- 1. No user_id set: always FALSE
|
|
-- 2. Single org context
|
|
-- a. org-scoped row, matching org
|
|
-- b. org-scoped row, different org (blocked)
|
|
-- c. NULL row: filtered out under an org pin (orphan not requested)
|
|
-- 3. org_ids filter
|
|
-- a. org-scoped row in list
|
|
-- b. org-scoped row not in list (blocked)
|
|
-- c. NULL row, list without null marker: filtered out even with a grant
|
|
-- c2. NULL row, list with null marker + global permission: allowed
|
|
-- d. NULL row, list with null marker + global prohibition: blocked
|
|
-- e. NULL row, list with null marker + no rule: blocked
|
|
-- 4. No org context
|
|
-- a. org-scoped row: uses row's org
|
|
-- b. NULL row + global permission [orphan + global rules -> TRUE]
|
|
-- c. NULL row + global prohibition [blocked]
|
|
-- d. NULL row + no rule [blocked]
|
|
--
|
|
-- User state carried from previous tests:
|
|
-- Karl (30000000-0000-0000-0000-000000000011):
|
|
-- - user_rules: read documents, read reports in GlobalTech HQ (no expiry)
|
|
-- - no role assignments, no org memberships
|
|
--
|
|
-- Prerequisites: 00_setup.sql -> 14_system_principals.sql
|
|
-- =============================================================================
|
|
|
|
\echo ''
|
|
\echo '================================================================'
|
|
\echo '15 -- RLS_CHECK'
|
|
\echo '================================================================'
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Section 1: No user_id set — always FALSE
|
|
-- ---------------------------------------------------------------------------
|
|
\echo ''
|
|
\echo '--- 1. No user_id: always FALSE ---'
|
|
|
|
RESET morbac.user_id;
|
|
RESET morbac.org_id;
|
|
RESET morbac.org_ids;
|
|
|
|
SELECT morbac.t('rls_check without user_id, org row',
|
|
morbac.rls_check('read', 'documents',
|
|
'10000000-0000-0000-0000-000000000001'::uuid),
|
|
FALSE);
|
|
|
|
SELECT morbac.t('rls_check without user_id, global row',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
FALSE);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Section 2: Single org context
|
|
-- ---------------------------------------------------------------------------
|
|
\echo ''
|
|
\echo '--- 2. Single org context ---'
|
|
|
|
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
|
|
SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ
|
|
|
|
-- 2a: org-scoped row, matching org — Karl has user_rule for read documents
|
|
SELECT morbac.t('rls_check single org, org row matches session org (Karl/documents)',
|
|
morbac.rls_check('read', 'documents',
|
|
'10000000-0000-0000-0000-000000000001'::uuid),
|
|
TRUE);
|
|
|
|
-- 2b: org-scoped row, different org — blocked before is_allowed
|
|
SELECT morbac.t('rls_check single org, org row from different org (blocked)',
|
|
morbac.rls_check('read', 'documents',
|
|
'10000000-0000-0000-0000-000000000002'::uuid),
|
|
FALSE);
|
|
|
|
-- 2c: NULL row — filtered out under a single org pin (orphan not requested)
|
|
SELECT morbac.t('rls_check single org, NULL row filtered out under org pin',
|
|
morbac.rls_check('read', 'documents', NULL),
|
|
FALSE);
|
|
|
|
-- 2c (contracts): still filtered out regardless of permission
|
|
SELECT morbac.t('rls_check single org, NULL row filtered out (contracts)',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
FALSE);
|
|
|
|
RESET morbac.user_id;
|
|
RESET morbac.org_id;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Section 3: org_ids filter
|
|
-- ---------------------------------------------------------------------------
|
|
\echo ''
|
|
\echo '--- 3. org_ids filter ---'
|
|
|
|
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
|
|
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001"]'; -- [GlobalTech HQ]
|
|
|
|
-- 3a: org-scoped row in the list — Karl has user_rule for read documents in GlobalTech
|
|
SELECT morbac.t('rls_check org_ids, org row in list (Karl/documents/GlobalTech)',
|
|
morbac.rls_check('read', 'documents',
|
|
'10000000-0000-0000-0000-000000000001'::uuid),
|
|
TRUE);
|
|
|
|
-- 3b: org-scoped row not in the list — blocked
|
|
SELECT morbac.t('rls_check org_ids, org row not in list (blocked)',
|
|
morbac.rls_check('read', 'documents',
|
|
'10000000-0000-0000-0000-000000000002'::uuid),
|
|
FALSE);
|
|
|
|
-- 3c: NULL row, list WITHOUT null marker — filtered out even with a global grant
|
|
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
|
|
VALUES (
|
|
'30000000-0000-0000-0000-000000000011', -- Karl
|
|
'read', 'contracts',
|
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
|
'permission'
|
|
);
|
|
|
|
SELECT morbac.t('rls_check org_ids without null marker, NULL row filtered out despite grant',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
FALSE);
|
|
|
|
-- 3c2: NULL row, list WITH null marker + global permission — allowed
|
|
SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001", null]';
|
|
|
|
SELECT morbac.t('rls_check org_ids with null marker, NULL row + global permission',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
TRUE);
|
|
|
|
DELETE FROM morbac.global_rules
|
|
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
|
AND activity = 'read' AND view = 'contracts';
|
|
|
|
-- 3d: NULL row, list with null marker + global prohibition
|
|
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
|
|
VALUES (
|
|
'30000000-0000-0000-0000-000000000011', -- Karl
|
|
'read', 'contracts',
|
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
|
'prohibition'
|
|
);
|
|
|
|
SELECT morbac.t('rls_check org_ids with null marker, NULL row + global prohibition',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
FALSE);
|
|
|
|
DELETE FROM morbac.global_rules
|
|
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
|
AND activity = 'read' AND view = 'contracts';
|
|
|
|
-- 3e: NULL row, list with null marker + no rule
|
|
SELECT morbac.t('rls_check org_ids with null marker, NULL row + no rule',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
FALSE);
|
|
|
|
RESET morbac.user_id;
|
|
RESET morbac.org_ids;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Section 4: No org context
|
|
-- ---------------------------------------------------------------------------
|
|
\echo ''
|
|
\echo '--- 4. No org context ---'
|
|
|
|
SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl
|
|
|
|
-- 4a: org-scoped row — uses row's org_id (Karl has user_rule in GlobalTech)
|
|
SELECT morbac.t('rls_check no org context, org row: uses row org (Karl/documents/GlobalTech)',
|
|
morbac.rls_check('read', 'documents',
|
|
'10000000-0000-0000-0000-000000000001'::uuid),
|
|
TRUE);
|
|
|
|
-- 4a (no permission): Karl has no rule in Engineering
|
|
SELECT morbac.t('rls_check no org context, org row: no permission in row org (Engineering)',
|
|
morbac.rls_check('read', 'documents',
|
|
'10000000-0000-0000-0000-000000000002'::uuid),
|
|
FALSE);
|
|
|
|
-- 4b: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only
|
|
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
|
|
VALUES (
|
|
'30000000-0000-0000-0000-000000000011', -- Karl
|
|
'read', 'contracts',
|
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
|
'permission'
|
|
);
|
|
|
|
SELECT morbac.t('rls_check no org context, global row + global permission [new: was FALSE]',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
TRUE);
|
|
|
|
DELETE FROM morbac.global_rules
|
|
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
|
AND activity = 'read' AND view = 'contracts';
|
|
|
|
-- 4c: global row + global prohibition
|
|
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
|
|
VALUES (
|
|
'30000000-0000-0000-0000-000000000011', -- Karl
|
|
'read', 'contracts',
|
|
(SELECT id FROM morbac.contexts WHERE name = 'always'),
|
|
'prohibition'
|
|
);
|
|
|
|
SELECT morbac.t('rls_check no org context, global row + global prohibition',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
FALSE);
|
|
|
|
DELETE FROM morbac.global_rules
|
|
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
|
AND activity = 'read' AND view = 'contracts';
|
|
|
|
-- 4d: global row + no rule
|
|
SELECT morbac.t('rls_check no org context, global row + no rule',
|
|
morbac.rls_check('read', 'contracts', NULL),
|
|
FALSE);
|
|
|
|
RESET morbac.user_id;
|
|
|
|
\echo ''
|
|
\echo '=== rls_check Tests Completed ==='
|