From f64cd7315922ea6f447434ef58c091d83ba7c627 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Wed, 1 Apr 2026 23:18:01 +0200 Subject: [PATCH] feat(rules): add rules for users --- src/auth_cache.sql | 25 ++++ src/authorization.sql | 47 ++++++- src/config.sql | 3 +- src/rls.sql | 53 ++++++-- src/system_rls.sql | 19 +++ src/user_rules.sql | 33 +++++ tests/12_user_rules.sql | 269 ++++++++++++++++++++++++++++++++++++++++ tools/build.sh | 1 + 8 files changed, 437 insertions(+), 13 deletions(-) create mode 100644 src/user_rules.sql create mode 100644 tests/12_user_rules.sql diff --git a/src/auth_cache.sql b/src/auth_cache.sql index e6115b8..846ff44 100644 --- a/src/auth_cache.sql +++ b/src/auth_cache.sql @@ -100,6 +100,31 @@ CREATE TRIGGER trg_invalidate_cache_cross_org AFTER INSERT OR UPDATE OR DELETE ON morbac.cross_org_rules FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); +CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_user_rule_change() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id UUID; + v_org_id UUID; +BEGIN + IF TG_OP = 'DELETE' THEN + v_user_id := OLD.user_id; + v_org_id := OLD.org_id; + ELSE + v_user_id := NEW.user_id; + v_org_id := NEW.org_id; + END IF; + DELETE FROM morbac.auth_cache WHERE user_id = v_user_id AND org_id = v_org_id; + RETURN COALESCE(NEW, OLD); +END; +$$; + +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(); + -- Refresh materialized hierarchy views when hierarchies change CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change() diff --git a/src/authorization.sql b/src/authorization.sql index f86a419..c032973 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -15,6 +15,7 @@ -- - rules.scope controls which orgs a rule covers (self/subtree/descendants/...). -- 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). CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( p_user_id UUID, @@ -70,7 +71,27 @@ BEGIN END IF; END LOOP; - -- STEP 3: Local permissions — find the highest-priority applicable one + -- STEP 3: User-level prohibitions — direct user rules, update max if higher + FOR v_rule IN + SELECT ur.context_id, COALESCE(ur.priority, 0) AS prio + FROM morbac.user_rules ur + WHERE ur.user_id = p_user_id + AND ur.org_id = p_org_id + AND ur.modality = 'prohibition' + AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) + AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) + ORDER BY COALESCE(ur.priority, 0) DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_prohibition_priority IS NULL OR v_rule.prio > v_max_prohibition_priority THEN + v_max_prohibition_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + -- STEP 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 @@ -88,7 +109,7 @@ BEGIN END IF; END LOOP; - -- STEP 4: 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 @@ -108,7 +129,27 @@ BEGIN END IF; END LOOP; - -- STEP 5: Priority resolution + -- 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 + WHERE ur.user_id = p_user_id + AND ur.org_id = p_org_id + AND ur.modality = 'permission' + AND ur.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity)) + AND ur.view IN (SELECT view FROM morbac.get_effective_views(p_view)) + AND morbac.is_rule_valid(ur.valid_from, ur.valid_until) + ORDER BY COALESCE(ur.priority, 0) DESC + LOOP + IF morbac.eval_context(v_rule.context_id) THEN + IF v_max_permission_priority IS NULL OR v_rule.prio > v_max_permission_priority THEN + v_max_permission_priority := v_rule.prio; + END IF; + EXIT; + END IF; + END LOOP; + + -- STEP 7: Priority resolution IF v_max_prohibition_priority IS NULL THEN RETURN v_max_permission_priority IS NOT NULL; END IF; diff --git a/src/config.sql b/src/config.sql index 677a6bf..aac2106 100644 --- a/src/config.sql +++ b/src/config.sql @@ -22,7 +22,8 @@ INSERT INTO morbac.config (key, value, description) VALUES ('system_view.activities', 'activities', 'View name for morbac.activities table access control'), ('system_view.views', 'views', 'View name for morbac.views table access control'), ('system_view.delegations', 'delegations', 'View name for morbac.delegations table access control'), - ('system_view.cross_org_rules', 'cross_org_rules', 'View name for morbac.cross_org_rules table access control'); + ('system_view.cross_org_rules', 'cross_org_rules', 'View name for morbac.cross_org_rules table access control'), + ('system_view.user_rules', 'user_rules', 'View name for morbac.user_rules table access control'); CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT) RETURNS TEXT diff --git a/src/rls.sql b/src/rls.sql index 569fdeb..0fc0ab3 100644 --- a/src/rls.sql +++ b/src/rls.sql @@ -48,6 +48,30 @@ $$; COMMENT ON FUNCTION morbac.current_org_id() IS 'Returns current organization ID from morbac.org_id session variable'; +CREATE OR REPLACE FUNCTION morbac.current_target_user_id() +RETURNS UUID +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_user_id TEXT; +BEGIN + v_user_id := current_setting('morbac.target_user_id', TRUE); + + IF v_user_id IS NULL OR v_user_id = '' THEN + RETURN NULL; + END IF; + + RETURN v_user_id::UUID; +EXCEPTION + WHEN OTHERS THEN + RETURN NULL; +END; +$$; + +COMMENT ON FUNCTION morbac.current_target_user_id() IS +'Returns target user ID filter from morbac.target_user_id session variable'; + -- Set via: SET morbac.org_ids = '["uuid1","uuid2"]' CREATE OR REPLACE FUNCTION morbac.current_org_ids() RETURNS UUID[] @@ -94,20 +118,23 @@ COMMENT ON FUNCTION morbac.get_user_orgs(UUID) IS 'Returns all org IDs the user has any direct role or active delegation in'; -- Org scoping: morbac.org_id (single) > morbac.org_ids (list) > all orgs. --- Pass the row org_id column to enable scoping: rls_check('read', 'docs', org_id) +-- User scoping: morbac.target_user_id filters rows to a specific user. +-- Pass row columns to enable scoping: rls_check('read', 'docs', org_id, user_id) CREATE OR REPLACE FUNCTION morbac.rls_check( - p_activity TEXT, - p_view TEXT, - p_row_org_id UUID DEFAULT NULL + p_activity TEXT, + p_view TEXT, + p_row_org_id UUID DEFAULT NULL, + p_row_user_id UUID DEFAULT NULL ) RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ DECLARE - v_user_id UUID; - v_org_id UUID; - v_org_ids UUID[]; + v_user_id UUID; + v_org_id UUID; + v_org_ids UUID[]; + v_target_user_id UUID; BEGIN v_user_id := morbac.current_user_id(); @@ -115,6 +142,14 @@ BEGIN RETURN FALSE; END IF; + -- User filter: if morbac.target_user_id is set, only rows matching that user pass + IF p_row_user_id IS NOT NULL THEN + v_target_user_id := morbac.current_target_user_id(); + IF v_target_user_id IS NOT NULL AND p_row_user_id <> v_target_user_id THEN + RETURN FALSE; + END IF; + END IF; + v_org_id := morbac.current_org_id(); IF v_org_id IS NOT NULL THEN @@ -141,5 +176,5 @@ BEGIN END; $$; -COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID) IS -'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id to enable org scoping (single org, org list, or all orgs).'; +COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) IS +'RLS helper: checks if current user is allowed to perform activity on view. Pass row org_id for org scoping (single org, org list, or all orgs). Pass row user_id to filter by morbac.target_user_id session variable.'; diff --git a/src/system_rls.sql b/src/system_rls.sql index 9773127..686b259 100644 --- a/src/system_rls.sql +++ b/src/system_rls.sql @@ -161,6 +161,25 @@ CREATE POLICY delegations_delete ON morbac.delegations FOR DELETE USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'delete', morbac.get_config('system_view.delegations'))); +-- morbac.user_rules +ALTER TABLE morbac.user_rules ENABLE ROW LEVEL SECURITY; + +CREATE POLICY user_rules_select ON morbac.user_rules FOR SELECT + USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'read', + morbac.get_config('system_view.user_rules'))); + +CREATE POLICY user_rules_insert ON morbac.user_rules FOR INSERT + WITH CHECK (morbac.is_allowed(morbac.current_user_id(), org_id, 'create', + morbac.get_config('system_view.user_rules'))); + +CREATE POLICY user_rules_update ON morbac.user_rules FOR UPDATE + USING (morbac.is_allowed(morbac.current_user_id(), org_id, 'update', + morbac.get_config('system_view.user_rules'))); + +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.cross_org_rules ALTER TABLE morbac.cross_org_rules ENABLE ROW LEVEL SECURITY; diff --git a/src/user_rules.sql b/src/user_rules.sql new file mode 100644 index 0000000..e518f07 --- /dev/null +++ b/src/user_rules.sql @@ -0,0 +1,33 @@ +-- Direct user-level rules: Rule(user, org, activity, view, context, modality) +-- +-- Grants or prohibits access for a specific user in an org, bypassing the role system. +-- Evaluated alongside regular rules and cross_org_rules in is_allowed_nocache(). +-- Priority resolution follows the same semantics: higher priority wins, ties go to prohibition. + +CREATE TABLE morbac.user_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, + activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, + view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, + context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, + modality morbac.modality NOT NULL, + priority INTEGER, + valid_from TIMESTAMPTZ, + valid_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + metadata JSONB DEFAULT '{}'::jsonb, + CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from), + UNIQUE(user_id, org_id, activity, view, context_id, modality) +); + +CREATE INDEX idx_user_rules_user_org ON morbac.user_rules(user_id, org_id); +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 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'; +COMMENT ON COLUMN morbac.user_rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; +COMMENT ON COLUMN morbac.user_rules.priority IS 'Optional priority (higher wins). NULL = 0. Follows same resolution as morbac.rules.'; diff --git a/tests/12_user_rules.sql b/tests/12_user_rules.sql new file mode 100644 index 0000000..652970a --- /dev/null +++ b/tests/12_user_rules.sql @@ -0,0 +1,269 @@ +-- ============================================================================= +-- User Rules Tests +-- ============================================================================= +-- Tests direct user-level rules via morbac.user_rules. +-- +-- Scenarios: +-- 1. No user rule: Karl (no role) is denied by default +-- 2. Direct user permission: Karl gets access without any role assignment +-- 3. User rule covers activity/view hierarchy +-- 4. User-level prohibition overrides a role-based permission +-- 5. Priority: user permission with higher priority overrides user prohibition +-- 6. Temporal user rules (valid_from / valid_until) +-- 7. rls_check user filter: morbac.target_user_id scopes rows to a specific user +-- +-- Prerequisites: 00_setup.sql -> 11_scope_rules.sql +-- ============================================================================= + +\echo '' +\echo '================================================================' +\echo '12 — USER RULES' +\echo '================================================================' + +-- --------------------------------------------------------------------------- +-- Section 1: No user rule — Karl (no role) is denied +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. No user rule: access denied ---' + +SELECT morbac.t('Karl (no role) reads GlobalTech documents [no user rule]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), FALSE); + +SELECT morbac.t('Karl (no role) reads GlobalTech financial_data [no user rule]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 2: Direct user permission — Karl gets access without a role +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Direct user permission ---' + +INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + '10000000-0000-0000-0000-000000000001', -- GlobalTech HQ + 'read', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('Karl (no role) reads GlobalTech documents [user rule grants access]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'documents' + ), TRUE); + +-- Rule only covers GlobalTech, not Engineering +SELECT morbac.t('Karl reads Engineering documents [no user rule for Engineering]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000002'::uuid, + 'read', 'documents' + ), FALSE); + +-- financial_data is a subtype of documents (view hierarchy), so the documents rule covers it +SELECT morbac.t('Karl reads GlobalTech financial_data [documents rule covers it via view hierarchy]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'financial_data' + ), TRUE); + +-- 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, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'contracts' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 3: User rule + activity/view hierarchy +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. User rule with activity/view hierarchy ---' + +-- Add a user rule for 'read reports' (parent of financial_data via view hierarchy) +INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + '10000000-0000-0000-0000-000000000001', -- GlobalTech HQ + 'read', 'reports', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +-- get_effective_activities('write') includes 'read', so read permission covers write requests +SELECT morbac.t('Karl writes GlobalTech documents [user rule read covers write via activity hierarchy]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'write', 'documents' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 4: User-level prohibition overrides role-based permission +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. User prohibition overrides role permission ---' + +-- Eve (intern) can read public_data via role +SELECT morbac.t('Eve (intern) reads GlobalTech public_data [role permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'public_data' + ), TRUE); + +-- Add a user prohibition for Eve on public_data +INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality) +VALUES ( + '30000000-0000-0000-0000-000000000005', -- Eve + '10000000-0000-0000-0000-000000000001', -- GlobalTech HQ + 'read', 'public_data', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'prohibition' +); + +SELECT morbac.t('Eve (intern) reads GlobalTech public_data [user prohibition blocks role permission]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'public_data' + ), FALSE); + +-- 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, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'public_data' + ), TRUE); + +-- --------------------------------------------------------------------------- +-- Section 5: Priority — higher-priority user permission overrides prohibition +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 5. Priority: user permission overrides user prohibition ---' + +-- Eve has a prohibition (priority 0) on public_data; add a higher-priority permission +INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, priority) +VALUES ( + '30000000-0000-0000-0000-000000000005', -- Eve + '10000000-0000-0000-0000-000000000001', -- GlobalTech HQ + 'read', 'public_data', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + 10 +); + +SELECT morbac.t('Eve reads GlobalTech public_data [priority-10 user permission beats priority-0 prohibition]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000005'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'public_data' + ), TRUE); + +-- Clean up the priority override for the temporal test +DELETE FROM morbac.user_rules +WHERE user_id = '30000000-0000-0000-0000-000000000005' + AND modality = 'permission' + AND priority = 10; + +-- --------------------------------------------------------------------------- +-- Section 6: Temporal user rules +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 6. Temporal user rules ---' + +-- Use contracts: Karl has no other rules covering it, so expiry is conclusive +INSERT INTO morbac.user_rules (user_id, org_id, activity, view, context_id, modality, valid_from, valid_until) +VALUES ( + '30000000-0000-0000-0000-000000000011', -- Karl + '10000000-0000-0000-0000-000000000001', -- GlobalTech HQ + 'read', 'contracts', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission', + now() - interval '1 hour', + now() + interval '1 day' +); + +SELECT morbac.t('Karl reads GlobalTech contracts [temporal user rule, active]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'contracts' + ), TRUE); + +-- Expire the rule +UPDATE morbac.user_rules +SET valid_until = now() - interval '1 second' +WHERE user_id = '30000000-0000-0000-0000-000000000011' + AND org_id = '10000000-0000-0000-0000-000000000001' + AND activity = 'read' AND view = 'contracts' + AND modality = 'permission'; + +SELECT morbac.t('Karl reads GlobalTech contracts [temporal user rule, expired]', + morbac.is_allowed_nocache( + '30000000-0000-0000-0000-000000000011'::uuid, + '10000000-0000-0000-0000-000000000001'::uuid, + 'read', 'contracts' + ), FALSE); + +-- --------------------------------------------------------------------------- +-- Section 7: rls_check user filter via morbac.target_user_id +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 7. rls_check user filter ---' + +SET morbac.user_id = '30000000-0000-0000-0000-000000000004'; -- Dave (employee) +SET morbac.org_id = '10000000-0000-0000-0000-000000000001'; -- GlobalTech HQ + +-- No target_user_id set: row with any user_id passes the user filter +SELECT morbac.t('rls_check passes without target_user_id filter', + morbac.rls_check( + 'read', 'documents', + '10000000-0000-0000-0000-000000000001'::uuid, + '30000000-0000-0000-0000-000000000004'::uuid + ), TRUE); + +-- 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', + morbac.rls_check( + 'read', 'documents', + '10000000-0000-0000-0000-000000000001'::uuid, + '30000000-0000-0000-0000-000000000004'::uuid + ), TRUE); + +-- Row belonging to Alice is filtered out +SELECT morbac.t('rls_check blocked when row user_id differs from target_user_id', + morbac.rls_check( + 'read', 'documents', + '10000000-0000-0000-0000-000000000001'::uuid, + '30000000-0000-0000-0000-000000000001'::uuid + ), FALSE); + +-- 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', + '10000000-0000-0000-0000-000000000001'::uuid + ), TRUE); + +RESET morbac.target_user_id; +RESET morbac.user_id; +RESET morbac.org_id; + +\echo '' +\echo '=== User Rules Tests Completed ===' diff --git a/tools/build.sh b/tools/build.sh index 683aedb..d5baf55 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -29,6 +29,7 @@ BUILD_FILES=( "contexts.sql" "rules.sql" "cross_org_rules.sql" + "user_rules.sql" "activity_view_bindings.sql" "obligations.sql" "audit.sql"