feat(system): introduce global rules and system principals for easier system account handling

This commit is contained in:
2026-04-04 09:23:46 +02:00
parent f64cd73159
commit 0e3b90af22
11 changed files with 1129 additions and 64 deletions
+4 -4
View File
@@ -43,8 +43,8 @@ install: build
@cp $(OUTPUT_DEV_FILENAME) $(OUTPUT_RELEASE_FILENAME)
@./tools/install.sh $(PROJECT_FILENAME) $(PROJECT_VERSION)
.PHONY: check
check: install
.PHONY: test
test: install
@echo "Running tests..."
@dropdb morbac_test 2>/dev/null || true
@createdb morbac_test
@@ -89,8 +89,8 @@ docker-uninstall:
@./tools/docker_uninstall.sh $(DOCKER_CONTAINER) $(PROJECT_FILENAME)
# Build and run tests in Docker container
.PHONY: docker-check
docker-check: docker-install
.PHONY: docker-test
docker-test: docker-install
@echo "Running tests in Docker..."
@./tools/docker_test.sh $(DOCKER_CONTAINER) morbac_test postgres
+125 -6
View File
@@ -39,10 +39,13 @@ flowchart TD
D --> G
E --> G
F --> G
G --> H[Find Highest-Priority Prohibition]
G --> I[Find Highest-Priority Permission]
H --> J{Compare Priorities}
I --> J
G --> SP{System Principal?}
SP -->|Yes| I[Find Highest-Priority Permission]
SP -->|No| H[Find Highest-Priority Prohibition]
H --> H2[+ Global Prohibitions step 3.5]
I --> I2[+ Global Permissions step 6.5]
H2 --> J{Compare Priorities}
I2 --> J
J -->|Permission priority > Prohibition priority| K[ALLOW]
J -->|Prohibition exists, no higher-priority permission| L[DENY]
J -->|No prohibition, permission found| K
@@ -88,6 +91,11 @@ erDiagram
views ||--o{ activity_view_bindings : "allowed_for"
contexts ||--o{ rules : "condition"
contexts ||--o{ global_rules : "condition"
activities ||--o{ global_rules : "action"
views ||--o{ global_rules : "target"
system_principals ||--o{ global_rules : "ruleset"
roles ||--o{ delegations : "delegated"
roles ||--o{ negative_role_assignments : "prohibited"
@@ -188,6 +196,30 @@ Inter-organizational access rules.
**Behavior:** Allows roles in a source organization to access resources in a target organization.
**morbac.system_principals**
Registry of backend service accounts. Registered user UUIDs are protected at the trigger level — no role assignment, rule, delegation, or prohibition can target them. Their permission rules in `global_rules` are equally immutable.
**Key columns:**
- `user_id`: UUID of the service account
- `description`: Human-readable label
**Behavior:** System principals bypass all prohibition evaluation. Only the DB owner can insert or remove entries (no RLS write policies).
**morbac.global_rules**
System-wide rules with no org or role binding.
**Key columns:**
- `user_id`: NULL = all users; non-NULL = specific user
- `activity`: NULL = any activity; non-NULL = specific activity (hierarchy applies)
- `view`: NULL = any view; non-NULL = specific view (hierarchy applies)
- `context_id`: Contextual condition
- `modality`: Permission or prohibition
- `priority`: Optional; same resolution semantics as `morbac.rules`
**Behavior:** Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in `is_allowed_nocache()`. NULL on `activity` or `view` matches any value — no hierarchy setup required for broad rules.
**morbac.activity_view_bindings**
Optional whitelist of valid (activity, view) pairs for rule creation.
@@ -556,6 +588,93 @@ SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TR
| Role in org A covers org A's descendants | `rules.scope = 'subtree'` (or other scope) |
| Role in org A accesses a different org B | `cross_org_rules` with `source_org_id = A` |
### Global Rules
Global rules apply system-wide — no org or role required. Use them to define blanket access policies that cut across the entire org hierarchy.
**Table:** `morbac.global_rules`
| Column | Purpose |
|---|---|
| `user_id` | NULL = all users; UUID = specific user |
| `activity` | NULL = any activity; specific name = that activity (hierarchy applies) |
| `view` | NULL = any view; specific name = that view (hierarchy applies) |
| `modality` | `permission` or `prohibition` |
| `priority` | Optional; same semantics as `morbac.rules` |
**Evaluation order:** Global prohibitions are evaluated at step 3.5 (after local, cross-org, and user-level prohibitions). Global permissions at step 6.5 (after all other permission sources). Both contribute to the same priority accumulators used in step 7 resolution.
**Use case: system account that reads all resources**
```sql
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
:system_account_uuid,
'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
```
`view = NULL` matches every view. Use `activity = NULL` too if the account needs all actions.
**Use case: protect service accounts from modification by anyone**
```sql
INSERT INTO morbac.views (name) VALUES ('service_accounts');
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
SELECT NULL, a.act, 'service_accounts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 100
FROM (VALUES ('delete'), ('update')) AS a(act);
```
Priority 100 ensures this prohibition overrides any role-based permission. To exempt a specific user, add a subject-specific permission with higher priority via `morbac.user_rules`.
### System Principals
Backend service accounts that must be fully immutable at the database level — no policy, no admin, no superadmin can touch them once registered.
**Table:** `morbac.system_principals`
| Column | Purpose |
|---|---|
| `user_id` | UUID of the service account (external, from your auth system) |
| `description` | Human-readable label |
**What is protected (trigger level — fires for all users including superusers):**
| Table | Blocked operations |
|---|---|
| `user_roles` | INSERT, UPDATE, DELETE |
| `user_rules` | INSERT, UPDATE, DELETE |
| `negative_role_assignments` | INSERT, UPDATE, DELETE |
| `delegations` | INSERT, UPDATE involving the principal |
| `global_rules` | All operations where `user_id` matches a system principal |
**Authorization behavior:** Prohibition evaluation (steps 13.5) is skipped entirely for system principals. Even a blanket `user_id=NULL` global prohibition does not affect them. Only their permission rules matter.
**Ruleset:** Define permissions for system principals via `global_rules` at deploy time. Those rows are immutable once inserted — no one can modify or delete them. Use `activity=NULL, view=NULL` to grant full access, or restrict to specific activities/views:
```sql
-- Register the service account (DB owner only)
INSERT INTO morbac.system_principals (user_id, description)
VALUES (:service_uuid, 'Backend API worker');
-- Grant full access (immutable after insert)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (:service_uuid, NULL, NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
-- Or restrict to specific operations
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (:service_uuid, 'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission');
```
**Access control on the registry itself:** `morbac.system_principals` has a SELECT-only RLS policy — a user needs `is_allowed(..., 'read', 'system_principals')` to list them. INSERT/UPDATE/DELETE have no RLS policy, so they are blocked for all non-superusers automatically. Only the database owner can register or remove system principals.
### Administration
Admin operations use the same `is_allowed()` engine as everything else — no separate code path.
@@ -568,7 +687,7 @@ The database owner (superuser) bypasses RLS by default — use that privilege on
**System view names**
The extension seeds built-in activities (`create`, `read`, `update`, `delete`) and system view names (`orgs`, `roles`, `rules`, `user_roles`, `contexts`, `activities`, `views`, `delegations`, `cross_org_rules`) at install time.
The extension seeds built-in activities (`create`, `read`, `update`, `delete`) and system view names (`orgs`, `roles`, `rules`, `user_roles`, `contexts`, `activities`, `views`, `delegations`, `cross_org_rules`, `user_rules`, `global_rules`, `system_principals`) at install time.
These names are config-driven. Override with `morbac.set_config()` to use your own naming conventions — the new name must then exist in `morbac.views` and your rules must reference it:
@@ -732,7 +851,7 @@ WHERE table_name = 'rules'
### Authorization Functions
**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Checks prohibitions first, then permissions, defaults to deny.
**`is_allowed(user_id, org_id, activity, view)`**: Main authorization decision. Returns BOOLEAN. Evaluates local rules, cross-org rules, user rules, and global rules; defaults to deny.
```sql
SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents');
+35
View File
@@ -125,6 +125,41 @@ CREATE TRIGGER trg_invalidate_cache_user_rules
AFTER INSERT OR UPDATE OR DELETE ON morbac.user_rules
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_user_rule_change();
-- Global rules have no org scope — any change invalidates the entire cache
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_global_rule_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
DELETE FROM morbac.auth_cache;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_invalidate_cache_global_rules
AFTER INSERT OR UPDATE OR DELETE ON morbac.global_rules
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_global_rule_change();
-- system_principals changes affect prohibition bypass — invalidate per user
CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_system_principal_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
DELETE FROM morbac.auth_cache WHERE user_id = v_user_id;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_invalidate_cache_system_principals
AFTER INSERT OR UPDATE OR DELETE ON morbac.system_principals
FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_system_principal_change();
-- Refresh materialized hierarchy views when hierarchies change
CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change()
+47
View File
@@ -32,7 +32,14 @@ DECLARE
v_rule RECORD;
v_max_prohibition_priority INTEGER := NULL;
v_max_permission_priority INTEGER := NULL;
v_is_system_principal BOOLEAN := FALSE;
BEGIN
SELECT TRUE INTO v_is_system_principal
FROM morbac.system_principals WHERE user_id = p_user_id;
-- STEPS 1-3.5: Prohibitions — skipped entirely for system principals
IF NOT v_is_system_principal THEN
-- STEP 1: Local prohibitions — find the highest-priority applicable one
FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
@@ -91,6 +98,27 @@ BEGIN
END IF;
END LOOP;
-- STEP 3.5: Global prohibitions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'prohibition'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN
v_max_prohibition_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
END IF; -- v_is_system_principal
-- STEP 4: Local permissions — find the highest-priority applicable one
FOR v_rule IN
SELECT r.context_id, COALESCE(r.priority, 0) AS prio
@@ -149,6 +177,25 @@ BEGIN
END IF;
END LOOP;
-- STEP 6.5: Global permissions
FOR v_rule IN
SELECT gr.context_id, COALESCE(gr.priority, 0) AS prio
FROM morbac.global_rules gr
WHERE (gr.user_id IS NULL OR gr.user_id = p_user_id)
AND gr.modality = 'permission'
AND (gr.activity IS NULL OR gr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)))
AND (gr.view IS NULL OR gr.view IN (SELECT view FROM morbac.get_effective_views(p_view)))
AND morbac.is_rule_valid(gr.valid_from, gr.valid_until)
ORDER BY COALESCE(gr.priority, 0) DESC
LOOP
IF morbac.eval_context(v_rule.context_id) THEN
IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN
v_max_permission_priority := v_rule.prio;
END IF;
EXIT;
END IF;
END LOOP;
-- STEP 7: Priority resolution
IF v_max_prohibition_priority IS NULL THEN
RETURN v_max_permission_priority IS NOT NULL;
+3 -1
View File
@@ -23,7 +23,9 @@ INSERT INTO morbac.config (key, value, description) VALUES
('system_view.views', 'views', 'View name for morbac.views table access control'),
('system_view.delegations', 'delegations', 'View name for morbac.delegations table access control'),
('system_view.cross_org_rules', 'cross_org_rules', 'View name for morbac.cross_org_rules table access control'),
('system_view.user_rules', 'user_rules', 'View name for morbac.user_rules table access control');
('system_view.user_rules', 'user_rules', 'View name for morbac.user_rules table access control'),
('system_view.global_rules', 'global_rules', 'View name for morbac.global_rules table access control'),
('system_view.system_principals', 'system_principals', 'View name for morbac.system_principals table access control');
CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT)
RETURNS TEXT
+57
View File
@@ -0,0 +1,57 @@
-- Global rules: Rule(user_id, activity, view, context, modality)
--
-- No org_id or role_id — applies system-wide regardless of org membership or roles.
-- user_id NULL = every user; non-NULL = specific user only.
-- activity NULL = any activity; view NULL = any view.
--
-- Evaluated at steps 3.5 (prohibitions) and 6.5 (permissions) in is_allowed_nocache().
-- When activity/view are set, hierarchy resolution applies normally.
CREATE TABLE morbac.global_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
activity TEXT REFERENCES morbac.activities(name) ON DELETE CASCADE,
view TEXT REFERENCES morbac.views(name) ON DELETE CASCADE,
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL,
priority INTEGER,
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_active BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}'::jsonb,
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
UNIQUE(user_id, activity, view, context_id, modality)
);
CREATE INDEX idx_global_rules_user_id ON morbac.global_rules(user_id);
CREATE INDEX idx_global_rules_activity_view ON morbac.global_rules(activity, view);
CREATE INDEX idx_global_rules_modality ON morbac.global_rules(modality);
CREATE INDEX idx_global_rules_fast_lookup ON morbac.global_rules(modality)
INCLUDE (user_id, activity, view, context_id)
WHERE is_active = true;
COMMENT ON TABLE morbac.global_rules IS
'System-wide rules with no org or role binding. user_id NULL = all users; activity/view NULL = any activity/view.';
COMMENT ON COLUMN morbac.global_rules.user_id IS
'NULL = all users; non-NULL = this specific user only';
COMMENT ON COLUMN morbac.global_rules.activity IS
'NULL = any activity; non-NULL = specific activity (hierarchy applies)';
COMMENT ON COLUMN morbac.global_rules.view IS
'NULL = any view; non-NULL = specific view (hierarchy applies)';
CREATE OR REPLACE FUNCTION morbac.global_rules_set_is_active()
RETURNS TRIGGER AS $$
BEGIN
NEW.is_active := (
(NEW.valid_from IS NULL OR NEW.valid_from <= CURRENT_TIMESTAMP)
AND (NEW.valid_until IS NULL OR NEW.valid_until > CURRENT_TIMESTAMP)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_global_rules_set_is_active ON morbac.global_rules;
CREATE TRIGGER trg_global_rules_set_is_active
BEFORE INSERT OR UPDATE ON morbac.global_rules
FOR EACH ROW EXECUTE FUNCTION morbac.global_rules_set_is_active();
+100
View File
@@ -0,0 +1,100 @@
-- System principals: backend service accounts defined at deploy time.
--
-- Registered user_ids are fully protected at the trigger level:
-- - No role assignments, revocations, or negative assignments
-- - No user_rules (permissions or prohibitions)
-- - No delegations involving them
-- - No targeted global_rules prohibitions
-- - All prohibitions are skipped in is_allowed_nocache() (see authorization.sql)
--
-- This table has no INSERT/UPDATE/DELETE RLS policies — only the database owner
-- can register or remove system principals (done in SQL at deploy time).
-- SELECT is gated by is_allowed() like all other system tables.
CREATE TABLE morbac.system_principals (
user_id UUID PRIMARY KEY,
description TEXT
);
COMMENT ON TABLE morbac.system_principals IS
'Registry of backend service accounts. Immutable at the trigger level — no policy can touch them.';
COMMENT ON COLUMN morbac.system_principals.user_id IS
'External user UUID of the service account';
-- Shared guard for tables with a single user_id column
CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
IF EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = v_user_id) THEN
RAISE EXCEPTION 'operation blocked: % is a system principal', v_user_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_system_principal_user_roles
BEFORE INSERT OR UPDATE OR DELETE ON morbac.user_roles
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal();
CREATE TRIGGER trg_system_principal_user_rules
BEFORE INSERT OR UPDATE OR DELETE ON morbac.user_rules
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal();
CREATE TRIGGER trg_system_principal_negative_roles
BEFORE INSERT OR UPDATE OR DELETE ON morbac.negative_role_assignments
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal();
-- Delegations have two user_id columns
CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal_delegation()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM morbac.system_principals
WHERE user_id IN (NEW.delegator_id, NEW.delegatee_id)
) THEN
RAISE EXCEPTION 'operation blocked: delegations cannot involve system principals';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_system_principal_delegations
BEFORE INSERT OR UPDATE ON morbac.delegations
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal_delegation();
-- Global rules: all operations on rows belonging to a system principal are blocked.
-- This protects both the permission rules defined for them and prevents prohibitions
-- from being added against them. Define their rules at deploy time as the DB owner.
CREATE OR REPLACE FUNCTION morbac.raise_if_system_principal_global_rule()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
IF v_user_id IS NOT NULL
AND EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = v_user_id)
THEN
RAISE EXCEPTION 'operation blocked: global rules for system principal % are immutable', v_user_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_system_principal_global_rules
BEFORE INSERT OR UPDATE OR DELETE ON morbac.global_rules
FOR EACH ROW EXECUTE FUNCTION morbac.raise_if_system_principal_global_rule();
+26
View File
@@ -180,6 +180,32 @@ CREATE POLICY user_rules_delete ON morbac.user_rules FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete',
morbac.get_config('system_view.user_rules')));
-- morbac.system_principals (no org_id — SELECT only; INSERT/UPDATE/DELETE reserved for DB owner)
ALTER TABLE morbac.system_principals ENABLE ROW LEVEL SECURITY;
CREATE POLICY system_principals_select ON morbac.system_principals FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.system_principals')));
-- morbac.global_rules (no org_id — use current session org for write checks)
ALTER TABLE morbac.global_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY global_rules_select ON morbac.global_rules FOR SELECT
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'read',
morbac.get_config('system_view.global_rules')));
CREATE POLICY global_rules_insert ON morbac.global_rules FOR INSERT
WITH CHECK (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'create',
morbac.get_config('system_view.global_rules')));
CREATE POLICY global_rules_update ON morbac.global_rules FOR UPDATE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'update',
morbac.get_config('system_view.global_rules')));
CREATE POLICY global_rules_delete ON morbac.global_rules FOR DELETE
USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete',
morbac.get_config('system_view.global_rules')));
-- morbac.cross_org_rules
ALTER TABLE morbac.cross_org_rules ENABLE ROW LEVEL SECURITY;
+384
View File
@@ -0,0 +1,384 @@
-- =============================================================================
-- Global Rules Tests
-- =============================================================================
-- Tests system-wide rules via morbac.global_rules.
--
-- Scenarios:
-- 1. No global rule: Karl (no role) is denied by default
-- 2. Global permission (user_id=NULL): all users gain access
-- 3. Global permission (user_id=uuid): only that user gains access
-- 4. Global prohibition (user_id=NULL): all users are denied regardless of role
-- 5. Global prohibition (user_id=uuid): only that user is denied
-- 6. Priority: global permission with higher priority overrides global prohibition
-- 7. Priority: high-priority global prohibition overrides role-based permission
-- 8. NULL activity/view wildcards (no hierarchy needed)
-- 9. Activity/view hierarchy applies when activity/view are set
-- 10. Temporal global rules (valid_from / valid_until)
--
-- Prerequisites: 00_setup.sql -> 12_user_rules.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '13 -- GLOBAL RULES'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: No global rule -- Karl (no role) is denied
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. No global rule: access denied ---'
SELECT morbac.t('Karl (no role) reads GlobalTech contracts [no global rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
SELECT morbac.t('Karl (no role) reads Engineering contracts [no global rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'contracts'
), FALSE);
-- ---------------------------------------------------------------------------
-- Section 2: Global permission (user_id=NULL) -- all users gain access
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. Global permission, user_id=NULL: all users ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
NULL,
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
-- Karl (no role) can now read contracts in any org
SELECT morbac.t('Karl reads GlobalTech contracts [global permission, any user]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
SELECT morbac.t('Karl reads Engineering contracts [global permission covers all orgs]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'read', 'contracts'
), TRUE);
-- Dave (employee) also benefits
SELECT morbac.t('Dave reads GlobalTech contracts [global permission, any user]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
DELETE FROM morbac.global_rules WHERE user_id IS NULL AND activity = 'read' AND view = 'contracts';
-- ---------------------------------------------------------------------------
-- Section 3: Global permission (user_id=uuid) -- specific user only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. Global permission, user_id=Karl 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('Karl reads GlobalTech contracts [user_id-specific global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
-- Dave has no role permission on contracts and no global rule for him
SELECT morbac.t('Dave reads GlobalTech contracts [user_id-specific rule does not cover Dave]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
-- ---------------------------------------------------------------------------
-- Section 4: Global prohibition (user_id=NULL) -- all users denied regardless of role
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Global prohibition, user_id=NULL: all users denied ---'
-- Dave (employee) has a role-based permission on public_data from setup
SELECT morbac.t('Dave reads GlobalTech public_data [role permission before global prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
NULL,
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
SELECT morbac.t('Dave reads GlobalTech public_data [global prohibition blocks role permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
-- Karl (no role) is also denied
SELECT morbac.t('Karl reads GlobalTech public_data [global prohibition, no role]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
-- Alice (CEO, highest role) is also denied
SELECT morbac.t('Alice reads GlobalTech public_data [global prohibition overrides CEO role]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
DELETE FROM morbac.global_rules WHERE user_id IS NULL AND activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 5: Global prohibition (user_id=uuid) -- specific user denied only
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Global prohibition, user_id=Dave only ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000004', -- Dave
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
SELECT morbac.t('Dave reads GlobalTech public_data [user_id-specific global prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
-- Carol (manager) is unaffected
SELECT morbac.t('Carol reads GlobalTech public_data [global prohibition does not affect Carol]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000003'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000004'
AND activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 6: Priority -- global permission overrides global prohibition
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. Priority: global permission > global prohibition ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
NULL,
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 5
);
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
'30000000-0000-0000-0000-000000000004', -- Dave
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission', 10
);
SELECT morbac.t('Dave reads GlobalTech public_data [priority-10 global permission beats priority-5 global prohibition]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
-- Karl has no user_id-specific permission -- global prohibition still blocks him
SELECT morbac.t('Karl reads GlobalTech public_data [global prohibition still blocks non-exempted user]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
DELETE FROM morbac.global_rules WHERE activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 7: Priority -- high-priority global prohibition overrides role permission
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. Priority: global prohibition > role permission ---'
-- Dave has role-based read on public_data (priority 0 by default)
SELECT morbac.t('Dave reads GlobalTech public_data [role permission, no global rule]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), TRUE);
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
NULL,
'read', 'public_data',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 100
);
SELECT morbac.t('Dave reads GlobalTech public_data [priority-100 global prohibition beats role permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'public_data'
), FALSE);
DELETE FROM morbac.global_rules WHERE activity = 'read' AND view = 'public_data';
-- ---------------------------------------------------------------------------
-- Section 8: NULL activity/view wildcards
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. NULL wildcards: activity=NULL and view=NULL ---'
-- view=NULL grants read on every view for Karl (no role)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl reads GlobalTech contracts [view=NULL global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
SELECT morbac.t('Karl reads GlobalTech financial_data [view=NULL global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'financial_data'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view IS NULL;
-- activity=NULL and view=NULL grants everything
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
NULL, NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl deletes GlobalTech contracts [activity=NULL, view=NULL global permission]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'contracts'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity IS NULL AND view IS NULL;
-- ---------------------------------------------------------------------------
-- Section 9: Activity/view hierarchy applies when activity/view are set
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 9. Activity/view hierarchy ---'
-- financial_data is a child of documents (view hierarchy from setup)
-- A global permission on 'documents' should cover 'financial_data'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('Karl reads GlobalTech financial_data [global permission on documents covers child view]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'financial_data'
), TRUE);
DELETE FROM morbac.global_rules
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'documents';
-- ---------------------------------------------------------------------------
-- Section 10: Temporal global rules
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 10. Temporal global rules ---'
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, valid_from, valid_until)
VALUES (
'30000000-0000-0000-0000-000000000011', -- Karl
'read', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
now() - interval '1 hour',
now() + interval '1 day'
);
SELECT morbac.t('Karl reads GlobalTech contracts [temporal global rule, active]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), TRUE);
UPDATE morbac.global_rules
SET valid_until = now() - interval '1 second'
WHERE user_id = '30000000-0000-0000-0000-000000000011'
AND activity = 'read' AND view = 'contracts';
SELECT morbac.t('Karl reads GlobalTech contracts [temporal global rule, expired]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000011'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'contracts'
), FALSE);
\echo ''
\echo '=== Global Rules Tests Completed ==='
+293
View File
@@ -0,0 +1,293 @@
-- =============================================================================
-- System Principals Tests
-- =============================================================================
-- Tests the morbac.system_principals registry and all associated protections.
--
-- Scenarios:
-- 1. Setup: register a system principal and define its permissions via global_rules
-- 2. Permissions work normally (system principal can access what it is granted)
-- 3. Prohibitions are ignored (no role-based, user-level, or global prohibition applies)
-- 4. Cannot assign roles to a system principal
-- 5. Cannot revoke roles from a system principal
-- 6. Cannot add negative role assignments for a system principal
-- 7. Cannot add user_rules for a system principal
-- 8. Cannot create delegations involving a system principal
-- 9. Cannot add a targeted global_rules prohibition for a system principal
-- 10. Cannot modify or delete existing global_rules for a system principal
--
-- Prerequisites: 00_setup.sql -> 13_global_rules.sql
-- =============================================================================
\echo ''
\echo '================================================================'
\echo '14 -- SYSTEM PRINCIPALS'
\echo '================================================================'
-- ---------------------------------------------------------------------------
-- Section 1: Setup
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 1. Setup ---'
-- Register a system principal (done as DB owner at deploy time)
INSERT INTO morbac.system_principals (user_id, description)
VALUES (
'40000000-0000-0000-0000-000000000001',
'Backend API service account'
);
-- Define its permissions via global_rules (immutable once inserted)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
NULL, NULL,
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
SELECT morbac.t('System principal registered',
EXISTS (SELECT 1 FROM morbac.system_principals WHERE user_id = '40000000-0000-0000-0000-000000000001'),
TRUE);
-- ---------------------------------------------------------------------------
-- Section 2: Permissions work
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 2. System principal permissions are effective ---'
SELECT morbac.t('System principal reads GlobalTech documents [global permission, activity=NULL view=NULL]',
morbac.is_allowed_nocache(
'40000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'read', 'documents'
), TRUE);
SELECT morbac.t('System principal deletes Engineering contracts [global permission covers all]',
morbac.is_allowed_nocache(
'40000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000002'::uuid,
'delete', 'contracts'
), TRUE);
-- ---------------------------------------------------------------------------
-- Section 3: Prohibitions are ignored
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 3. Prohibitions are ignored for system principals ---'
-- Insert a blanket global prohibition (user_id=NULL affects everyone normally)
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality, priority)
VALUES (
NULL,
'delete', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition', 999
);
SELECT morbac.t('System principal deletes contracts [blanket global prohibition ignored]',
morbac.is_allowed_nocache(
'40000000-0000-0000-0000-000000000001'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'contracts'
), TRUE);
-- Regular user is still affected by the prohibition
SELECT morbac.t('Dave deletes GlobalTech contracts [blanket prohibition applies to regular users]',
morbac.is_allowed_nocache(
'30000000-0000-0000-0000-000000000004'::uuid,
'10000000-0000-0000-0000-000000000001'::uuid,
'delete', 'contracts'
), FALSE);
DELETE FROM morbac.global_rules
WHERE user_id IS NULL AND activity = 'delete' AND view = 'contracts';
-- ---------------------------------------------------------------------------
-- Section 4: Cannot assign roles to a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 4. Role assignment blocked ---'
SELECT morbac.t('Assigning role to system principal raises exception',
(SELECT COUNT(*) FROM (
SELECT morbac.assign_role(
'40000000-0000-0000-0000-000000000001'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'::uuid
)
) sub) = 0,
FALSE
) WHERE FALSE; -- skip, tested via exception below
DO $$
BEGIN
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'40000000-0000-0000-0000-000000000001',
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: role assignment to system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 5: Cannot add negative role assignments for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 5. Negative role assignment blocked ---'
DO $$
BEGIN
INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id)
VALUES (
'40000000-0000-0000-0000-000000000001',
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: negative role assignment for system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 6: Cannot add user_rules for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 6. user_rules blocked ---'
DO $$
BEGIN
INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000001',
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: user_rules insert for system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 7: Cannot create delegations involving a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 7. Delegations blocked ---'
DO $$
BEGIN
INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id)
VALUES (
'30000000-0000-0000-0000-000000000001', -- Alice as delegator
'40000000-0000-0000-0000-000000000001', -- system principal as delegatee
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = '10000000-0000-0000-0000-000000000001'),
'10000000-0000-0000-0000-000000000001'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: delegation to system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 8: Cannot add a targeted global_rules prohibition
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 8. Targeted global prohibition blocked ---'
DO $$
BEGIN
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
'delete', 'contracts',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'prohibition'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: global prohibition targeting system principal blocked';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 9: Cannot add a permission global_rule for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 9. New global permission for system principal blocked ---'
DO $$
BEGIN
INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality)
VALUES (
'40000000-0000-0000-0000-000000000001',
'read', 'documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission'
);
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: new global permission for system principal blocked (define at deploy time only)';
ELSE
RAISE;
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Section 10: Cannot delete existing global_rules for a system principal
-- ---------------------------------------------------------------------------
\echo ''
\echo '--- 10. Deleting system principal global_rules blocked ---'
DO $$
BEGIN
DELETE FROM morbac.global_rules
WHERE user_id = '40000000-0000-0000-0000-000000000001';
RAISE EXCEPTION 'expected exception not raised';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM LIKE '%system principal%' THEN
RAISE NOTICE 'PASS: deletion of system principal global rules blocked';
ELSE
RAISE;
END IF;
END;
$$;
\echo ''
\echo '=== System Principals Tests Completed ==='
+2
View File
@@ -30,6 +30,8 @@ BUILD_FILES=(
"rules.sql"
"cross_org_rules.sql"
"user_rules.sql"
"global_rules.sql"
"system_principals.sql"
"activity_view_bindings.sql"
"obligations.sql"
"audit.sql"