feat(system): introduce global rules and system principals for easier system account handling
This commit is contained in:
@@ -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
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
|
||||
+100
-53
@@ -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;
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ==='
|
||||
@@ -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 ==='
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user