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:
+29
-20
@@ -1,25 +1,27 @@
|
||||
-- =============================================================================
|
||||
-- rls_check Tests
|
||||
-- =============================================================================
|
||||
-- Tests morbac.rls_check() with all session-org combinations, focusing on
|
||||
-- the two cases fixed to support global rows (p_row_org_id IS NULL):
|
||||
-- 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. global row (NULL org_id): uses session org
|
||||
-- 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. global row + global permission [was FALSE, now TRUE]
|
||||
-- d. global row + global prohibition [was FALSE, now correctly FALSE]
|
||||
-- e. global row + no rule [was FALSE, still FALSE]
|
||||
-- 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. global row + global permission [was FALSE, now TRUE]
|
||||
-- c. global row + global prohibition [was FALSE, now correctly FALSE]
|
||||
-- d. global row + no rule [was FALSE, still FALSE]
|
||||
-- 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):
|
||||
@@ -74,13 +76,13 @@ SELECT morbac.t('rls_check single org, org row from different org (blocked)',
|
||||
'10000000-0000-0000-0000-000000000002'::uuid),
|
||||
FALSE);
|
||||
|
||||
-- 2c: global row — uses session org, so Karl's user_rule on GlobalTech applies
|
||||
SELECT morbac.t('rls_check single org, global row (NULL org_id): uses session org',
|
||||
-- 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),
|
||||
TRUE);
|
||||
FALSE);
|
||||
|
||||
-- 2c (no permission): Karl has no rule for contracts in GlobalTech
|
||||
SELECT morbac.t('rls_check single org, global row (NULL org_id): no permission for contracts',
|
||||
-- 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);
|
||||
|
||||
@@ -108,7 +110,7 @@ SELECT morbac.t('rls_check org_ids, org row not in list (blocked)',
|
||||
'10000000-0000-0000-0000-000000000002'::uuid),
|
||||
FALSE);
|
||||
|
||||
-- 3c: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only
|
||||
-- 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
|
||||
@@ -117,7 +119,14 @@ VALUES (
|
||||
'permission'
|
||||
);
|
||||
|
||||
SELECT morbac.t('rls_check org_ids, global row + global permission [new: was FALSE]',
|
||||
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);
|
||||
|
||||
@@ -125,7 +134,7 @@ DELETE FROM morbac.global_rules
|
||||
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
||||
AND activity = 'read' AND view = 'contracts';
|
||||
|
||||
-- 3d: global row + global prohibition
|
||||
-- 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
|
||||
@@ -134,7 +143,7 @@ VALUES (
|
||||
'prohibition'
|
||||
);
|
||||
|
||||
SELECT morbac.t('rls_check org_ids, global row + global prohibition',
|
||||
SELECT morbac.t('rls_check org_ids with null marker, NULL row + global prohibition',
|
||||
morbac.rls_check('read', 'contracts', NULL),
|
||||
FALSE);
|
||||
|
||||
@@ -142,8 +151,8 @@ DELETE FROM morbac.global_rules
|
||||
WHERE user_id = '30000000-0000-0000-0000-000000000011'
|
||||
AND activity = 'read' AND view = 'contracts';
|
||||
|
||||
-- 3e: global row + no rule
|
||||
SELECT morbac.t('rls_check org_ids, global row + no rule',
|
||||
-- 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);
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
-- =============================================================================
|
||||
-- Unattributed (no-org) rule Tests
|
||||
-- =============================================================================
|
||||
-- Tests scope = 'unattributed': rules authored by an org that govern objects
|
||||
-- with no org (org_id IS NULL), evaluated via is_allowed(user, NULL, ...).
|
||||
--
|
||||
-- Key properties:
|
||||
-- - role-bound: the user must hold the rule's role in the declaring org
|
||||
-- - partitioned: unattributed rules never reach real-org objects, and
|
||||
-- org-scoped rules never reach no-org objects
|
||||
-- - composes with prohibition precedence, revocation, delegation, multi-org
|
||||
-- - has_permission() capability probe surfaces the grant
|
||||
--
|
||||
-- Fixtures created here (isolated from the GlobalTech scenario):
|
||||
-- AttribCorp (org) role triage user Nomad
|
||||
-- IntakeCorp (org) role intake user Nomad (multi-org over the same pool)
|
||||
--
|
||||
-- Prerequisites: 00_setup.sql -> 15_rls_check.sql
|
||||
-- =============================================================================
|
||||
|
||||
\echo ''
|
||||
\echo '================================================================'
|
||||
\echo '16 -- UNATTRIBUTED'
|
||||
\echo '================================================================'
|
||||
|
||||
RESET morbac.user_id;
|
||||
RESET morbac.org_id;
|
||||
RESET morbac.org_ids;
|
||||
|
||||
INSERT INTO morbac.orgs (id, name) VALUES
|
||||
('40000000-0000-0000-0000-000000000001','AttribCorp'),
|
||||
('40000000-0000-0000-0000-000000000002','IntakeCorp');
|
||||
|
||||
INSERT INTO morbac.roles (id, org_id, name) VALUES
|
||||
('40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001','triage'),
|
||||
('40000000-0000-0000-0000-0000000000a2','40000000-0000-0000-0000-000000000002','intake');
|
||||
|
||||
-- Nomad: triage in AttribCorp; Scout: delegatee
|
||||
\set NOMAD '''40000000-0000-0000-0000-0000000000f1'''
|
||||
\set SCOUT '''40000000-0000-0000-0000-0000000000f2'''
|
||||
\set STRANGER '''40000000-0000-0000-0000-0000000000f9'''
|
||||
|
||||
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
|
||||
(:NOMAD,'40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001');
|
||||
|
||||
\set CTX '(SELECT id FROM morbac.contexts WHERE name = ''always'')'
|
||||
|
||||
-- unattributed permission for triage
|
||||
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
|
||||
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
|
||||
'read','documents', :CTX,'permission','unattributed');
|
||||
|
||||
\echo ''
|
||||
\echo '--- 1. Authorization + role binding ---'
|
||||
|
||||
SELECT morbac.t('unattributed grant -> orphan object allowed',
|
||||
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), TRUE);
|
||||
|
||||
SELECT morbac.t('stranger without role -> orphan denied',
|
||||
morbac.is_allowed_nocache(:STRANGER, NULL, 'read','documents'), FALSE);
|
||||
|
||||
\echo ''
|
||||
\echo '--- 2. Partition: unattributed does not reach real-org objects ---'
|
||||
|
||||
SELECT morbac.t('unattributed rule does NOT grant AttribCorp object',
|
||||
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), FALSE);
|
||||
|
||||
-- add a self permission; now the org object is allowed, orphan still allowed
|
||||
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
|
||||
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
|
||||
'read','documents', :CTX,'permission','self');
|
||||
|
||||
SELECT morbac.t('self rule grants AttribCorp object',
|
||||
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), TRUE);
|
||||
|
||||
SELECT morbac.t('orphan still allowed alongside self rule',
|
||||
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), TRUE);
|
||||
|
||||
\echo ''
|
||||
\echo '--- 3. Partition: org-scoped does not reach no-org objects ---'
|
||||
|
||||
-- remove the unattributed rule; self remains
|
||||
DELETE FROM morbac.rules
|
||||
WHERE org_id = '40000000-0000-0000-0000-000000000001'
|
||||
AND role_id = '40000000-0000-0000-0000-0000000000a1'
|
||||
AND scope = 'unattributed';
|
||||
|
||||
SELECT morbac.t('self rule does NOT reach orphan object',
|
||||
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
|
||||
|
||||
SELECT morbac.t('AttribCorp object still allowed by self rule',
|
||||
morbac.is_allowed_nocache(:NOMAD, '40000000-0000-0000-0000-000000000001','read','documents'), TRUE);
|
||||
|
||||
-- restore unattributed permission for the remaining tests
|
||||
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
|
||||
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
|
||||
'read','documents', :CTX,'permission','unattributed');
|
||||
|
||||
\echo ''
|
||||
\echo '--- 4. Prohibition precedence on orphan objects ---'
|
||||
|
||||
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope, priority)
|
||||
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a1',
|
||||
'read','documents', :CTX,'prohibition','unattributed', 10);
|
||||
|
||||
SELECT morbac.t('unattributed prohibition (prio 10) beats permission (prio 0)',
|
||||
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
|
||||
|
||||
DELETE FROM morbac.rules
|
||||
WHERE org_id = '40000000-0000-0000-0000-000000000001'
|
||||
AND role_id = '40000000-0000-0000-0000-0000000000a1'
|
||||
AND scope = 'unattributed' AND modality = 'prohibition';
|
||||
|
||||
\echo ''
|
||||
\echo '--- 5. Revocation ---'
|
||||
|
||||
DELETE FROM morbac.user_roles WHERE user_id = :NOMAD;
|
||||
|
||||
SELECT morbac.t('revoke role -> orphan access removed',
|
||||
morbac.is_allowed_nocache(:NOMAD, NULL, 'read','documents'), FALSE);
|
||||
|
||||
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
|
||||
(:NOMAD,'40000000-0000-0000-0000-0000000000a1','40000000-0000-0000-0000-000000000001');
|
||||
|
||||
\echo ''
|
||||
\echo '--- 6. Delegation propagates orphan access ---'
|
||||
|
||||
INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_until)
|
||||
VALUES (:NOMAD, :SCOUT, '40000000-0000-0000-0000-0000000000a1',
|
||||
'40000000-0000-0000-0000-000000000001', now() + interval '1 day');
|
||||
|
||||
SELECT morbac.t('delegatee gains orphan access via delegated role',
|
||||
morbac.is_allowed_nocache(:SCOUT, NULL, 'read','documents'), TRUE);
|
||||
|
||||
\echo ''
|
||||
\echo '--- 7. Multi-org: independent authority over the same pool ---'
|
||||
|
||||
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
|
||||
(:NOMAD,'40000000-0000-0000-0000-0000000000a2','40000000-0000-0000-0000-000000000002');
|
||||
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
|
||||
VALUES ('40000000-0000-0000-0000-000000000002','40000000-0000-0000-0000-0000000000a2',
|
||||
'write','documents', :CTX,'permission','unattributed');
|
||||
|
||||
SELECT morbac.t('IntakeCorp role independently grants orphan write',
|
||||
morbac.is_allowed_nocache(:NOMAD, NULL, 'write','documents'), TRUE);
|
||||
|
||||
\echo ''
|
||||
\echo '--- 8. has_permission capability probe ---'
|
||||
|
||||
SELECT morbac.t('has_permission TRUE via orphan grant',
|
||||
morbac.has_permission(:NOMAD, 'read','documents'), TRUE);
|
||||
|
||||
SELECT morbac.t('has_permission FALSE for ungranted activity/view',
|
||||
morbac.has_permission(:STRANGER, 'read','documents'), FALSE);
|
||||
|
||||
\echo ''
|
||||
\echo '--- 9. Org target triad: specific / unattributed / all ---'
|
||||
|
||||
-- role-based 'all': every org, unattributed included
|
||||
INSERT INTO morbac.roles (id, org_id, name) VALUES
|
||||
('40000000-0000-0000-0000-0000000000a3','40000000-0000-0000-0000-000000000001','overseer');
|
||||
INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES
|
||||
(:SCOUT,'40000000-0000-0000-0000-0000000000a3','40000000-0000-0000-0000-000000000001');
|
||||
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope)
|
||||
VALUES ('40000000-0000-0000-0000-000000000001','40000000-0000-0000-0000-0000000000a3',
|
||||
'approve','documents', :CTX,'permission','all');
|
||||
|
||||
SELECT morbac.t('scope all reaches a specific org',
|
||||
morbac.is_allowed_nocache(:SCOUT, '40000000-0000-0000-0000-000000000002','approve','documents'), TRUE);
|
||||
|
||||
SELECT morbac.t('scope all reaches unattributed objects',
|
||||
morbac.is_allowed_nocache(:SCOUT, NULL,'approve','documents'), TRUE);
|
||||
|
||||
-- roleless user_rule targeting unattributed (org_id NULL)
|
||||
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
|
||||
VALUES (:STRANGER, NULL, 'read','reports', :CTX,'permission');
|
||||
|
||||
SELECT morbac.t('user_rule with no org grants unattributed objects',
|
||||
morbac.is_allowed_nocache(:STRANGER, NULL,'read','reports'), TRUE);
|
||||
|
||||
SELECT morbac.t('user_rule with no org does NOT reach a real org',
|
||||
morbac.is_allowed_nocache(:STRANGER, '40000000-0000-0000-0000-000000000001','read','reports'), FALSE);
|
||||
|
||||
-- roleless user_rule targeting a specific org stays partitioned
|
||||
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
|
||||
VALUES (:STRANGER, '40000000-0000-0000-0000-000000000002', 'read','documents', :CTX,'permission');
|
||||
|
||||
SELECT morbac.t('user_rule with an org grants that org',
|
||||
morbac.is_allowed_nocache(:STRANGER, '40000000-0000-0000-0000-000000000002','read','documents'), TRUE);
|
||||
|
||||
SELECT morbac.t('user_rule with an org does NOT reach unattributed',
|
||||
morbac.is_allowed_nocache(:STRANGER, NULL,'read','documents'), FALSE);
|
||||
|
||||
\echo ''
|
||||
\echo '--- 10. rls_check filter matrix ---'
|
||||
|
||||
SELECT set_config('morbac.user_id', :NOMAD, false);
|
||||
|
||||
RESET morbac.org_id;
|
||||
RESET morbac.org_ids;
|
||||
SELECT morbac.t('no filter: orphan row visible',
|
||||
morbac.rls_check('read','documents', NULL), TRUE);
|
||||
|
||||
SET morbac.org_id = '40000000-0000-0000-0000-000000000001';
|
||||
SELECT morbac.t('single org pin: orphan row filtered out',
|
||||
morbac.rls_check('read','documents', NULL), FALSE);
|
||||
RESET morbac.org_id;
|
||||
|
||||
SET morbac.org_ids = '[null]';
|
||||
SELECT morbac.t('org_ids [null]: orphan row visible',
|
||||
morbac.rls_check('read','documents', NULL), TRUE);
|
||||
SELECT morbac.t('org_ids [null]: real-org row filtered out',
|
||||
morbac.rls_check('read','documents', '40000000-0000-0000-0000-000000000001'::uuid), FALSE);
|
||||
|
||||
SET morbac.org_ids = '["40000000-0000-0000-0000-000000000001", null]';
|
||||
SELECT morbac.t('org_ids [AttribCorp, null]: orphan row visible',
|
||||
morbac.rls_check('read','documents', NULL), TRUE);
|
||||
SELECT morbac.t('org_ids [AttribCorp, null]: AttribCorp row visible',
|
||||
morbac.rls_check('read','documents', '40000000-0000-0000-0000-000000000001'::uuid), TRUE);
|
||||
|
||||
RESET morbac.user_id;
|
||||
RESET morbac.org_ids;
|
||||
|
||||
\echo ''
|
||||
\echo '=== Unattributed Tests Completed ==='
|
||||
Reference in New Issue
Block a user