diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 0918a67..87aef39 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -123,7 +123,7 @@ erDiagram **morbac.contexts**: Contextual conditions as callable predicates. Column `evaluator` (REGPROC) references a function returning BOOLEAN (preferably STABLE). Built-in context `always` returns true. -**morbac.rules**: Core rules linking org, role, activity, view, context, modality, and scope. The `scope` column (default `'self'`) controls which orgs the rule covers relative to `org_id` — evaluated at query time so new child orgs are picked up automatically without re-inserting rules. +**morbac.rules**: Core rules linking org, role, activity, view, context, modality, and scope. The `scope` column (default `'self'`) controls which orgs the rule covers relative to `org_id` - evaluated at query time so new child orgs are picked up automatically without re-inserting rules. ### Advanced Feature Tables @@ -198,7 +198,7 @@ Inter-organizational access rules. **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. +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 @@ -218,7 +218,7 @@ System-wide rules with no org or role binding. - `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. +**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** @@ -373,7 +373,7 @@ JOIN morbac.contexts c ON c.name = v.context_name; ### Organization Rule Scope -Every rule has a `scope` column (default `'self'`) that controls which organizations the rule covers relative to its `org_id`. Scope is **evaluated at query time** — adding a new child org to the hierarchy is enough for it to be covered by existing scoped rules. No rule re-creation needed. +Every rule has a `scope` column (default `'self'`) that controls which organizations the rule covers relative to its `org_id`. Scope is **evaluated at query time** - adding a new child org to the hierarchy is enough for it to be covered by existing scoped rules. No rule re-creation needed. | Scope | Covers | |---|---| @@ -414,11 +414,11 @@ WHERE o.name = 'EMEA Region'; **Cache behavior:** The auth cache is fully invalidated whenever the org tree changes (`INSERT`/`UPDATE`/`DELETE` on `morbac.orgs`), so scoped rules are always consistent. -**`get_org_scope(org_id, scope, max_depth?)`** is the underlying helper — it returns `(org_id, depth)` rows and can be used directly when you need to iterate over an org set. An optional `p_max_depth` limits traversal depth. +**`get_org_scope(org_id, scope, max_depth?)`** is the underlying helper - it returns `(org_id, depth)` rows and can be used directly when you need to iterate over an org set. An optional `p_max_depth` limits traversal depth. ### Unattributed (no-org) Objects -An object whose `org_id` is `NULL` is **unattributed**: it belongs to no organization, typically because it is awaiting attribution. This is the only meaning `NULL` carries in the org dimension — it never means "any org" and never means "all orgs". +An object whose `org_id` is `NULL` is **unattributed**: it belongs to no organization, typically because it is awaiting attribution. This is the only meaning `NULL` carries in the org dimension - it never means "any org" and never means "all orgs". #### The org target vocabulary @@ -426,15 +426,15 @@ Every rule kind selects its target the same way. There are exactly three targets | Target | Role-based (`morbac.rules`) | User-level (roleless) | |---|---|---| -| A specific organization | `scope` = `'self'`, `'subtree'`, … | `user_rules` with an `org_id` | +| A specific organization | `scope` = `'self'`, `'subtree'`, ... | `user_rules` with an `org_id` | | Unattributed objects | `scope` = `'unattributed'` | `user_rules` with `org_id = NULL` | | All orgs (unattributed included) | `scope` = `'all'` | `global_rules` | -The two object classes are **partitioned**: an `'unattributed'` rule can never reach an object that has an org, and the tree scopes (`'self'`, `'subtree'`, …) can never reach an unattributed object. Only `'all'` and `global_rules` deliberately span both. +The two object classes are **partitioned**: an `'unattributed'` rule can never reach an object that has an org, and the tree scopes (`'self'`, `'subtree'`, ...) can never reach an unattributed object. Only `'all'` and `global_rules` deliberately span both. #### Granting a role access to unattributed objects -The declaring org is the policy authority; the role must be held **in that org**. Grant, revoke, and delegate the role exactly as usual — access to the unattributed pool follows. +The declaring org is the policy authority; the role must be held **in that org**. Grant, revoke, and delegate the role exactly as usual - access to the unattributed pool follows. ```sql INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, scope) @@ -445,7 +445,7 @@ JOIN morbac.contexts c ON c.name = 'always' WHERE o.name = 'Acme Corp'; ``` -Several organizations may each declare their own policy over the same unattributed pool — that is ordinary Multi-OrBAC: independent authorities over a shared object space. +Several organizations may each declare their own policy over the same unattributed pool - that is ordinary Multi-OrBAC: independent authorities over a shared object space. Prohibitions, priorities, contexts, temporal validity, role hierarchy, delegation, derived roles, negative assignments and SoD all apply unchanged: @@ -463,7 +463,7 @@ Granting a single user access without a role uses `user_rules` with no org: ```sql INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality) -SELECT '…user…'::uuid, NULL, 'read', 'documents', c.id, 'permission' +SELECT '...user...'::uuid, NULL, 'read', 'documents', c.id, 'permission' FROM morbac.contexts c WHERE c.name = 'always'; ``` @@ -492,9 +492,9 @@ Which records come back is chosen with the session variables. A JSON `null` elem | Session | Returns | |---|---| -| *(nothing set)* | all authorized records — every org **and** unattributed | -| `morbac.org_id = ''` | that org only — unattributed excluded | -| `morbac.org_ids = '[""]'` | those orgs only — unattributed excluded | +| *(nothing set)* | all authorized records - every org **and** unattributed | +| `morbac.org_id = ''` | that org only - unattributed excluded | +| `morbac.org_ids = '[""]'` | those orgs only - unattributed excluded | | `morbac.org_ids = '[null]'` | **unattributed only** (the attribution queue) | | `morbac.org_ids = '["", null]'` | that org **plus** unattributed | @@ -508,7 +508,7 @@ SELECT * FROM app.documents; #### Capability probe for UI gating -To decide whether to show a feature at all — rather than authorize a specific object — use: +To decide whether to show a feature at all - rather than authorize a specific object - use: ```sql SELECT morbac.has_permission(user_id, 'read', 'documents'); @@ -683,7 +683,7 @@ INSERT INTO morbac.cross_org_rules ( SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TRUE ``` -**Scope vs. cross-org rules — when to use which:** +**Scope vs. cross-org rules - when to use which:** | Need | Use | |---|---| @@ -692,7 +692,7 @@ SELECT morbac.is_allowed(auditor_id, subsidiary_id, 'read', 'financials'); -- TR ### 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. +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` @@ -736,7 +736,7 @@ Priority 100 ensures this prohibition overrides any role-based permission. To ex ### 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. +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` @@ -745,7 +745,7 @@ Backend service accounts that must be fully immutable at the database level — | `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):** +**What is protected (trigger level - fires for all users including superusers):** | Table | Blocked operations | |---|---| @@ -755,9 +755,9 @@ Backend service accounts that must be fully immutable at the database level — | `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. +**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: +**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) @@ -775,23 +775,23 @@ 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. +**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. +Admin operations use the same `is_allowed()` engine as everything else - no separate code path. **System table RLS** `morbac.*` tables have RLS policies. `is_allowed()` and all its internal callees are `SECURITY DEFINER`, running as the extension owner and bypassing RLS. This breaks the recursion: RLS policies call `is_allowed()`, which queries morbac tables without re-triggering the policies. -The database owner (superuser) bypasses RLS by default — use that privilege only during bootstrap. +The database owner (superuser) bypasses RLS by default - use that privilege only during bootstrap. **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`, `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: +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: ```sql -- Rename 'rules' to 'policies' in your system @@ -953,7 +953,7 @@ WHERE table_name = 'rules' ### Authorization Functions -**`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. `org_id` is a specific organization, or `NULL` when the object is unattributed (no org) — `NULL` never means "any org". Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests). Unattributed decisions are not cached. +**`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. `org_id` is a specific organization, or `NULL` when the object is unattributed (no org) - `NULL` never means "any org". Cache writes are silently skipped in read-only transactions so this function is safe to call from both read-write and read-only contexts (e.g. PostgREST GET requests). Unattributed decisions are not cached. ```sql SELECT morbac.is_allowed(user_uuid, org_uuid, 'read', 'documents'); @@ -976,7 +976,11 @@ SELECT morbac.has_permission(user_uuid, 'read', 'documents'); **`get_org_descendants(org_id)`**: Returns all child organizations with depth (including self at depth 0). -**`get_org_scope(org_id, scope, max_depth?)`**: Returns a named set of organizations relative to `org_id`. Scope values: `self`, `children`, `descendants`, `subtree`, `parent`, `ancestors`, `lineage`, `root`. Optional `max_depth` limits traversal depth. +**`get_org_scope(org_id, scope, max_depth?)`**: Returns a named set of organizations relative to `org_id`. Scope values: `self`, `children`, `descendants`, `subtree`, `parent`, `ancestors`, `lineage`, `root`, plus `all` (every organization) and `unattributed` (no rows - an unattributed object has no org to return). Optional `max_depth` limits traversal depth. + +**`org_in_scope(target_org_id, rule_org_id, scope)`**: Returns TRUE when a rule declared at `rule_org_id` with `scope` covers `target_org_id`. A NULL target is covered only by `unattributed` and `all`; the tree scopes never match one. + +**`refresh_hierarchy_cache()`**: Rebuilds the materialized org, role, activity and view closures. Triggers call it whenever a hierarchy changes; call it manually after a bulk load. ```sql -- All orgs in the subtree, up to 2 levels deep @@ -1007,6 +1011,8 @@ SELECT * FROM morbac.get_org_scope(org_uuid, 'subtree', 2); **`eval_derived_role(evaluator, user_id, org_id)`**: Evaluate a derived role condition function (REGPROC). Returns BOOLEAN. +**`is_rule_valid(valid_from, valid_until)`**: Returns TRUE when a validity window covers the current timestamp. Every store applies it before a rule can match. + ### Context Functions **`eval_context(context_id)`**: Evaluate a context predicate. @@ -1083,10 +1089,10 @@ The row-scoped `rls_check` resolves which records to return from session variabl | Session variable | Behaviour | |---|---| -| `morbac.org_id` set | that single org — unattributed excluded | +| `morbac.org_id` set | that single org - unattributed excluded | | `morbac.org_ids` set | the listed orgs; a `null` element adds unattributed records | | `morbac.org_ids = '[null]'` | unattributed records only | -| neither set | all authorized records — every org **and** unattributed | +| neither set | all authorized records - every org **and** unattributed | #### Setting context from HTTP headers diff --git a/src/activity_view_bindings.sql b/src/activity_view_bindings.sql index b030962..259380a 100644 --- a/src/activity_view_bindings.sql +++ b/src/activity_view_bindings.sql @@ -19,7 +19,7 @@ BEGIN IF EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity) AND NOT EXISTS (SELECT 1 FROM morbac.activity_view_bindings WHERE activity = NEW.activity AND view = NEW.view) THEN - RAISE EXCEPTION 'Activity "%" is not allowed on view "%" — add a binding to morbac.activity_view_bindings to permit it', + RAISE EXCEPTION 'Activity "%" is not allowed on view "%" - add a binding to morbac.activity_view_bindings to permit it', NEW.activity, NEW.view; END IF; RETURN NEW; diff --git a/src/auth_cache.sql b/src/auth_cache.sql index af4e78c..87732fb 100644 --- a/src/auth_cache.sql +++ b/src/auth_cache.sql @@ -125,7 +125,7 @@ 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 +-- 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 @@ -141,7 +141,7 @@ 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 +-- system_principals changes affect prohibition bypass - invalidate per user CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_system_principal_change() RETURNS TRIGGER LANGUAGE plpgsql diff --git a/src/authorization.sql b/src/authorization.sql index 1dd9bd2..a5fa2a1 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -17,7 +17,7 @@ -- Scope: -- - rules.scope selects which objects a rule covers: a specific org -- (self/subtree/descendants/...), 'unattributed', or 'all'. --- Evaluated at query time via org_in_scope() — new orgs are covered automatically. +-- Evaluated at query time via org_in_scope() - new orgs are covered automatically. -- - cross_org_rules.source_org_id is always required: user must hold the role there. -- - user_rules target a specific user directly (no role required); their org_id -- is a specific org, or NULL for unattributed objects. @@ -44,10 +44,10 @@ BEGIN SELECT 1 FROM morbac.system_principals WHERE user_id = p_user_id ); - -- STEPS 1-3.5: Prohibitions — skipped entirely for system principals + -- STEPS 1-3.5: Prohibitions - skipped entirely for system principals IF NOT v_is_system_principal THEN - -- STEP 1: Local prohibitions — find the highest-priority applicable one + -- 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 @@ -65,7 +65,7 @@ BEGIN END IF; END LOOP; - -- STEP 2: Cross-org prohibitions — update max if a higher priority is found + -- 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 @@ -85,7 +85,7 @@ BEGIN END IF; END LOOP; - -- STEP 3: User-level prohibitions — direct user rules, update max if higher + -- 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 @@ -126,7 +126,7 @@ BEGIN END IF; -- v_is_system_principal - -- STEP 4: Local permissions — find the highest-priority applicable one + -- STEP 4: Local permissions - find the highest-priority applicable one FOR v_rule IN SELECT r.context_id, COALESCE(r.priority, 0) AS prio FROM morbac.rules r @@ -144,7 +144,7 @@ BEGIN END IF; END LOOP; - -- STEP 5: Cross-org permissions — update max if higher found + -- STEP 5: Cross-org permissions - update max if higher found FOR v_rule IN SELECT cr.context_id, COALESCE(cr.priority, 0) AS prio FROM morbac.cross_org_rules cr @@ -164,7 +164,7 @@ BEGIN END IF; END LOOP; - -- STEP 6: User-level permissions — direct user rules, update max if higher + -- STEP 6: User-level permissions - 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 diff --git a/src/global_rules.sql b/src/global_rules.sql index 188ef7d..9ee02aa 100644 --- a/src/global_rules.sql +++ b/src/global_rules.sql @@ -1,6 +1,6 @@ -- Global rules: Rule(user_id, activity, view, context, modality) -- --- No org_id or role_id — applies system-wide regardless of org membership or roles. +-- 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. -- diff --git a/src/hierarchy_functions.sql b/src/hierarchy_functions.sql index 5a0da63..4b32654 100644 --- a/src/hierarchy_functions.sql +++ b/src/hierarchy_functions.sql @@ -53,16 +53,16 @@ COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS -- Get a named scope of organizations relative to a given org. -- -- Supported scopes: --- 'self' — the org itself only (depth = 0) --- 'children' — direct children only (descendants at depth = 1) --- 'descendants' — all descendants, excluding self (depth > 0) --- 'subtree' — self + all descendants (equivalent to get_org_descendants) --- 'parent' — direct parent only (ancestor at depth = 1) --- 'ancestors' — all ancestors, excluding self (depth > 0) --- 'lineage' — self + all ancestors (equivalent to get_org_ancestors) --- 'root' — topmost ancestor only (max depth ancestor) --- 'unattributed' — the no-org bucket; resolves to no real orgs (empty set) --- 'all' — every organization (unattributed is not an org, so it is +-- 'self' - the org itself only (depth = 0) +-- 'children' - direct children only (descendants at depth = 1) +-- 'descendants' - all descendants, excluding self (depth > 0) +-- 'subtree' - self + all descendants (equivalent to get_org_descendants) +-- 'parent' - direct parent only (ancestor at depth = 1) +-- 'ancestors' - all ancestors, excluding self (depth > 0) +-- 'lineage' - self + all ancestors (equivalent to get_org_ancestors) +-- 'root' - topmost ancestor only (max depth ancestor) +-- 'unattributed' - the no-org bucket; resolves to no real orgs (empty set) +-- 'all' - every organization (unattributed is not an org, so it is -- not listed here; org_in_scope('all') does cover it) -- -- Optional p_max_depth limits how many levels are traversed (NULL = unlimited). diff --git a/src/rules.sql b/src/rules.sql index 9d7d12f..cde41dd 100644 --- a/src/rules.sql +++ b/src/rules.sql @@ -1,17 +1,17 @@ -- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality) -- -- scope controls which orgs this rule covers relative to org_id: --- 'self' — exact org only (default, current behavior) --- 'subtree' — org + all descendants --- 'descendants' — all descendants, excluding self --- 'children' — direct children only --- 'parent' — direct parent only --- 'ancestors' — all ancestors, excluding self --- 'lineage' — self + all ancestors --- 'root' — topmost ancestor only --- 'unattributed' — no-org objects only (org_id column above is the declaring authority) --- 'all' — every org, unattributed included --- Evaluated at query time via org_in_scope() — new orgs are picked up automatically. +-- 'self' - exact org only (default, current behavior) +-- 'subtree' - org + all descendants +-- 'descendants' - all descendants, excluding self +-- 'children' - direct children only +-- 'parent' - direct parent only +-- 'ancestors' - all ancestors, excluding self +-- 'lineage' - self + all ancestors +-- 'root' - topmost ancestor only +-- 'unattributed' - no-org objects only (org_id column above is the declaring authority) +-- 'all' - every org, unattributed included +-- Evaluated at query time via org_in_scope() - new orgs are picked up automatically. -- -- Org target vocabulary: a specific org (the tree scopes), 'unattributed', or 'all'. @@ -44,7 +44,7 @@ INCLUDE (role_id, context_id) WHERE is_active = true; COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; -COMMENT ON COLUMN morbac.rules.scope IS 'Object scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root, unattributed, all. Selects which objects (by org) the rule reaches: a specific org via the tree scopes, unattributed for no-org objects only, or all for every org including unattributed. Evaluated at query time — new orgs are covered automatically.'; +COMMENT ON COLUMN morbac.rules.scope IS 'Object scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root, unattributed, all. Selects which objects (by org) the rule reaches: a specific org via the tree scopes, unattributed for no-org objects only, or all for every org including unattributed. Evaluated at query time - new orgs are covered automatically.'; COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.'; diff --git a/src/system_principals.sql b/src/system_principals.sql index 6722722..d81db43 100644 --- a/src/system_principals.sql +++ b/src/system_principals.sql @@ -7,7 +7,7 @@ -- - 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 +-- 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. @@ -17,7 +17,7 @@ CREATE TABLE morbac.system_principals ( ); COMMENT ON TABLE morbac.system_principals IS -'Registry of backend service accounts. Immutable at the trigger level — no policy can touch them.'; +'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'; diff --git a/src/system_rls.sql b/src/system_rls.sql index 9caa45e..93511f1 100644 --- a/src/system_rls.sql +++ b/src/system_rls.sql @@ -85,7 +85,7 @@ CREATE POLICY user_roles_delete ON morbac.user_roles FOR DELETE USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete', morbac.get_config('system_view.user_roles'))); --- morbac.contexts (global — use current session org for writes) +-- morbac.contexts (global - use current session org for writes) ALTER TABLE morbac.contexts ENABLE ROW LEVEL SECURITY; CREATE POLICY contexts_select ON morbac.contexts FOR SELECT @@ -104,7 +104,7 @@ CREATE POLICY contexts_delete ON morbac.contexts FOR DELETE USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete', morbac.get_config('system_view.contexts'))); --- morbac.activities (global — use current session org for writes) +-- morbac.activities (global - use current session org for writes) ALTER TABLE morbac.activities ENABLE ROW LEVEL SECURITY; CREATE POLICY activities_select ON morbac.activities FOR SELECT @@ -123,7 +123,7 @@ CREATE POLICY activities_delete ON morbac.activities FOR DELETE USING (morbac.is_allowed(morbac.current_user_id(), morbac.current_org_id(), 'delete', morbac.get_config('system_view.activities'))); --- morbac.views (global — use current session org for writes) +-- morbac.views (global - use current session org for writes) ALTER TABLE morbac.views ENABLE ROW LEVEL SECURITY; CREATE POLICY views_select ON morbac.views FOR SELECT @@ -180,14 +180,14 @@ 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) +-- 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) +-- 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 diff --git a/src/user_rules.sql b/src/user_rules.sql index 8bb9c8e..e5ea1c8 100644 --- a/src/user_rules.sql +++ b/src/user_rules.sql @@ -34,7 +34,7 @@ CREATE INDEX idx_user_rules_activity_view ON morbac.user_rules(activity, view); CREATE INDEX idx_user_rules_modality ON morbac.user_rules(modality); CREATE INDEX idx_user_rules_lookup ON morbac.user_rules(user_id, org_id, activity, modality, view); -COMMENT ON TABLE morbac.user_rules IS 'Direct user-level rules — grant or prohibit access for a specific user, bypassing the role system'; +COMMENT ON TABLE morbac.user_rules IS 'Direct user-level rules - grant or prohibit access for a specific user, bypassing the role system'; COMMENT ON COLUMN morbac.user_rules.user_id IS 'User this rule applies to directly'; COMMENT ON COLUMN morbac.user_rules.org_id IS 'Org where the resource resides; NULL targets unattributed (no-org) objects'; COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; diff --git a/src/views.sql b/src/views.sql index f0f667b..36d6c9e 100644 --- a/src/views.sql +++ b/src/views.sql @@ -24,7 +24,7 @@ COMMENT ON TABLE morbac.view_hierarchy IS 'View hierarchy - senior views inherit COMMENT ON COLUMN morbac.view_hierarchy.senior_view IS 'Senior view (more specific)'; COMMENT ON COLUMN morbac.view_hierarchy.junior_view IS 'Junior view (more general)'; --- Default system view names — match system_view.* config keys. +-- Default system view names - match system_view.* config keys. -- Override config values to rename; the new name must be seeded here too. INSERT INTO morbac.views (name, description) VALUES ('orgs', 'Organizations table'), diff --git a/tests/00_setup.sql b/tests/00_setup.sql index 4e9dbe4..7ce8e63 100644 --- a/tests/00_setup.sql +++ b/tests/00_setup.sql @@ -1,11 +1,11 @@ -- ============================================================================= --- Test Setup — GlobalTech Inc. Company Scenario +-- Test Setup - GlobalTech Inc. Company Scenario -- ============================================================================= -- This file establishes the full company structure used across all test files: -- -- GlobalTech HQ (root) --- ├── Engineering Dept (child) --- └── Sales Dept (child) +-- +-- Engineering Dept (child) +-- \-- Sales Dept (child) -- -- Role hierarchy in GlobalTech (senior -> junior, i.e. senior inherits junior perms): -- ceo -> director -> manager -> employee -> intern @@ -15,18 +15,18 @@ -- tech_lead -> engineer -- -- Users: --- Alice — CEO at GlobalTech --- Bob — Director at GlobalTech --- Carol — Manager at GlobalTech --- Dave — Employee at GlobalTech --- Eve — Intern at GlobalTech --- Frank — Contractor at GlobalTech --- Grace — HR Manager at GlobalTech --- Heidi — Auditor at GlobalTech --- Ivan — Accountant at GlobalTech --- Judy — Engineer at Engineering + Sales Rep at Sales (multi-org) --- Karl — No role (unauthorized user) --- Leo — Employee at GlobalTech (used for delegation target) +-- Alice - CEO at GlobalTech +-- Bob - Director at GlobalTech +-- Carol - Manager at GlobalTech +-- Dave - Employee at GlobalTech +-- Eve - Intern at GlobalTech +-- Frank - Contractor at GlobalTech +-- Grace - HR Manager at GlobalTech +-- Heidi - Auditor at GlobalTech +-- Ivan - Accountant at GlobalTech +-- Judy - Engineer at Engineering + Sales Rep at Sales (multi-org) +-- Karl - No role (unauthorized user) +-- Leo - Employee at GlobalTech (used for delegation target) -- ============================================================================= -- Clean slate @@ -55,7 +55,7 @@ WHERE id IN ( '10000000-0000-0000-0000-000000000003' ); -SELECT o.name, COALESCE(p.name, '—') AS parent +SELECT o.name, COALESCE(p.name, ' - ') AS parent FROM morbac.orgs o LEFT JOIN morbac.orgs p ON o.parent_id = p.id ORDER BY o.parent_id NULLS FIRST, o.name; @@ -122,7 +122,7 @@ JOIN morbac.roles jr ON jr.id = rh.junior_role_id ORDER BY sr.name; -- --------------------------------------------------------------------------- --- USER — ROLE ASSIGNMENTS +-- USER - ROLE ASSIGNMENTS -- --------------------------------------------------------------------------- \echo '' \echo '=== Setup: User-Role Assignments ===' @@ -155,7 +155,7 @@ INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES ('30000000-0000-0000-0000-000000000010', '20000000-0002-0000-0000-000000000002', '10000000-0000-0000-0000-000000000002'), ('30000000-0000-0000-0000-000000000010', '20000000-0003-0000-0000-000000000002', '10000000-0000-0000-0000-000000000003'); --- Karl: no role (unauthorized user — intentionally not assigned any role) +-- Karl: no role (unauthorized user - intentionally not assigned any role) SELECT ur.user_id, r.name AS role, o.name AS organization FROM morbac.user_roles ur @@ -214,7 +214,7 @@ BEGIN RETURN TRUE; END; $$; INSERT INTO morbac.contexts (name, description, evaluator) VALUES - ('business_hours', 'During business hours (Mon–Fri 9–17)', 'morbac.ctx_business_hours'::regproc), + ('business_hours', 'During business hours (Mon-Fri 9-17)', 'morbac.ctx_business_hours'::regproc), ('after_hours', 'Outside business hours', 'morbac.ctx_after_hours'::regproc), ('end_of_quarter', 'End-of-quarter reporting window', 'morbac.ctx_end_of_quarter'::regproc); @@ -274,7 +274,7 @@ JOIN morbac.contexts c ON c.name = v.context_name; -- Disable auth cache so state changes between calls are always reflected SELECT morbac.set_config('cache_ttl_seconds', '0'); --- morbac.t(label, actual, expected) — boolean assertion +-- morbac.t(label, actual, expected) - boolean assertion CREATE OR REPLACE FUNCTION morbac.t(label TEXT, actual BOOLEAN, expect BOOLEAN) RETURNS TEXT LANGUAGE sql STABLE AS $$ SELECT CASE WHEN actual IS NOT DISTINCT FROM expect @@ -284,7 +284,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$ END; $$; --- morbac.t_null(label, actual) — assert value is NULL +-- morbac.t_null(label, actual) - assert value is NULL CREATE OR REPLACE FUNCTION morbac.t_null(label TEXT, actual TEXT) RETURNS TEXT LANGUAGE sql STABLE AS $$ SELECT CASE WHEN actual IS NULL @@ -293,7 +293,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$ END; $$; --- morbac.t_not_null(label, actual) — assert value is NOT NULL +-- morbac.t_not_null(label, actual) - assert value is NOT NULL CREATE OR REPLACE FUNCTION morbac.t_not_null(label TEXT, actual TEXT) RETURNS TEXT LANGUAGE sql STABLE AS $$ SELECT CASE WHEN actual IS NOT NULL @@ -302,7 +302,7 @@ RETURNS TEXT LANGUAGE sql STABLE AS $$ END; $$; --- morbac.t_eq(label, actual, expected) — assert two numeric values are equal +-- morbac.t_eq(label, actual, expected) - assert two numeric values are equal CREATE OR REPLACE FUNCTION morbac.t_eq(label TEXT, actual NUMERIC, expect NUMERIC) RETURNS TEXT LANGUAGE sql STABLE AS $$ SELECT CASE WHEN actual = expect diff --git a/tests/01_authorization.sql b/tests/01_authorization.sql index e49641c..b36fe12 100644 --- a/tests/01_authorization.sql +++ b/tests/01_authorization.sql @@ -5,25 +5,25 @@ -- No activity or view hierarchy is active yet (added in 02_hierarchies.sql). -- -- Users and their roles at GlobalTech HQ: --- Alice — ceo (permission: inherits all via hierarchy) --- Bob — director (inherits manager -> employee -> intern) --- Carol — manager (inherits employee -> intern) --- Dave — employee (inherits intern) --- Eve — intern --- Frank — contractor (has prohibition on financial/hr data) --- Grace — hr_manager --- Heidi — auditor --- Ivan — accountant --- Judy — engineer@Engineering + sales_rep@Sales (multi-org) --- Karl — no role --- Leo — employee +-- Alice - ceo (permission: inherits all via hierarchy) +-- Bob - director (inherits manager -> employee -> intern) +-- Carol - manager (inherits employee -> intern) +-- Dave - employee (inherits intern) +-- Eve - intern +-- Frank - contractor (has prohibition on financial/hr data) +-- Grace - hr_manager +-- Heidi - auditor +-- Ivan - accountant +-- Judy - engineer@Engineering + sales_rep@Sales (multi-org) +-- Karl - no role +-- Leo - employee -- -- Prerequisites: 00_setup.sql -- ============================================================================= \echo '' \echo '================================================================' -\echo '01 — CORE AUTHORIZATION DECISIONS' +\echo '01 - CORE AUTHORIZATION DECISIONS' \echo '================================================================' -- --------------------------------------------------------------------------- @@ -32,7 +32,7 @@ \echo '' \echo '--- 1. Basic permission grants ---' --- Dave (employee) can read documents — has explicit permission +-- Dave (employee) can read documents - has explicit permission SELECT morbac.t('Dave (employee) reads documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -40,7 +40,7 @@ SELECT morbac.t('Dave (employee) reads documents', 'read', 'documents' ), TRUE); --- Dave (employee) can write documents — context is business_hours (evaluates TRUE) +-- Dave (employee) can write documents - context is business_hours (evaluates TRUE) SELECT morbac.t('Dave (employee) writes documents [business_hours context=true]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -89,12 +89,12 @@ SELECT morbac.t('Ivan (accountant) writes financial_data', ), TRUE); -- --------------------------------------------------------------------------- --- Section 2: Default deny — no rule exists for the combination +-- Section 2: Default deny - no rule exists for the combination -- --------------------------------------------------------------------------- \echo '' \echo '--- 2. Default deny (no permission rule) ---' --- Dave (employee) cannot delete documents — no delete permission for employee +-- Dave (employee) cannot delete documents - no delete permission for employee SELECT morbac.t('Dave (employee) deletes documents [no permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -102,7 +102,7 @@ SELECT morbac.t('Dave (employee) deletes documents [no permission]', 'delete', 'documents' ), FALSE); --- Eve (intern) cannot read documents — intern only has public_data permission +-- Eve (intern) cannot read documents - intern only has public_data permission SELECT morbac.t('Eve (intern) reads documents [intern has no docs permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000005'::uuid, @@ -110,7 +110,7 @@ SELECT morbac.t('Eve (intern) reads documents [intern has no docs permission]', 'read', 'documents' ), FALSE); --- Dave (employee) cannot read financial_data — no rule for employee -> financial_data +-- Dave (employee) cannot read financial_data - no rule for employee -> financial_data SELECT morbac.t('Dave (employee) reads financial_data [no permission before view hierarchy]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -118,7 +118,7 @@ SELECT morbac.t('Dave (employee) reads financial_data [no permission before view 'read', 'financial_data' ), FALSE); --- Grace (hr_manager) cannot read audit_logs — no rule for hr_manager -> audit_logs +-- Grace (hr_manager) cannot read audit_logs - no rule for hr_manager -> audit_logs SELECT morbac.t('Grace (hr_manager) reads audit_logs [no permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000007'::uuid, @@ -126,7 +126,7 @@ SELECT morbac.t('Grace (hr_manager) reads audit_logs [no permission]', 'read', 'audit_logs' ), FALSE); --- Dave (employee) cannot approve documents — no approve permission for employee +-- Dave (employee) cannot approve documents - no approve permission for employee SELECT morbac.t('Dave (employee) approves documents [no permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -135,12 +135,12 @@ SELECT morbac.t('Dave (employee) approves documents [no permission]', ), FALSE); -- --------------------------------------------------------------------------- --- Section 3: Default deny — user has no role at all +-- Section 3: Default deny - user has no role at all -- --------------------------------------------------------------------------- \echo '' \echo '--- 3. Default deny (user has no role) ---' --- Karl has no role anywhere — all actions denied +-- Karl has no role anywhere - all actions denied SELECT morbac.t('Karl (no role) reads documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000011'::uuid, @@ -148,7 +148,7 @@ SELECT morbac.t('Karl (no role) reads documents', 'read', 'documents' ), FALSE); --- Completely unknown user UUID — EXPECT FALSE +-- Completely unknown user UUID - EXPECT FALSE SELECT morbac.t('Unknown user reads documents', morbac.is_allowed_nocache( 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid, @@ -162,7 +162,7 @@ SELECT morbac.t('Unknown user reads documents', \echo '' \echo '--- 4. Prohibition overrides permission ---' --- Frank (contractor) reads documents — permission granted, no prohibition +-- Frank (contractor) reads documents - permission granted, no prohibition SELECT morbac.t('Frank (contractor) reads documents [has permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000006'::uuid, @@ -170,7 +170,7 @@ SELECT morbac.t('Frank (contractor) reads documents [has permission]', 'read', 'documents' ), TRUE); --- Frank (contractor) reads financial_data — PROHIBITED +-- Frank (contractor) reads financial_data - PROHIBITED SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000006'::uuid, @@ -178,7 +178,7 @@ SELECT morbac.t('Frank (contractor) reads financial_data [prohibited]', 'read', 'financial_data' ), FALSE); --- Frank (contractor) reads hr_data — prohibited +-- Frank (contractor) reads hr_data - prohibited SELECT morbac.t('Frank (contractor) reads hr_data [prohibited]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000006'::uuid, @@ -211,7 +211,7 @@ WHERE org_id = '10000000-0000-0000-0000-000000000001' AND activity = 'read' AND view = 'financial_data' AND modality = 'permission'; -- --------------------------------------------------------------------------- --- Section 5: Context filtering — rule only applies when context is TRUE +-- Section 5: Context filtering - rule only applies when context is TRUE -- --------------------------------------------------------------------------- \echo '' \echo '--- 5. Context filtering ---' @@ -256,7 +256,7 @@ WHERE org_id = '10000000-0000-0000-0000-000000000001' AND activity = 'export' AND view = 'documents'; -- --------------------------------------------------------------------------- --- Section 6: Wrong organization — user has no role in the target org +-- Section 6: Wrong organization - user has no role in the target org -- --------------------------------------------------------------------------- \echo '' \echo '--- 6. Wrong organization ---' @@ -278,12 +278,12 @@ SELECT morbac.t('Judy (Engineering engineer) reads GlobalTech financial_data [no ), FALSE); -- --------------------------------------------------------------------------- --- Section 7: Multi-organization user — access scoped to each org independently +-- Section 7: Multi-organization user - access scoped to each org independently -- --------------------------------------------------------------------------- \echo '' \echo '--- 7. Multi-org user (Judy) ---' --- Judy is engineer at Engineering — can read documents there +-- Judy is engineer at Engineering - can read documents there SELECT morbac.t('Judy (engineer) reads Engineering documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000010'::uuid, @@ -291,7 +291,7 @@ SELECT morbac.t('Judy (engineer) reads Engineering documents', 'read', 'documents' ), TRUE); --- Judy is sales_rep at Sales — can write contracts there +-- Judy is sales_rep at Sales - can write contracts there SELECT morbac.t('Judy (sales_rep) writes Sales contracts', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000010'::uuid, @@ -313,7 +313,7 @@ SELECT morbac.t('Judy writes documents at Sales [engineer perms dont carry over] \echo '' \echo '--- 8. Role hierarchy inheritance ---' --- Carol (manager) inherits employee permissions — can read documents (employee perm) +-- Carol (manager) inherits employee permissions - can read documents (employee perm) SELECT morbac.t('Carol (manager, inherits employee) reads documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000003'::uuid, @@ -321,7 +321,7 @@ SELECT morbac.t('Carol (manager, inherits employee) reads documents', 'read', 'documents' ), TRUE); --- Carol (manager) has own permission — can approve documents +-- Carol (manager) has own permission - can approve documents SELECT morbac.t('Carol (manager) approves documents [own permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000003'::uuid, @@ -329,7 +329,7 @@ SELECT morbac.t('Carol (manager) approves documents [own permission]', 'approve', 'documents' ), TRUE); --- Bob (director) inherits manager -> employee chain — can read documents +-- Bob (director) inherits manager -> employee chain - can read documents SELECT morbac.t('Bob (director, inherits manager+employee) reads documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000002'::uuid, @@ -337,7 +337,7 @@ SELECT morbac.t('Bob (director, inherits manager+employee) reads documents', 'read', 'documents' ), TRUE); --- Bob (director) inherits manager — can approve documents +-- Bob (director) inherits manager - can approve documents SELECT morbac.t('Bob (director, inherits manager) approves documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000002'::uuid, @@ -345,7 +345,7 @@ SELECT morbac.t('Bob (director, inherits manager) approves documents', 'approve', 'documents' ), TRUE); --- Alice (CEO) inherits the entire hierarchy — can do everything below +-- Alice (CEO) inherits the entire hierarchy - can do everything below SELECT morbac.t('Alice (CEO, inherits all) reads documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000001'::uuid, @@ -367,7 +367,7 @@ SELECT morbac.t('Alice (CEO, inherits all) deletes documents', 'delete', 'documents' ), TRUE); --- Eve (intern) cannot approve — intern has no approve permission +-- Eve (intern) cannot approve - intern has no approve permission SELECT morbac.t('Eve (intern) approves documents [intern has no approve permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000005'::uuid, @@ -464,7 +464,7 @@ SELECT morbac.t('Dave reads contracts: prohibition priority=10 beats permission -- Equal priorities: prohibition wins (modality tiebreaker) UPDATE morbac.rules SET priority = 5 WHERE id = 'e0000000-0000-0000-0000-000000000001'; -SELECT morbac.t('Dave reads contracts: equal priority — prohibition wins by modality precedence [denied]', +SELECT morbac.t('Dave reads contracts: equal priority - prohibition wins by modality precedence [denied]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, '10000000-0000-0000-0000-000000000001'::uuid, @@ -477,7 +477,7 @@ UPDATE morbac.rules SET priority = NULL WHERE id IN ( 'e0000000-0000-0000-0000-000000000002' ); -SELECT morbac.t('Dave reads contracts: no priority set — prohibition wins by default [denied]', +SELECT morbac.t('Dave reads contracts: no priority set - prohibition wins by default [denied]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, '10000000-0000-0000-0000-000000000001'::uuid, diff --git a/tests/02_hierarchies.sql b/tests/02_hierarchies.sql index da57b82..61408af 100644 --- a/tests/02_hierarchies.sql +++ b/tests/02_hierarchies.sql @@ -2,10 +2,10 @@ -- Hierarchy Tests -- ============================================================================= -- Tests all four hierarchy types: --- 1. Role hierarchy — senior roles inherit permissions of junior roles (transitive) --- 2. Activity hierarchy — requesting a senior activity also matches junior-activity rules --- 3. View hierarchy — requesting a senior view also matches junior-view rules --- 4. Org hierarchy — get_org_ancestors / get_org_descendants traversal +-- 1. Role hierarchy - senior roles inherit permissions of junior roles (transitive) +-- 2. Activity hierarchy - requesting a senior activity also matches junior-activity rules +-- 3. View hierarchy - requesting a senior view also matches junior-view rules +-- 4. Org hierarchy - get_org_ancestors / get_org_descendants traversal -- -- Hierarchy semantics in this system: -- Activity: (senior='write', junior='read') means get_effective_activities('write') @@ -19,50 +19,50 @@ \echo '' \echo '================================================================' -\echo '02 — HIERARCHIES' +\echo '02 - HIERARCHIES' \echo '================================================================' -- --------------------------------------------------------------------------- --- Section 1: Role hierarchy — get_effective_roles and get_inherited_roles +-- Section 1: Role hierarchy - get_effective_roles and get_inherited_roles -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. Role hierarchy introspection ---' --- Eve (intern) direct assignment only — 1 effective role +-- Eve (intern) direct assignment only - 1 effective role SELECT morbac.t_eq('Eve has 1 effective role (intern only)', (SELECT COUNT(*) FROM morbac.get_effective_roles( '30000000-0000-0000-0000-000000000005'::uuid, '10000000-0000-0000-0000-000000000001'::uuid ))::bigint, 1); --- Dave (employee) has employee + intern via hierarchy — 2 effective roles +-- Dave (employee) has employee + intern via hierarchy - 2 effective roles SELECT morbac.t_eq('Dave has 2 effective roles (employee, intern)', (SELECT COUNT(*) FROM morbac.get_effective_roles( '30000000-0000-0000-0000-000000000004'::uuid, '10000000-0000-0000-0000-000000000001'::uuid ))::bigint, 2); --- Carol (manager) has manager + employee + intern via hierarchy — 3 effective roles +-- Carol (manager) has manager + employee + intern via hierarchy - 3 effective roles SELECT morbac.t_eq('Carol has 3 effective roles (manager, employee, intern)', (SELECT COUNT(*) FROM morbac.get_effective_roles( '30000000-0000-0000-0000-000000000003'::uuid, '10000000-0000-0000-0000-000000000001'::uuid ))::bigint, 3); --- Alice (CEO) has ceo, director, manager, employee, intern — 5 effective roles +-- Alice (CEO) has ceo, director, manager, employee, intern - 5 effective roles SELECT morbac.t_eq('Alice has 5 effective roles (ceo through intern)', (SELECT COUNT(*) FROM morbac.get_effective_roles( '30000000-0000-0000-0000-000000000001'::uuid, '10000000-0000-0000-0000-000000000001'::uuid ))::bigint, 5); --- get_inherited_roles for manager — manager itself + employee + intern = 3 +-- get_inherited_roles for manager - manager itself + employee + intern = 3 SELECT morbac.t_eq('get_inherited_roles(manager) returns 3 roles (manager, employee, intern)', (SELECT COUNT(*) FROM morbac.get_inherited_roles( '20000000-0001-0000-0000-000000000003'::uuid ))::bigint, 3); --- get_inherited_roles for ceo — entire chain = 5 +-- get_inherited_roles for ceo - entire chain = 5 SELECT morbac.t_eq('get_inherited_roles(ceo) returns 5 roles (ceo through intern)', (SELECT COUNT(*) FROM morbac.get_inherited_roles( '20000000-0001-0000-0000-000000000001'::uuid @@ -83,10 +83,10 @@ SELECT morbac.t('intern role appears in manager inherited roles', ), TRUE); -- --------------------------------------------------------------------------- --- Section 2: Role hierarchy — authorization via inheritance (transitive) +-- Section 2: Role hierarchy - authorization via inheritance (transitive) -- --------------------------------------------------------------------------- \echo '' -\echo '--- 2. Role hierarchy — authorization via inheritance ---' +\echo '--- 2. Role hierarchy - authorization via inheritance ---' -- Carol (manager) has employee permission (read documents) via 1-level inheritance SELECT morbac.t('Carol (manager, 1-level inherit) reads documents', @@ -128,7 +128,7 @@ SELECT morbac.t('Carol (manager) approves documents (own perm)', 'approve', 'documents' ), TRUE); --- Eve (intern) cannot approve — no role above intern has approve perm +-- Eve (intern) cannot approve - no role above intern has approve perm SELECT morbac.t('Eve (intern) approves documents [inheritance only goes up in seniority]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000005'::uuid, @@ -306,7 +306,7 @@ SELECT morbac.t_eq('get_effective_activities(export) returns 2 (export, read)', -- Rule: employee has 'read documents' permission. -- With hierarchy (write->read), requesting 'write' also matches the 'read' rule. --- Dave (employee) requests 'write' — matches 'read' rule via write->read hierarchy +-- Dave (employee) requests 'write' - matches 'read' rule via write->read hierarchy SELECT morbac.t('Dave (employee, has read perm) writes docs via activity hierarchy', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -314,7 +314,7 @@ SELECT morbac.t('Dave (employee, has read perm) writes docs via activity hierarc 'write', 'documents' ), TRUE); --- Dave (employee) requests 'delete' — delete->write->read chain, 'read' rule matches +-- Dave (employee) requests 'delete' - delete->write->read chain, 'read' rule matches SELECT morbac.t('Dave (employee, has read perm) deletes docs via delete->write->read hierarchy', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -322,7 +322,7 @@ SELECT morbac.t('Dave (employee, has read perm) deletes docs via delete->write-> 'delete', 'documents' ), TRUE); --- Dave (employee) requests 'export' — export->read, 'read' rule matches +-- Dave (employee) requests 'export' - export->read, 'read' rule matches SELECT morbac.t('Dave (employee, has read perm) exports docs via export->read hierarchy', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -330,7 +330,7 @@ SELECT morbac.t('Dave (employee, has read perm) exports docs via export->read hi 'export', 'documents' ), TRUE); --- Eve (intern) has only 'read public_data' — requesting 'write public_data' also matches +-- Eve (intern) has only 'read public_data' - requesting 'write public_data' also matches SELECT morbac.t('Eve (intern, has read perm) writes public_data via activity hierarchy', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000005'::uuid, @@ -338,7 +338,7 @@ SELECT morbac.t('Eve (intern, has read perm) writes public_data via activity hie 'write', 'public_data' ), TRUE); --- 'audit' is not in the hierarchy — no junior, no senior — no match for employee +-- 'audit' is not in the hierarchy - no junior, no senior - no match for employee SELECT morbac.t('Dave (employee) audits documents [audit not in hierarchy, no permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -385,7 +385,7 @@ SELECT morbac.t('documents is in effective views of financial_data', -- requesting 'financial_data' -> get_effective_views('financial_data') = {financial_data, documents} -- the 'documents' rule matches -> access granted --- Dave (employee) reads financial_data — matches employee's 'read documents' rule via view hierarchy +-- Dave (employee) reads financial_data - matches employee's 'read documents' rule via view hierarchy SELECT morbac.t('Dave (employee, has read documents) reads financial_data via view hierarchy', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -393,7 +393,7 @@ SELECT morbac.t('Dave (employee, has read documents) reads financial_data via vi 'read', 'financial_data' ), TRUE); --- Dave (employee) reads hr_data — matches 'read documents' via view hierarchy +-- Dave (employee) reads hr_data - matches 'read documents' via view hierarchy SELECT morbac.t('Dave (employee, has read documents) reads hr_data via view hierarchy', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -401,7 +401,7 @@ SELECT morbac.t('Dave (employee, has read documents) reads hr_data via view hier 'read', 'hr_data' ), TRUE); --- Frank (contractor) reads documents — permitted +-- Frank (contractor) reads documents - permitted SELECT morbac.t('Frank (contractor) reads documents [has permission]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000006'::uuid, @@ -409,8 +409,8 @@ SELECT morbac.t('Frank (contractor) reads documents [has permission]', 'read', 'documents' ), TRUE); --- Frank (contractor) reads financial_data — matches contractor's 'read documents' rule via view hierarchy --- BUT contractor has a PROHIBITION on financial_data — prohibition wins +-- Frank (contractor) reads financial_data - matches contractor's 'read documents' rule via view hierarchy +-- BUT contractor has a PROHIBITION on financial_data - prohibition wins SELECT morbac.t('Frank (contractor) reads financial_data [prohibition overrides view-hierarchy match]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000006'::uuid, @@ -436,7 +436,7 @@ SELECT morbac.t_eq('Sales Dept has 2 ancestors (self + GlobalTech HQ)', '10000000-0000-0000-0000-000000000003'::uuid ))::bigint, 2); --- GlobalTech HQ is the root — only 1 ancestor (itself) +-- GlobalTech HQ is the root - only 1 ancestor (itself) SELECT morbac.t_eq('GlobalTech HQ (root) has 1 ancestor (itself only)', (SELECT COUNT(*) FROM morbac.get_org_ancestors( '10000000-0000-0000-0000-000000000001'::uuid @@ -478,17 +478,17 @@ SELECT morbac.t('Dave (GlobalTech employee) reads Engineering docs [no cross-org ), FALSE); -- --------------------------------------------------------------------------- --- Section 6: get_org_scope — named scope helper +-- Section 6: get_org_scope - named scope helper -- --------------------------------------------------------------------------- -- Org tree used in tests: -- GlobalTech HQ (root) id: 10000000-0000-0000-0000-000000000001 --- ├── Engineering Dept id: 10000000-0000-0000-0000-000000000002 --- └── Sales Dept id: 10000000-0000-0000-0000-000000000003 +-- +-- Engineering Dept id: 10000000-0000-0000-0000-000000000002 +-- \-- Sales Dept id: 10000000-0000-0000-0000-000000000003 -- --------------------------------------------------------------------------- \echo '' \echo '--- 6. get_org_scope ---' --- 'self' — always returns exactly the org itself +-- 'self' - always returns exactly the org itself SELECT morbac.t_eq('scope self (root) returns 1 row', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'self'))::bigint, 1); @@ -501,7 +501,7 @@ SELECT morbac.t('scope self returns the org itself at depth 0', WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 0 ), TRUE); --- 'children' — direct children only (depth = 1 descendants) +-- 'children' - direct children only (depth = 1 descendants) SELECT morbac.t_eq('scope children of root returns 2 rows (Engineering + Sales)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'children'))::bigint, 2); @@ -520,7 +520,7 @@ SELECT morbac.t('scope children includes Engineering at depth 1', WHERE org_id = '10000000-0000-0000-0000-000000000002' AND depth = 1 ), TRUE); --- 'descendants' — all descendants excluding self +-- 'descendants' - all descendants excluding self SELECT morbac.t_eq('scope descendants of root returns 2 rows (Engineering + Sales, no self)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'descendants'))::bigint, 2); @@ -533,7 +533,7 @@ SELECT morbac.t('scope descendants does not include self', SELECT morbac.t_eq('scope descendants of leaf returns 0 rows', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'descendants'))::bigint, 0); --- 'subtree' — self + all descendants +-- 'subtree' - self + all descendants SELECT morbac.t_eq('scope subtree of root returns 3 rows (self + Engineering + Sales)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree'))::bigint, 3); @@ -546,7 +546,7 @@ SELECT morbac.t('scope subtree includes self at depth 0', SELECT morbac.t_eq('scope subtree of leaf returns 1 row (self only)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'subtree'))::bigint, 1); --- 'parent' — direct parent only +-- 'parent' - direct parent only SELECT morbac.t_eq('scope parent of Engineering returns 1 row (GlobalTech HQ)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'parent'))::bigint, 1); @@ -559,7 +559,7 @@ SELECT morbac.t('scope parent of Engineering returns GlobalTech HQ at depth 1', SELECT morbac.t_eq('scope parent of root returns 0 rows (no parent)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'parent'))::bigint, 0); --- 'ancestors' — all ancestors excluding self +-- 'ancestors' - all ancestors excluding self SELECT morbac.t_eq('scope ancestors of Engineering returns 1 row (GlobalTech HQ only)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'ancestors'))::bigint, 1); @@ -572,7 +572,7 @@ SELECT morbac.t('scope ancestors does not include self', SELECT morbac.t_eq('scope ancestors of root returns 0 rows', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'ancestors'))::bigint, 0); --- 'lineage' — self + all ancestors +-- 'lineage' - self + all ancestors SELECT morbac.t_eq('scope lineage of Engineering returns 2 rows (self + GlobalTech HQ)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'lineage'))::bigint, 2); @@ -585,7 +585,7 @@ SELECT morbac.t('scope lineage includes self at depth 0', SELECT morbac.t_eq('scope lineage of root returns 1 row (self only)', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'lineage'))::bigint, 1); --- 'root' — topmost ancestor only +-- 'root' - topmost ancestor only SELECT morbac.t_eq('scope root of Engineering returns 1 row', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000002'::uuid, 'root'))::bigint, 1); @@ -604,7 +604,7 @@ SELECT morbac.t('scope root of root returns the org itself', WHERE org_id = '10000000-0000-0000-0000-000000000001' ), TRUE); --- p_max_depth — depth limiting +-- p_max_depth - depth limiting SELECT morbac.t_eq('scope subtree max_depth=0 returns only self', (SELECT COUNT(*) FROM morbac.get_org_scope('10000000-0000-0000-0000-000000000001'::uuid, 'subtree', 0))::bigint, 1); diff --git a/tests/03_delegation.sql b/tests/03_delegation.sql index d16f5fb..334e8a3 100644 --- a/tests/03_delegation.sql +++ b/tests/03_delegation.sql @@ -20,11 +20,11 @@ \echo '' \echo '================================================================' -\echo '03 — DELEGATION' +\echo '03 - DELEGATION' \echo '================================================================' -- --------------------------------------------------------------------------- --- Section 1: Baseline — Leo (employee) before any delegation +-- Section 1: Baseline - Leo (employee) before any delegation -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. Baseline: Leo before delegation ---' @@ -37,7 +37,7 @@ SELECT morbac.t('Leo (employee) reads documents before delegation', 'read', 'documents' ), TRUE); --- Leo cannot approve documents — that requires manager role +-- Leo cannot approve documents - that requires manager role SELECT morbac.t('Leo (employee) approves documents before delegation [no manager perm]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000012'::uuid, @@ -66,7 +66,7 @@ SELECT morbac.t('Leo (employee) does not have approve permission before delegati ), FALSE); -- --------------------------------------------------------------------------- --- Section 2: Active delegation — Carol delegates manager role to Leo +-- Section 2: Active delegation - Carol delegates manager role to Leo -- --------------------------------------------------------------------------- \echo '' \echo '--- 2. Active delegation: Carol -> Leo (manager role, 1 day) ---' @@ -83,7 +83,7 @@ VALUES ( now() + interval '1 day' ); --- Leo now has delegated manager role — can approve documents +-- Leo now has delegated manager role - can approve documents SELECT morbac.t('Leo (delegated manager) approves documents', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000012'::uuid, @@ -217,7 +217,7 @@ VALUES ( TRUE -- revoked ); --- Dave should NOT get accountant from revoked delegation — verify revoked flag is set +-- Dave should NOT get accountant from revoked delegation - verify revoked flag is set -- Note: employee already has write financial_data via view hierarchy; -- so we verify the delegation is actually revoked in the DB. SELECT morbac.t('Revoked accountant delegation has revoked=TRUE in DB', @@ -296,7 +296,7 @@ INSERT INTO morbac.delegations (id, delegator_id, delegatee_id, role_id, org_id, valid_from, valid_until) VALUES ( 'de000001-0000-0000-0000-000000000005', - '30000000-0000-0000-0000-000000000004', -- Dave (employee — does NOT hold manager) + '30000000-0000-0000-0000-000000000004', -- Dave (employee - does NOT hold manager) '30000000-0000-0000-0000-000000000005', -- Eve '20000000-0001-0000-0000-000000000003', -- manager role '10000000-0000-0000-0000-000000000001', @@ -330,7 +330,7 @@ SELECT morbac.t('Invalid delegation not in Eve comprehensive roles', \echo '' \echo '--- 8. get_comprehensive_roles source reporting ---' --- Alice (CEO, direct) — source should be 'direct' +-- Alice (CEO, direct) - source should be 'direct' SELECT morbac.t('Alice CEO role has source=direct in comprehensive roles', EXISTS( SELECT 1 FROM morbac.get_comprehensive_roles( diff --git a/tests/04_constraints.sql b/tests/04_constraints.sql index ab05676..0f71484 100644 --- a/tests/04_constraints.sql +++ b/tests/04_constraints.sql @@ -2,10 +2,10 @@ -- Constraint Tests -- ============================================================================= -- Tests business constraints: --- 1. Separation of Duty (SoD) — mutually exclusive roles --- 2. Negative role assignments — explicit blocking of a role --- 3. Role cardinality constraints — min/max users per role --- 4. Rule conflict detection — modality conflicts on same tuple +-- 1. Separation of Duty (SoD) - mutually exclusive roles +-- 2. Negative role assignments - explicit blocking of a role +-- 3. Role cardinality constraints - min/max users per role +-- 4. Rule conflict detection - modality conflicts on same tuple -- -- Scenario: -- - auditor and accountant are mutually exclusive (no one can hold both) @@ -19,11 +19,11 @@ \echo '' \echo '================================================================' -\echo '04 — CONSTRAINTS' +\echo '04 - CONSTRAINTS' \echo '================================================================' -- --------------------------------------------------------------------------- --- Section 1: Separation of Duty — define conflict +-- Section 1: Separation of Duty - define conflict -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. Separation of Duty setup ---' @@ -49,7 +49,7 @@ SELECT morbac.t_eq('SoD conflict between auditor and accountant created', \echo '' \echo '--- 2. SoD violation detection ---' --- Heidi (auditor) — check if assigning accountant role would violate SoD +-- Heidi (auditor) - check if assigning accountant role would violate SoD SELECT morbac.t('Assigning accountant to Heidi (auditor) violates SoD', morbac.check_sod_violation( '30000000-0000-0000-0000-000000000008'::uuid, -- Heidi @@ -57,7 +57,7 @@ SELECT morbac.t('Assigning accountant to Heidi (auditor) violates SoD', '10000000-0000-0000-0000-000000000001'::uuid ), TRUE); --- Ivan (accountant) — check if assigning auditor role would violate SoD +-- Ivan (accountant) - check if assigning auditor role would violate SoD SELECT morbac.t('Assigning auditor to Ivan (accountant) violates SoD [symmetric]', morbac.check_sod_violation( '30000000-0000-0000-0000-000000000009'::uuid, -- Ivan @@ -65,8 +65,8 @@ SELECT morbac.t('Assigning auditor to Ivan (accountant) violates SoD [symmetric] '10000000-0000-0000-0000-000000000001'::uuid ), TRUE); --- Dave (employee) — check if assigning accountant would violate SoD --- Dave is not an auditor — no conflict +-- Dave (employee) - check if assigning accountant would violate SoD +-- Dave is not an auditor - no conflict SELECT morbac.t('Assigning accountant to Dave (not an auditor) does not violate SoD', morbac.check_sod_violation( '30000000-0000-0000-0000-000000000004'::uuid, -- Dave @@ -74,7 +74,7 @@ SELECT morbac.t('Assigning accountant to Dave (not an auditor) does not violate '10000000-0000-0000-0000-000000000001'::uuid ), FALSE); --- Heidi (auditor) — assigning a non-conflicting role (manager) is fine +-- Heidi (auditor) - assigning a non-conflicting role (manager) is fine SELECT morbac.t('Assigning manager to Heidi (auditor) does not violate SoD', morbac.check_sod_violation( '30000000-0000-0000-0000-000000000008'::uuid, -- Heidi @@ -126,7 +126,7 @@ VALUES ( 'Frank is a contractor and must not gain employee-level access' ); --- Frank's employee role is negated — verify via get_comprehensive_roles +-- Frank's employee role is negated - verify via get_comprehensive_roles -- employee role must not appear (negated by negative assignment) SELECT morbac.t('Frank: employee role excluded by negative assignment', NOT EXISTS( @@ -148,7 +148,7 @@ SELECT morbac.t('Frank: contractor role still present after employee negated', ), TRUE); -- Negative assignment on a role the user never had is harmless --- Karl has no role — adding negative assignment for manager is a no-op +-- Karl has no role - adding negative assignment for manager is a no-op INSERT INTO morbac.negative_role_assignments (user_id, role_id, org_id, reason) VALUES ( '30000000-0000-0000-0000-000000000011', -- Karl @@ -181,7 +181,7 @@ WHERE user_id = '30000000-0000-0000-0000-000000000006' \echo '' \echo '--- 4. Role cardinality constraints ---' --- Set a cardinality constraint: compliance_officer role — min 1, max 2 +-- Set a cardinality constraint: compliance_officer role - min 1, max 2 INSERT INTO morbac.role_cardinality (role_id, min_users, max_users, description) VALUES ( '20000000-0001-0000-0000-000000000010', -- compliance_officer @@ -189,8 +189,8 @@ VALUES ( 'Compliance officer role: at least 1, at most 2' ); --- Currently 0 users have compliance_officer — adding one should be fine (0 < max=2) -SELECT morbac.t_null('Adding first compliance_officer (0 users, max=2) — no violation', +-- Currently 0 users have compliance_officer - adding one should be fine (0 < max=2) +SELECT morbac.t_null('Adding first compliance_officer (0 users, max=2) - no violation', morbac.check_cardinality_violation( '20000000-0001-0000-0000-000000000010'::uuid, TRUE -- adding @@ -201,15 +201,15 @@ INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES ('30000000-0000-0000-0000-000000000004', '20000000-0001-0000-0000-000000000010', '10000000-0000-0000-0000-000000000001'), ('30000000-0000-0000-0000-000000000005', '20000000-0001-0000-0000-000000000010', '10000000-0000-0000-0000-000000000001'); --- Now 2 users — at max. Trying to add a 3rd should violate -SELECT morbac.t_not_null('Adding 3rd compliance_officer (2 users, max=2) — violation returned', +-- Now 2 users - at max. Trying to add a 3rd should violate +SELECT morbac.t_not_null('Adding 3rd compliance_officer (2 users, max=2) - violation returned', morbac.check_cardinality_violation( '20000000-0001-0000-0000-000000000010'::uuid, TRUE -- adding )); --- Removing one — 2 users, min=1 — removing leaves 1 which is ≥ min=1, should be fine -SELECT morbac.t_null('Removing from 2 compliance_officers (min=1) — no violation (still above min)', +-- Removing one - 2 users, min=1 - removing leaves 1 which is >= min=1, should be fine +SELECT morbac.t_null('Removing from 2 compliance_officers (min=1) - no violation (still above min)', morbac.check_cardinality_violation( '20000000-0001-0000-0000-000000000010'::uuid, FALSE -- removing @@ -221,21 +221,21 @@ WHERE user_id = '30000000-0000-0000-0000-000000000005' AND role_id = '20000000-0001-0000-0000-000000000010'; -- 1 user remaining = min. Removing the last one would violate min=1 -SELECT morbac.t_not_null('Removing last compliance_officer (1 user, min=1) — violation returned', +SELECT morbac.t_not_null('Removing last compliance_officer (1 user, min=1) - violation returned', morbac.check_cardinality_violation( '20000000-0001-0000-0000-000000000010'::uuid, FALSE -- removing )); --- Adding again after being at 1 — 1 user, max=2 — ok -SELECT morbac.t_null('Adding when at 1 compliance_officer (max=2) — no violation', +-- Adding again after being at 1 - 1 user, max=2 - ok +SELECT morbac.t_null('Adding when at 1 compliance_officer (max=2) - no violation', morbac.check_cardinality_violation( '20000000-0001-0000-0000-000000000010'::uuid, TRUE -- adding )); --- Role with no cardinality constraint — no violation for any operation -SELECT morbac.t_null('Checking cardinality for employee role (no constraint) — no violation', +-- Role with no cardinality constraint - no violation for any operation +SELECT morbac.t_null('Checking cardinality for employee role (no constraint) - no violation', morbac.check_cardinality_violation( '20000000-0001-0000-0000-000000000004'::uuid, -- employee TRUE @@ -263,8 +263,8 @@ VALUES ( 'permission' ); --- No conflict yet — only a permission exists -SELECT morbac.t_eq('detect_rule_conflicts: permission alone — no conflicts', +-- No conflict yet - only a permission exists +SELECT morbac.t_eq('detect_rule_conflicts: permission alone - no conflicts', (SELECT COUNT(*) FROM morbac.detect_rule_conflicts( '10000000-0000-0000-0000-000000000001', '20000000-0001-0000-0000-000000000004', @@ -309,7 +309,7 @@ SELECT morbac.t('detect_rule_conflicts: conflicting rule is the permission', WHERE conflicting_modality = 'permission' ), TRUE); --- Insert an obligation for the same tuple — conflicts with the prohibition +-- Insert an obligation for the same tuple - conflicts with the prohibition INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) VALUES ( 'f0000000-0000-0000-0000-000000000003', @@ -331,7 +331,7 @@ SELECT morbac.t_eq('detect_rule_conflicts: obligation conflicts with existing pr ))::bigint, 1); --- Insert a recommendation — conflicts with both obligation and prohibition +-- Insert a recommendation - conflicts with both obligation and prohibition INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) VALUES ( 'f0000000-0000-0000-0000-000000000004', @@ -354,7 +354,7 @@ SELECT morbac.t_eq('detect_rule_conflicts: recommendation conflicts with prohibi 2); -- No conflict between permission and recommendation (they coexist meaningfully) -SELECT morbac.t_eq('detect_rule_conflicts: permission vs recommendation — no conflict', +SELECT morbac.t_eq('detect_rule_conflicts: permission vs recommendation - no conflict', (SELECT COUNT(*) FROM morbac.detect_rule_conflicts( '10000000-0000-0000-0000-000000000001', '20000000-0001-0000-0000-000000000004', diff --git a/tests/05_temporal.sql b/tests/05_temporal.sql index 9ad7e10..a7ff462 100644 --- a/tests/05_temporal.sql +++ b/tests/05_temporal.sql @@ -9,7 +9,7 @@ -- 3. Expired rule (valid_until in the past) -> denied -- 4. Future rule (valid_from in the future) -> denied -- 5. Active time window (valid_from past, valid_until future) -> allowed --- 6. Multiple rules for same combination — only active ones count +-- 6. Multiple rules for same combination - only active ones count -- 7. Expired prohibition: no longer blocks access after it expires -- 8. Temporal rules interact correctly with role hierarchy -- @@ -21,7 +21,7 @@ \echo '' \echo '================================================================' -\echo '05 — TEMPORAL CONSTRAINTS' +\echo '05 - TEMPORAL CONSTRAINTS' \echo '================================================================' -- --------------------------------------------------------------------------- @@ -37,35 +37,35 @@ INSERT INTO morbac.views (name, description) VALUES \echo '--- 1. is_rule_valid() helper ---' -- No bounds: always valid -SELECT morbac.t('is_rule_valid(NULL, NULL) — always valid', +SELECT morbac.t('is_rule_valid(NULL, NULL) - always valid', morbac.is_rule_valid(NULL::timestamptz, NULL::timestamptz), TRUE); -- Past valid_from, no valid_until: currently active -SELECT morbac.t('is_rule_valid(past, NULL) — started in past, no end', +SELECT morbac.t('is_rule_valid(past, NULL) - started in past, no end', morbac.is_rule_valid('2000-01-01'::timestamptz, NULL), TRUE); -- Future valid_from: not yet active -SELECT morbac.t('is_rule_valid(future, NULL) — not yet started', +SELECT morbac.t('is_rule_valid(future, NULL) - not yet started', morbac.is_rule_valid('2099-01-01'::timestamptz, NULL), FALSE); -- Past valid_until: expired -SELECT morbac.t('is_rule_valid(NULL, past) — already expired', +SELECT morbac.t('is_rule_valid(NULL, past) - already expired', morbac.is_rule_valid(NULL, '2000-01-01'::timestamptz), FALSE); -- Future valid_until, no valid_from: currently active -SELECT morbac.t('is_rule_valid(NULL, future) — no start, future end', +SELECT morbac.t('is_rule_valid(NULL, future) - no start, future end', morbac.is_rule_valid(NULL, '2099-01-01'::timestamptz), TRUE); -- Active window: past start, future end -SELECT morbac.t('is_rule_valid(past, future) — within active window', +SELECT morbac.t('is_rule_valid(past, future) - within active window', morbac.is_rule_valid('2000-01-01'::timestamptz, '2099-01-01'::timestamptz), TRUE); -- Fully past window (both start and end in the past) -SELECT morbac.t('is_rule_valid(past_start, past_end) — entirely expired', +SELECT morbac.t('is_rule_valid(past_start, past_end) - entirely expired', morbac.is_rule_valid('2000-01-01'::timestamptz, '2001-01-01'::timestamptz), FALSE); -- Fully future window (both start and end in the future) -SELECT morbac.t('is_rule_valid(future_start, future_end) — entirely in the future', +SELECT morbac.t('is_rule_valid(future_start, future_end) - entirely in the future', morbac.is_rule_valid('2090-01-01'::timestamptz, '2099-01-01'::timestamptz), FALSE); -- --------------------------------------------------------------------------- @@ -105,7 +105,7 @@ SELECT morbac.t('is_active = FALSE after valid_until set to past', DELETE FROM morbac.rules WHERE activity = 'audit' AND view = 'temp_view'; -- --------------------------------------------------------------------------- --- Section 3: Expired rule — valid_until in the past +-- Section 3: Expired rule - valid_until in the past -- --------------------------------------------------------------------------- \echo '' \echo '--- 3. Expired rule (valid_until in the past) ---' @@ -129,7 +129,7 @@ SELECT morbac.t('Dave (employee) exports temp_view via expired rule [denied]', ), FALSE); -- --------------------------------------------------------------------------- --- Section 4: Future rule — valid_from in the future +-- Section 4: Future rule - valid_from in the future -- --------------------------------------------------------------------------- \echo '' \echo '--- 4. Future rule (valid_from in the future) ---' @@ -209,7 +209,7 @@ SELECT morbac.t('Dave exports temp_view (export rule now active)', ), TRUE); -- --------------------------------------------------------------------------- --- Section 7: Temporal prohibition — expired prohibition no longer blocks +-- Section 7: Temporal prohibition - expired prohibition no longer blocks -- --------------------------------------------------------------------------- \echo '' \echo '--- 7. Expired prohibition no longer blocks ---' @@ -295,7 +295,7 @@ SELECT morbac.t('Carol (manager) approves temp_view [temporal rule, active]', 'approve', 'temp_view' ), TRUE); --- Alice (CEO) inherits from manager — should also get the temporal permission +-- Alice (CEO) inherits from manager - should also get the temporal permission SELECT morbac.t('Alice (CEO, inherits manager) approves temp_view [temporal rule, active]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000001'::uuid, diff --git a/tests/06_cross_org.sql b/tests/06_cross_org.sql index 8ec82cf..4b32ed7 100644 --- a/tests/06_cross_org.sql +++ b/tests/06_cross_org.sql @@ -22,7 +22,7 @@ \echo '' \echo '================================================================' -\echo '06 — CROSS-ORGANIZATIONAL RULES' +\echo '06 - CROSS-ORGANIZATIONAL RULES' \echo '================================================================' -- --------------------------------------------------------------------------- @@ -53,7 +53,7 @@ SELECT morbac.t('Nina has eng_auditor role at Engineering', ), TRUE); -- --------------------------------------------------------------------------- --- Section 1: No cross-org rule — access between orgs is denied by default +-- Section 1: No cross-org rule - access between orgs is denied by default -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. No cross-org rule: access denied by default ---' @@ -75,7 +75,7 @@ SELECT morbac.t('Judy (Engineering engineer) reads GlobalTech financial_data [no ), FALSE); -- --------------------------------------------------------------------------- --- Section 2: Cross-org permission — Sales sales_rep reads GlobalTech reports +-- Section 2: Cross-org permission - Sales sales_rep reads GlobalTech reports -- --------------------------------------------------------------------------- \echo '' \echo '--- 2. Cross-org permission ---' @@ -123,7 +123,7 @@ SELECT morbac.t('Judy (Sales sales_rep) reads GlobalTech documents [no rule for \echo '' \echo '--- 3. Role must be held in source org ---' --- Karl has no role anywhere — cannot use the Sales->GlobalTech cross-org rule +-- Karl has no role anywhere - cannot use the Sales->GlobalTech cross-org rule SELECT morbac.t('Karl (no role) reads GlobalTech reports via cross-org rule [no role in source]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000011'::uuid, @@ -140,7 +140,7 @@ SELECT morbac.t('Karl (no role) reads GlobalTech documents [no access anywhere]' ), FALSE); -- --------------------------------------------------------------------------- --- Section 4: Cross-org prohibition — blocks access even with regular permission +-- Section 4: Cross-org prohibition - blocks access even with regular permission -- --------------------------------------------------------------------------- \echo '' \echo '--- 4. Cross-org prohibition ---' diff --git a/tests/07_audit.sql b/tests/07_audit.sql index f73d075..cf134c5 100644 --- a/tests/07_audit.sql +++ b/tests/07_audit.sql @@ -19,7 +19,7 @@ \echo '' \echo '================================================================' -\echo '07 — AUDIT LOGGING' +\echo '07 - AUDIT LOGGING' \echo '================================================================' -- Clear any existing audit log entries to start fresh @@ -160,7 +160,7 @@ DELETE FROM morbac.rules WHERE id = 'a0000000-0000-0000-0000-000000000001'; \echo '' \echo '--- 5. Query audit log by record_id ---' --- The test rule had INSERT, UPDATE, DELETE — should be 3 entries +-- The test rule had INSERT, UPDATE, DELETE - should be 3 entries SELECT morbac.t_eq('Audit log has 3 entries for test rule record (INSERT + UPDATE + DELETE)', (SELECT COUNT(*) FROM morbac.audit_log WHERE table_name = 'rules' @@ -245,7 +245,7 @@ BEGIN END; $$; --- Perform another user_roles change — should NOT be logged +-- Perform another user_roles change - should NOT be logged INSERT INTO morbac.user_roles (user_id, role_id, org_id) VALUES ( '30000000-0000-0000-0000-000000000011', diff --git a/tests/08_system_access.sql b/tests/08_system_access.sql index 7714af3..bbfea96 100644 --- a/tests/08_system_access.sql +++ b/tests/08_system_access.sql @@ -10,7 +10,7 @@ -- 2. Grant permissions via regular rules, verify access -- 3. Prohibition overrides permission (standard engine behavior) -- 4. Role hierarchy applies: senior role inherits permissions --- 5. assign_role() / revoke_role() — SoD/cardinality enforcement, RLS guards the INSERT/DELETE +-- 5. assign_role() / revoke_role() - SoD/cardinality enforcement, RLS guards the INSERT/DELETE -- 6. RLS on morbac tables: session user cannot read/write without rules -- 7. Rules are org-scoped -- @@ -19,7 +19,7 @@ \echo '' \echo '================================================================' -\echo '08 — SYSTEM ACCESS' +\echo '08 - SYSTEM ACCESS' \echo '================================================================' -- --------------------------------------------------------------------------- @@ -218,7 +218,7 @@ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO morbac_rls_tester; SET SESSION AUTHORIZATION morbac_rls_tester; --- Grace (hr_manager) has create/delete on user_roles — RLS should allow +-- Grace (hr_manager) has create/delete on user_roles - RLS should allow SET morbac.user_id = '30000000-0000-0000-0000-000000000007'; SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; @@ -241,7 +241,7 @@ WHERE user_id = '30000000-0000-0000-0000-000000000011' AND role_id = '20000000-0001-0000-0000-000000000005' AND org_id = '10000000-0000-0000-0000-000000000001'; --- Dave (employee) has no rules for user_roles — RLS should block +-- Dave (employee) has no rules for user_roles - RLS should block SET morbac.user_id = '30000000-0000-0000-0000-000000000004'; SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; diff --git a/tests/09_utilities.sql b/tests/09_utilities.sql index ca8dde8..46998ff 100644 --- a/tests/09_utilities.sql +++ b/tests/09_utilities.sql @@ -3,14 +3,14 @@ -- ============================================================================= -- Tests miscellaneous utility functions and advanced features: -- --- 1. pending_obligations — returns obligation rules for a user --- 2. pending_recommendations — returns recommendation rules for a user +-- 1. pending_obligations - returns obligation rules for a user +-- 2. pending_recommendations - returns recommendation rules for a user -- 3. Obligations/recommendations do NOT affect is_allowed() -- 3b. Conflict resolution: prohibition voids obligation; prohibition/obligation voids recommendation --- 4. user_has_role — checks if user holds a named role --- 5. user_roles_in_org — lists all roles for user in org --- 6. eval_context — evaluates context predicates directly --- 7. Derived roles — computed via evaluator function +-- 4. user_has_role - checks if user holds a named role +-- 5. user_roles_in_org - lists all roles for user in org +-- 6. eval_context - evaluates context predicates directly +-- 7. Derived roles - computed via evaluator function -- 8. RLS helpers: get_user_orgs, current_org_ids, rls_check() org scoping -- -- Prerequisites: 00_setup.sql -> 08_system_access.sql @@ -18,7 +18,7 @@ \echo '' \echo '================================================================' -\echo '09 — UTILITIES, DERIVED ROLES, RLS' +\echo '09 - UTILITIES, DERIVED ROLES, RLS' \echo '================================================================' -- --------------------------------------------------------------------------- @@ -139,7 +139,7 @@ SELECT morbac.t('Dave has recommendation for read audit_logs but no permission [ ), FALSE); -- --------------------------------------------------------------------------- --- Section 3b: Conflict resolution — prohibition voids obligation/recommendation +-- Section 3b: Conflict resolution - prohibition voids obligation/recommendation -- --------------------------------------------------------------------------- \echo '' \echo '--- 3b. Conflict resolution: prohibition voids obligation and recommendation ---' @@ -330,13 +330,13 @@ SELECT morbac.t('eval_context(end_of_quarter) = TRUE', \echo '' \echo '--- 7. Derived roles ---' --- Create a derived role: 'senior_employee' — dynamically granted to Dave (only) +-- Create a derived role: 'senior_employee' - dynamically granted to Dave (only) INSERT INTO morbac.roles (id, org_id, name, description) VALUES ( '20000000-0001-0000-0000-000000000011', '10000000-0000-0000-0000-000000000001', 'senior_employee', - 'Senior employee — granted dynamically based on tenure' + 'Senior employee - granted dynamically based on tenure' ); -- Evaluator function: returns TRUE only for Dave at GlobalTech @@ -379,7 +379,7 @@ SELECT morbac.t('Dave has senior_employee derived role in comprehensive roles', AND source = 'derived' ), TRUE); --- Eve (intern) does NOT satisfy the evaluator — no derived role +-- Eve (intern) does NOT satisfy the evaluator - no derived role SELECT morbac.t('Eve has no derived roles in comprehensive roles', NOT EXISTS( SELECT 1 FROM morbac.get_comprehensive_roles( diff --git a/tests/10_activity_view_bindings.sql b/tests/10_activity_view_bindings.sql index 7b8a170..0538a5d 100644 --- a/tests/10_activity_view_bindings.sql +++ b/tests/10_activity_view_bindings.sql @@ -2,8 +2,8 @@ -- Activity-View Binding Tests -- ============================================================================= -- Tests opt-in activity-to-view restrictions: --- 1. No bindings defined — any view is allowed --- 2. Binding defined — listed view is allowed, unlisted view is blocked +-- 1. No bindings defined - any view is allowed +-- 2. Binding defined - listed view is allowed, unlisted view is blocked -- 3. Blocking applies to cross_org_rules as well -- 4. Removing all bindings lifts the restriction -- @@ -12,11 +12,11 @@ \echo '' \echo '================================================================' -\echo '10 — ACTIVITY-VIEW BINDINGS' +\echo '10 - ACTIVITY-VIEW BINDINGS' \echo '================================================================' -- --------------------------------------------------------------------------- --- Section 1: No bindings — unconstrained +-- Section 1: No bindings - unconstrained -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. No bindings: any view is allowed ---' @@ -39,7 +39,7 @@ SELECT morbac.t('No bindings: audit/documents rule inserted successfully', DELETE FROM morbac.rules WHERE id = 'b0000000-0000-0000-0000-000000000001'; -- --------------------------------------------------------------------------- --- Section 2: Binding defined — listed view allowed, unlisted view blocked +-- Section 2: Binding defined - listed view allowed, unlisted view blocked -- --------------------------------------------------------------------------- \echo '' \echo '--- 2. Binding defined: allowed view works, unlisted view blocked ---' diff --git a/tests/11_scope_rules.sql b/tests/11_scope_rules.sql index 48aa8df..c36dba2 100644 --- a/tests/11_scope_rules.sql +++ b/tests/11_scope_rules.sql @@ -3,18 +3,18 @@ -- ============================================================================= -- Tests rules.scope and cross_org_rules.source_org_id = NULL: -- --- 1. scope='self' (default) — exact org only, unchanged behavior --- 2. scope='subtree' — rule at root covers self + Engineering + Sales --- 3. scope='descendants' — covers Engineering + Sales but NOT GlobalTech itself --- 4. scope='children' — covers direct children only --- 5. New org added after rule creation — picked up automatically (cache invalidation) +-- 1. scope='self' (default) - exact org only, unchanged behavior +-- 2. scope='subtree' - rule at root covers self + Engineering + Sales +-- 3. scope='descendants' - covers Engineering + Sales but NOT GlobalTech itself +-- 4. scope='children' - covers direct children only +-- 5. New org added after rule creation - picked up automatically (cache invalidation) -- -- Prerequisites: 00_setup.sql -> 10_activity_view_bindings.sql -- ============================================================================= \echo '' \echo '================================================================' -\echo '11 — SCOPE RULES AND GLOBAL CROSS-ORG RULES' +\echo '11 - SCOPE RULES AND GLOBAL CROSS-ORG RULES' \echo '================================================================' -- Setup: create a dedicated role for scope tests (avoid polluting existing rules) @@ -23,7 +23,7 @@ VALUES ( '20000000-0001-0000-0000-000000000012', '10000000-0000-0000-0000-000000000001', 'analyst', - 'Data analyst — scope tests' + 'Data analyst - scope tests' ); -- Assign Karl (previously no role) as analyst at GlobalTech @@ -35,7 +35,7 @@ VALUES ( ); -- --------------------------------------------------------------------------- --- Section 1: scope='self' (default) — exact org only +-- Section 1: scope='self' (default) - exact org only -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. scope=self (default) ---' @@ -67,7 +67,7 @@ SELECT morbac.t('Karl (analyst, scope=self) reads reports in Engineering [denied DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000001'; -- --------------------------------------------------------------------------- --- Section 2: scope='subtree' — root + all descendants +-- Section 2: scope='subtree' - root + all descendants -- --------------------------------------------------------------------------- \echo '' \echo '--- 2. scope=subtree ---' @@ -106,7 +106,7 @@ SELECT morbac.t('Karl (analyst, scope=subtree) reads reports in Sales [allowed]' DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000002'; -- --------------------------------------------------------------------------- --- Section 3: scope='descendants' — children only, NOT self +-- Section 3: scope='descendants' - children only, NOT self -- --------------------------------------------------------------------------- \echo '' \echo '--- 3. scope=descendants ---' @@ -145,7 +145,7 @@ SELECT morbac.t('Karl (analyst, scope=descendants) reads reports in Sales [allow DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000003'; -- --------------------------------------------------------------------------- --- Section 4: scope='children' — direct children only +-- Section 4: scope='children' - direct children only -- --------------------------------------------------------------------------- \echo '' \echo '--- 4. scope=children ---' @@ -193,7 +193,7 @@ DELETE FROM morbac.rules WHERE id = 'c0000000-0000-0000-0000-000000000004'; DELETE FROM morbac.orgs WHERE id = '10000000-0000-0000-0000-000000000004'; -- --------------------------------------------------------------------------- --- Section 5: New org added after rule creation — scope picks it up automatically +-- Section 5: New org added after rule creation - scope picks it up automatically -- --------------------------------------------------------------------------- \echo '' \echo '--- 5. Dynamic scope: new org covered automatically ---' @@ -225,7 +225,7 @@ VALUES ( '10000000-0000-0000-0000-000000000001' ); --- The scoped rule was defined before Legal Dept existed — still covers it +-- The scoped rule was defined before Legal Dept existed - still covers it SELECT morbac.t('Karl (analyst) reads documents in Legal Dept [new org, covered by subtree scope]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000011'::uuid, diff --git a/tests/12_user_rules.sql b/tests/12_user_rules.sql index 652970a..70eb96f 100644 --- a/tests/12_user_rules.sql +++ b/tests/12_user_rules.sql @@ -17,11 +17,11 @@ \echo '' \echo '================================================================' -\echo '12 — USER RULES' +\echo '12 - USER RULES' \echo '================================================================' -- --------------------------------------------------------------------------- --- Section 1: No user rule — Karl (no role) is denied +-- Section 1: No user rule - Karl (no role) is denied -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. No user rule: access denied ---' @@ -41,7 +41,7 @@ SELECT morbac.t('Karl (no role) reads GlobalTech financial_data [no user rule]', ), FALSE); -- --------------------------------------------------------------------------- --- Section 2: Direct user permission — Karl gets access without a role +-- Section 2: Direct user permission - Karl gets access without a role -- --------------------------------------------------------------------------- \echo '' \echo '--- 2. Direct user permission ---' @@ -78,7 +78,7 @@ SELECT morbac.t('Karl reads GlobalTech financial_data [documents rule covers it 'read', 'financial_data' ), TRUE); --- contracts has no hierarchy relationship — documents rule does not cover it +-- contracts has no hierarchy relationship - documents rule does not cover it SELECT morbac.t('Karl reads GlobalTech contracts [no user rule, no hierarchy coverage]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000011'::uuid, @@ -141,7 +141,7 @@ SELECT morbac.t('Eve (intern) reads GlobalTech public_data [user prohibition blo 'read', 'public_data' ), FALSE); --- Dave (employee) is unaffected — only Eve has the prohibition +-- Dave (employee) is unaffected - only Eve has the prohibition SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibition]', morbac.is_allowed_nocache( '30000000-0000-0000-0000-000000000004'::uuid, @@ -150,7 +150,7 @@ SELECT morbac.t('Dave (employee) reads GlobalTech public_data [no user prohibiti ), TRUE); -- --------------------------------------------------------------------------- --- Section 5: Priority — higher-priority user permission overrides prohibition +-- Section 5: Priority - higher-priority user permission overrides prohibition -- --------------------------------------------------------------------------- \echo '' \echo '--- 5. Priority: user permission overrides user prohibition ---' @@ -236,7 +236,7 @@ SELECT morbac.t('rls_check passes without target_user_id filter', '30000000-0000-0000-0000-000000000004'::uuid ), TRUE); --- Set target_user_id to Dave — rows belonging to Dave pass +-- Set target_user_id to Dave - rows belonging to Dave pass SET morbac.target_user_id = '30000000-0000-0000-0000-000000000004'; SELECT morbac.t('rls_check passes when row user_id matches target_user_id', @@ -254,7 +254,7 @@ SELECT morbac.t('rls_check blocked when row user_id differs from target_user_id' '30000000-0000-0000-0000-000000000001'::uuid ), FALSE); --- No p_row_user_id passed — user filter does not apply +-- No p_row_user_id passed - user filter does not apply SELECT morbac.t('rls_check passes when no row user_id passed (filter skipped)', morbac.rls_check( 'read', 'documents', diff --git a/tests/15_rls_check.sql b/tests/15_rls_check.sql index 8f0623d..43e8583 100644 --- a/tests/15_rls_check.sql +++ b/tests/15_rls_check.sql @@ -37,7 +37,7 @@ \echo '================================================================' -- --------------------------------------------------------------------------- --- Section 1: No user_id set — always FALSE +-- Section 1: No user_id set - always FALSE -- --------------------------------------------------------------------------- \echo '' \echo '--- 1. No user_id: always FALSE ---' @@ -64,19 +64,19 @@ SELECT morbac.t('rls_check without user_id, global row', SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ --- 2a: org-scoped row, matching org — Karl has user_rule for read documents +-- 2a: org-scoped row, matching org - Karl has user_rule for read documents SELECT morbac.t('rls_check single org, org row matches session org (Karl/documents)', morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'::uuid), TRUE); --- 2b: org-scoped row, different org — blocked before is_allowed +-- 2b: org-scoped row, different org - blocked before is_allowed SELECT morbac.t('rls_check single org, org row from different org (blocked)', morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'::uuid), FALSE); --- 2c: NULL row — filtered out under a single org pin (orphan not requested) +-- 2c: NULL row - filtered out under a single org pin (orphan not requested) SELECT morbac.t('rls_check single org, NULL row filtered out under org pin', morbac.rls_check('read', 'documents', NULL), FALSE); @@ -98,19 +98,19 @@ RESET morbac.org_id; SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001"]'; -- [GlobalTech HQ] --- 3a: org-scoped row in the list — Karl has user_rule for read documents in GlobalTech +-- 3a: org-scoped row in the list - Karl has user_rule for read documents in GlobalTech SELECT morbac.t('rls_check org_ids, org row in list (Karl/documents/GlobalTech)', morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'::uuid), TRUE); --- 3b: org-scoped row not in the list — blocked +-- 3b: org-scoped row not in the list - blocked SELECT morbac.t('rls_check org_ids, org row not in list (blocked)', morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000002'::uuid), FALSE); --- 3c: NULL row, list WITHOUT null marker — filtered out even with a global grant +-- 3c: NULL row, list WITHOUT null marker - filtered out even with a global grant INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) VALUES ( '30000000-0000-0000-0000-000000000011', -- Karl @@ -123,7 +123,7 @@ SELECT morbac.t('rls_check org_ids without null marker, NULL row filtered out de morbac.rls_check('read', 'contracts', NULL), FALSE); --- 3c2: NULL row, list WITH null marker + global permission — allowed +-- 3c2: NULL row, list WITH null marker + global permission - allowed SET morbac.org_ids = '["10000000-0000-0000-0000-000000000001", null]'; SELECT morbac.t('rls_check org_ids with null marker, NULL row + global permission', @@ -167,7 +167,7 @@ RESET morbac.org_ids; SET morbac.user_id = '30000000-0000-0000-0000-000000000011'; -- Karl --- 4a: org-scoped row — uses row's org_id (Karl has user_rule in GlobalTech) +-- 4a: org-scoped row - uses row's org_id (Karl has user_rule in GlobalTech) SELECT morbac.t('rls_check no org context, org row: uses row org (Karl/documents/GlobalTech)', morbac.rls_check('read', 'documents', '10000000-0000-0000-0000-000000000001'::uuid), @@ -179,7 +179,7 @@ SELECT morbac.t('rls_check no org context, org row: no permission in row org (En '10000000-0000-0000-0000-000000000002'::uuid), FALSE); --- 4b: global row + global permission — now goes to is_allowed(Karl, NULL, ...) → global_rules only +-- 4b: NULL row + global permission - is_allowed(Karl, NULL, ...) sees unattributed + global rules INSERT INTO morbac.global_rules (user_id, activity, view, context_id, modality) VALUES ( '30000000-0000-0000-0000-000000000011', -- Karl