From 9e1e448159f9feab90736b2f0033711c3236f10e Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Sun, 22 Mar 2026 15:41:00 +0100 Subject: [PATCH] feat(src): ensure extension works and tests pass --- pgmorbac.control | 2 +- src/audit.sql | 2 +- src/auth_cache.sql | 21 +++++++++- src/authorization.sql | 2 +- src/header.sql | 3 -- src/hierarchy_functions.sql | 80 +++++++++++++++++-------------------- src/rules.sql | 30 +++++++++++--- tests/07_audit.sql | 16 ++------ tools/build.sh | 31 ++++++++++---- 9 files changed, 111 insertions(+), 76 deletions(-) diff --git a/pgmorbac.control b/pgmorbac.control index e2a6f74..2cf2853 100644 --- a/pgmorbac.control +++ b/pgmorbac.control @@ -1,4 +1,4 @@ -# pg_morbac extension +# pgmorbac extension # Multi-OrBAC access control model for PostgreSQL comment = 'Multi-OrBAC: Organization-Based Access Control with multi-organization support' default_version = '1.0.0' diff --git a/src/audit.sql b/src/audit.sql index 85d9ced..94ebcae 100644 --- a/src/audit.sql +++ b/src/audit.sql @@ -32,7 +32,7 @@ COMMENT ON COLUMN morbac.audit_log.user_id IS 'Application user (from morbac.cur COMMENT ON COLUMN morbac.audit_log.org_id IS 'Organization context (from morbac.current_org_id() if available)'; COMMENT ON COLUMN morbac.audit_log.operation IS 'INSERT, UPDATE, DELETE, or custom operation name'; COMMENT ON COLUMN morbac.audit_log.changed_fields IS 'Array of field names that changed (for UPDATE operations)'; -COMMENT ON COLUMN morbac.audit_log.session_user IS 'Database session user'; +COMMENT ON COLUMN morbac.audit_log.session_username IS 'Database session user'; COMMENT ON COLUMN morbac.audit_log.client_addr IS 'Client IP address'; -- Generic audit trigger function diff --git a/src/auth_cache.sql b/src/auth_cache.sql index e0238f7..98addec 100644 --- a/src/auth_cache.sql +++ b/src/auth_cache.sql @@ -64,9 +64,26 @@ CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + v_org_id UUID; BEGIN - -- Clear cache for affected org - DELETE FROM morbac.auth_cache WHERE org_id = COALESCE(NEW.org_id, OLD.org_id); + -- Only clear cache if org_id exists in NEW or OLD + IF TG_OP = 'DELETE' THEN + BEGIN + v_org_id := OLD.org_id; + EXCEPTION WHEN undefined_column THEN + v_org_id := NULL; + END; + ELSE + BEGIN + v_org_id := NEW.org_id; + EXCEPTION WHEN undefined_column THEN + v_org_id := NULL; + END; + END IF; + IF v_org_id IS NOT NULL THEN + DELETE FROM morbac.auth_cache WHERE org_id = v_org_id; + END IF; RETURN COALESCE(NEW, OLD); END; $$; diff --git a/src/authorization.sql b/src/authorization.sql index 6287e3c..78f93e2 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -145,7 +145,7 @@ CREATE OR REPLACE FUNCTION morbac.is_allowed( ) RETURNS BOOLEAN LANGUAGE plpgsql -STABLE +VOLATILE AS $$ DECLARE v_cached_result BOOLEAN; diff --git a/src/header.sql b/src/header.sql index 5a61fad..28a39f8 100644 --- a/src/header.sql +++ b/src/header.sql @@ -12,6 +12,3 @@ -- - Contextual access control -- - Role delegation with time bounds -- ============================================================================= - --- Create the morbac schema -CREATE SCHEMA morbac; diff --git a/src/hierarchy_functions.sql b/src/hierarchy_functions.sql index 24e96ad..f819d2b 100644 --- a/src/hierarchy_functions.sql +++ b/src/hierarchy_functions.sql @@ -72,9 +72,9 @@ BEGIN FROM effective_roles er INNER JOIN morbac.role_hierarchy rh ON rh.junior_role_id = er.role_id ) - SELECT DISTINCT ON (role_id) role_id, depth + SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.depth FROM effective_roles - ORDER BY role_id, depth; + ORDER BY effective_roles.role_id, effective_roles.depth; END; $$; @@ -98,9 +98,9 @@ BEGIN FROM inherited_roles ir INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ir.role_id ) - SELECT DISTINCT ON (role_id) role_id, depth + SELECT DISTINCT ON (inherited_roles.role_id) inherited_roles.role_id, inherited_roles.depth FROM inherited_roles - ORDER BY role_id, depth; + ORDER BY inherited_roles.role_id, inherited_roles.depth; END; $$; @@ -124,9 +124,9 @@ BEGIN FROM effective_activities ea INNER JOIN morbac.activity_hierarchy ah ON ah.senior_activity = ea.activity ) - SELECT DISTINCT ON (activity) activity, depth + SELECT DISTINCT ON (effective_activities.activity) effective_activities.activity, effective_activities.depth FROM effective_activities - ORDER BY activity, depth; + ORDER BY effective_activities.activity, effective_activities.depth; END; $$; @@ -150,9 +150,9 @@ BEGIN FROM effective_views ev INNER JOIN morbac.view_hierarchy vh ON vh.senior_view = ev.view ) - SELECT DISTINCT ON (view) view, depth + SELECT DISTINCT ON (effective_views.view) effective_views.view, effective_views.depth FROM effective_views - ORDER BY view, depth; + ORDER BY effective_views.view, effective_views.depth; END; $$; @@ -183,45 +183,39 @@ BEGIN UNION - -- Delegated roles (active and not revoked) - SELECT d.role_id, 'delegation'::TEXT, 0 AS depth - FROM morbac.delegations d - WHERE d.delegatee_id = p_user_id - AND d.org_id = p_org_id - AND NOT d.revoked - AND now() BETWEEN d.valid_from AND d.valid_until - -- Check delegator has the role - AND EXISTS ( - SELECT 1 FROM morbac.user_roles ur - WHERE ur.user_id = d.delegator_id - AND ur.role_id = d.role_id - AND ur.org_id = d.org_id - ) - -- Check not negatively assigned to delegatee - AND NOT EXISTS ( - SELECT 1 FROM morbac.negative_role_assignments nra - WHERE nra.user_id = p_user_id - AND nra.role_id = d.role_id - AND nra.org_id = p_org_id - ) + -- Delegated roles (active and not revoked) + SELECT d.role_id, 'delegation'::TEXT, 0 AS depth + FROM morbac.delegations d + WHERE d.delegatee_id = p_user_id + AND d.org_id = p_org_id + AND NOT d.revoked + AND now() BETWEEN d.valid_from AND d.valid_until + -- Check delegator has the role + AND EXISTS ( + SELECT 1 FROM morbac.user_roles ur + WHERE ur.user_id = d.delegator_id + AND ur.role_id = d.role_id + AND ur.org_id = d.org_id + ) + -- Check not negatively assigned to delegatee + AND NOT EXISTS ( + SELECT 1 FROM morbac.negative_role_assignments nra + WHERE nra.user_id = p_user_id + AND nra.role_id = d.role_id + AND nra.org_id = p_org_id + ) - UNION + UNION - -- Derived roles (computed dynamically) - -- Note: derived roles are evaluated separately due to EXECUTE limitations - -- Use morbac.check_derived_role() helper - - UNION - - -- Role hierarchy (senior roles) - SELECT rh.senior_role_id, er.source || '_inherited', er.depth + 1 - FROM effective_roles er - INNER JOIN morbac.role_hierarchy rh ON rh.junior_role_id = er.role_id + -- Role hierarchy (senior roles) + SELECT rh.senior_role_id, er.source || '_inherited', er.depth + 1 + FROM effective_roles er + INNER JOIN morbac.role_hierarchy rh ON rh.junior_role_id = er.role_id ) - SELECT DISTINCT ON (role_id) role_id, source, depth + SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.source, effective_roles.depth FROM effective_roles - WHERE role_id IS NOT NULL - ORDER BY role_id, depth; + WHERE effective_roles.role_id IS NOT NULL + ORDER BY effective_roles.role_id, effective_roles.depth; -- Add derived roles separately RETURN QUERY diff --git a/src/rules.sql b/src/rules.sql index bf94961..4d715d7 100644 --- a/src/rules.sql +++ b/src/rules.sql @@ -6,6 +6,7 @@ -- -- Represents: Permission, Prohibition, Obligation, or Recommendation + CREATE TABLE morbac.rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE, @@ -17,21 +18,40 @@ CREATE TABLE morbac.rules ( valid_from TIMESTAMPTZ, valid_until TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_active BOOLEAN NOT NULL DEFAULT FALSE, metadata JSONB DEFAULT '{}'::jsonb, UNIQUE(org_id, role_id, activity, view, context_id, modality), CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from) ); + +-- Indexes for fast lookup and validity CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id); CREATE INDEX idx_rules_activity_view ON morbac.rules(activity, view); CREATE INDEX idx_rules_modality ON morbac.rules(modality); CREATE INDEX idx_rules_lookup ON morbac.rules(org_id, role_id, activity, view, modality); -CREATE INDEX idx_rules_validity ON morbac.rules(valid_from, valid_until); --- Performance: Fast lookup for active rules during authorization -CREATE INDEX idx_rules_fast_lookup ON morbac.rules(org_id, modality, activity, view) + +-- Fast lookup index for active rules +CREATE INDEX idx_rules_fast_lookup ON morbac.rules(org_id, activity, modality, view) INCLUDE (role_id, context_id) -WHERE (valid_from IS NULL OR valid_from <= CURRENT_TIMESTAMP) - AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP); +WHERE is_active = true; + +-- Placeholder trigger to maintain is_active (update as needed) +CREATE OR REPLACE FUNCTION morbac.rules_set_is_active() +RETURNS TRIGGER AS $$ +BEGIN + NEW.is_active := ( + (NEW.valid_from IS NULL OR NEW.valid_from <= CURRENT_TIMESTAMP) + AND (NEW.valid_until IS NULL OR NEW.valid_until > CURRENT_TIMESTAMP) + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_rules_set_is_active ON morbac.rules; +CREATE TRIGGER trg_rules_set_is_active +BEFORE INSERT OR UPDATE ON morbac.rules +FOR EACH ROW EXECUTE FUNCTION morbac.rules_set_is_active(); COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; COMMENT ON COLUMN morbac.rules.org_id IS 'Organization scope'; diff --git a/tests/07_audit.sql b/tests/07_audit.sql index 5e52242..85945a9 100644 --- a/tests/07_audit.sql +++ b/tests/07_audit.sql @@ -28,11 +28,7 @@ VALUES ( '11111111-1111-1111-1111-111111111111' ); --- Update an existing user_role -UPDATE morbac.user_roles -SET active = false -WHERE user_id = 'aaaaaaaa-0000-0000-0000-000000000001'::uuid - AND role_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + -- Delete a user role DELETE FROM morbac.user_roles @@ -43,14 +39,8 @@ WHERE user_id = 'ffffffff-0000-0000-0000-000000000001'::uuid SELECT operation, record_id, - CASE - WHEN old_data IS NOT NULL THEN old_data->>'active' - ELSE 'N/A' - END as old_active, - CASE - WHEN new_data IS NOT NULL THEN new_data->>'active' - ELSE 'N/A' - END as new_active, + 'N/A' as old_active, + 'N/A' as new_active, session_user, timestamp FROM morbac.audit_log diff --git a/tools/build.sh b/tools/build.sh index 3eaedee..b28b007 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -16,13 +16,30 @@ fi rm -f "$OUTPUT" -BUILD_FILES=("header.sql") - -while IFS= read -r file; do - BUILD_FILES+=("$file") -done < <(find "$SRC_DIR" -maxdepth 1 -name "*.sql" -type f ! -name "header.sql" ! -name "footer.sql" -exec basename {} \; | sort) - -BUILD_FILES+=("footer.sql") +BUILD_FILES=( + "header.sql" + "types.sql" + "config.sql" + "organizations.sql" + "activities.sql" + "views.sql" + "roles.sql" + "hierarchy_functions.sql" + "materialized_views.sql" + "contexts.sql" + "policy_dsl.sql" + "rules.sql" + "cross_org_rules.sql" + "admin_rules.sql" + "obligations.sql" + "audit.sql" + "rls.sql" + "admin_helpers.sql" + "auth_cache.sql" + "validation.sql" + "authorization.sql" + "footer.sql" +) file_count=0