From 0e3b90af22b516729ec923a77d1fa4766bbcf515 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Sat, 4 Apr 2026 09:23:46 +0200 Subject: [PATCH] feat(system): introduce global rules and system principals for easier system account handling --- Makefile | 8 +- docs/DOCUMENTATION.md | 131 ++++++++++- src/auth_cache.sql | 35 +++ src/authorization.sql | 153 ++++++++----- src/config.sql | 4 +- src/global_rules.sql | 57 +++++ src/system_principals.sql | 100 +++++++++ src/system_rls.sql | 26 +++ tests/13_global_rules.sql | 384 +++++++++++++++++++++++++++++++++ tests/14_system_principals.sql | 293 +++++++++++++++++++++++++ tools/build.sh | 2 + 11 files changed, 1129 insertions(+), 64 deletions(-) create mode 100644 src/global_rules.sql create mode 100644 src/system_principals.sql create mode 100644 tests/13_global_rules.sql create mode 100644 tests/14_system_principals.sql diff --git a/Makefile b/Makefile index 95d66eb..7d6ef28 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index fcf9d46..ce30435 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -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 1–3.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'); diff --git a/src/auth_cache.sql b/src/auth_cache.sql index 846ff44..af4e78c 100644 --- a/src/auth_cache.sql +++ b/src/auth_cache.sql @@ -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() diff --git a/src/authorization.sql b/src/authorization.sql index c032973..c5d24bd 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -32,64 +32,92 @@ DECLARE v_rule RECORD; v_max_prohibition_priority INTEGER := NULL; v_max_permission_priority INTEGER := NULL; + v_is_system_principal BOOLEAN := FALSE; BEGIN - -- STEP 1: Local prohibitions — find the highest-priority applicable one - FOR v_rule IN - SELECT r.context_id, COALESCE(r.priority, 0) AS prio - FROM morbac.rules r - WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope) - AND r.modality = 'prohibition' - AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id)) - AND morbac.is_rule_valid(r.valid_from, r.valid_until) - ORDER BY COALESCE(r.priority, 0) DESC - LOOP - IF morbac.eval_context(v_rule.context_id) THEN - v_max_prohibition_priority := v_rule.prio; - EXIT; - END IF; - END LOOP; + SELECT TRUE INTO v_is_system_principal + FROM morbac.system_principals WHERE user_id = p_user_id; - -- STEP 2: Cross-org prohibitions — update max if a higher priority is found - FOR v_rule IN - SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio - FROM morbac.cross_org_rules cr - WHERE cr.target_org_id = p_org_id - AND cr.modality = 'prohibition' - AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) - AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) - ORDER BY COALESCE(cr.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; + -- STEPS 1-3.5: Prohibitions — skipped entirely for system principals + IF NOT v_is_system_principal THEN - -- STEP 3: User-level prohibitions — direct user rules, update max if higher - FOR v_rule IN - SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio - FROM morbac.user_rules ur - WHERE ur.user_id = p_user_id - AND ur.org_id = p_org_id - AND ur.modality = 'prohibition' - AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) - AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) - AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) - ORDER BY COALESCE(ur.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 + -- STEP 1: Local prohibitions — find the highest-priority applicable one + FOR v_rule IN + SELECT r.context_id, COALESCE(r.priority, 0) AS prio + FROM morbac.rules r + WHERE morbac.org_in_scope(p_org_id, r.org_id, r.scope) + AND r.modality = 'prohibition' + AND r.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) + AND r.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND r.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, r.org_id)) + AND morbac.is_rule_valid(r.valid_from, r.valid_until) + ORDER BY COALESCE(r.priority, 0) DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN v_max_prohibition_priority := v_rule.prio; + EXIT; END IF; - EXIT; - END IF; - END LOOP; + END LOOP; + + -- STEP 2: Cross-org prohibitions — update max if a higher priority is found + FOR v_rule IN + SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio + FROM morbac.cross_org_rules cr + WHERE cr.target_org_id = p_org_id + AND cr.modality = 'prohibition' + AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) + AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND cr.role_id IN (SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)) + AND morbac.is_rule_valid(cr.valid_from, cr.valid_until) + ORDER BY COALESCE(cr.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; + + -- STEP 3: User-level prohibitions — direct user rules, update max if higher + FOR v_rule IN + SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio + FROM morbac.user_rules ur + WHERE ur.user_id = p_user_id + AND ur.org_id = p_org_id + AND ur.modality = 'prohibition' + AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) + AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) + ORDER BY COALESCE(ur.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; + + -- 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 @@ -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; diff --git a/src/config.sql b/src/config.sql index aac2106..d1b02e5 100644 --- a/src/config.sql +++ b/src/config.sql @@ -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 diff --git a/src/global_rules.sql b/src/global_rules.sql new file mode 100644 index 0000000..188ef7d --- /dev/null +++ b/src/global_rules.sql @@ -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(); diff --git a/src/system_principals.sql b/src/system_principals.sql new file mode 100644 index 0000000..7ea2f81 --- /dev/null +++ b/src/system_principals.sql @@ -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(); diff --git a/src/system_rls.sql b/src/system_rls.sql index 686b259..9caa45e 100644 --- a/src/system_rls.sql +++ b/src/system_rls.sql @@ -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; diff --git a/tests/13_global_rules.sql b/tests/13_global_rules.sql new file mode 100644 index 0000000..242e547 --- /dev/null +++ b/tests/13_global_rules.sql @@ -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 ===' diff --git a/tests/14_system_principals.sql b/tests/14_system_principals.sql new file mode 100644 index 0000000..4d92ff5 --- /dev/null +++ b/tests/14_system_principals.sql @@ -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 ===' diff --git a/tools/build.sh b/tools/build.sh index d5baf55..0cdee00 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -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"