From bfcbf1537999a1d433a8ad8d939b3571ca829517 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Sat, 28 Mar 2026 13:03:12 +0100 Subject: [PATCH] feat(activity): add binding to views --- .DS_Store | Bin 8196 -> 8196 bytes docs/DOCUMENTATION.md | 28 +++++ src/activity_view_bindings.sql | 40 +++++++ tests/10_activity_view_bindings.sql | 178 ++++++++++++++++++++++++++++ tools/build.sh | 1 + 5 files changed, 247 insertions(+) create mode 100644 src/activity_view_bindings.sql create mode 100644 tests/10_activity_view_bindings.sql diff --git a/.DS_Store b/.DS_Store index 8bfd930f9a2cfad05a88b89cad3c1eebe4ed4ce7..983e882156a6f3afa29a86b7e919f970b56d0ce6 100644 GIT binary patch delta 437 zcmZp1XmOa}FDl5uz`)4BAi%)j&fv=6!{863^)?nxW1rZ-yP2JXg@aLWa)p4kybyyk zP|gLYCLTz;0Z9Nmj{<5mXsDdB^E`4cp>@uIY}^fVp3RU zYI(eXh;x2kNn&PRY7tmNW=bkhNlbWVUP^wsQ+{b)N-Y0hGJlN zjyEZCk?UkHMMEE+7b?#+&D9|ZS zr;h%C zD!4!CY1@!WgoFF19<%pa*-3L96->O-%iaH(N9hhne`{sy_db}*PV*zw6{BC`2kW+5 A5C8xG diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index af713fa..57c827b 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -80,10 +80,12 @@ erDiagram activities ||--o{ rules : "action" activities ||--o{ activity_hierarchy : "parent" activities ||--o{ activity_hierarchy : "child" + activities ||--o{ activity_view_bindings : "restricted_to" views ||--o{ rules : "target" views ||--o{ view_hierarchy : "parent" views ||--o{ view_hierarchy : "child" + views ||--o{ activity_view_bindings : "allowed_for" contexts ||--o{ rules : "condition" @@ -199,6 +201,32 @@ Administration meta-policies for delegated management. **Behavior:** Enables organization-scoped administrators without database superuser privileges. +**morbac.activity_view_bindings** + +Optional whitelist of valid (activity, view) pairs for rule creation. + +**Key columns:** +- `activity`: Activity to restrict +- `view`: Permitted view for that activity + +**Behavior:** Opt-in per activity. If any binding exists for an activity, rules (including cross-org rules) can only use listed views. Activities with no bindings are unconstrained. Enforced at the database level via triggers on `morbac.rules` and `morbac.cross_org_rules`. + +```sql +-- Restrict 'approve' to invoices and contracts only +INSERT INTO morbac.activity_view_bindings (activity, view) VALUES + ('approve', 'invoices'), + ('approve', 'contracts'); + +-- This succeeds +INSERT INTO morbac.rules (..., activity, view, ...) VALUES (..., 'approve', 'invoices', ...); + +-- This raises an exception +INSERT INTO morbac.rules (..., activity, view, ...) VALUES (..., 'approve', 'user_profiles', ...); + +-- Remove all bindings to lift the restriction +DELETE FROM morbac.activity_view_bindings WHERE activity = 'approve'; +``` + ## Core Concepts ### Deontic Modalities diff --git a/src/activity_view_bindings.sql b/src/activity_view_bindings.sql new file mode 100644 index 0000000..b030962 --- /dev/null +++ b/src/activity_view_bindings.sql @@ -0,0 +1,40 @@ +-- Optional whitelist of valid (activity, view) pairs. +-- If any binding exists for an activity, rules may only use listed views. +-- Activities with no bindings are unconstrained. + +CREATE TABLE morbac.activity_view_bindings ( + activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE, + view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE, + PRIMARY KEY (activity, view) +); + +COMMENT ON TABLE morbac.activity_view_bindings IS +'Optional whitelist of valid (activity, view) pairs. Constrains rule creation on a per-activity basis.'; + +CREATE OR REPLACE FUNCTION morbac.trg_check_activity_view_binding() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +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', + NEW.activity, NEW.view; + END IF; + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION morbac.trg_check_activity_view_binding() IS +'Blocks rule creation when an activity has bindings but the target view is not among them.'; + +DROP TRIGGER IF EXISTS trg_activity_view_binding_check ON morbac.rules; +CREATE TRIGGER trg_activity_view_binding_check +BEFORE INSERT OR UPDATE ON morbac.rules +FOR EACH ROW EXECUTE FUNCTION morbac.trg_check_activity_view_binding(); + +DROP TRIGGER IF EXISTS trg_activity_view_binding_check ON morbac.cross_org_rules; +CREATE TRIGGER trg_activity_view_binding_check +BEFORE INSERT OR UPDATE ON morbac.cross_org_rules +FOR EACH ROW EXECUTE FUNCTION morbac.trg_check_activity_view_binding(); diff --git a/tests/10_activity_view_bindings.sql b/tests/10_activity_view_bindings.sql new file mode 100644 index 0000000..7b8a170 --- /dev/null +++ b/tests/10_activity_view_bindings.sql @@ -0,0 +1,178 @@ +-- ============================================================================= +-- 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 +-- 3. Blocking applies to cross_org_rules as well +-- 4. Removing all bindings lifts the restriction +-- +-- Prerequisites: 00_setup.sql -> 09_utilities.sql +-- ============================================================================= + +\echo '' +\echo '================================================================' +\echo '10 — ACTIVITY-VIEW BINDINGS' +\echo '================================================================' + +-- --------------------------------------------------------------------------- +-- Section 1: No bindings — unconstrained +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 1. No bindings: any view is allowed ---' + +-- With no bindings, inserting a rule succeeds for any activity/view pair +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'b0000000-0000-0000-0000-000000000001', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'audit', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('No bindings: audit/documents rule inserted successfully', + EXISTS(SELECT 1 FROM morbac.rules WHERE id = 'b0000000-0000-0000-0000-000000000001'), + TRUE); + +DELETE FROM morbac.rules WHERE id = 'b0000000-0000-0000-0000-000000000001'; + +-- --------------------------------------------------------------------------- +-- Section 2: Binding defined — listed view allowed, unlisted view blocked +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 2. Binding defined: allowed view works, unlisted view blocked ---' + +-- Restrict 'audit' activity to only 'audit_logs' +INSERT INTO morbac.activity_view_bindings (activity, view) VALUES ('audit', 'audit_logs'); + +SELECT morbac.t_eq('Binding for audit/audit_logs created', + (SELECT COUNT(*) FROM morbac.activity_view_bindings WHERE activity = 'audit')::bigint, + 1); + +-- Inserting a rule with the listed view succeeds +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'b0000000-0000-0000-0000-000000000002', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', -- employee + 'audit', 'audit_logs', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('Binding allows audit/audit_logs rule', + EXISTS(SELECT 1 FROM morbac.rules WHERE id = 'b0000000-0000-0000-0000-000000000002'), + TRUE); + +-- Inserting a rule with an unlisted view is blocked +DO $$ +BEGIN + INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality) + VALUES ( + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'audit', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' + ); + RAISE NOTICE 'CHECK FAIL: Binding should have blocked audit/documents rule'; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'CHECK PASS: Binding blocks audit/documents rule (unlisted view)'; +END; +$$; + +-- Other activities without bindings are unaffected +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'b0000000-0000-0000-0000-000000000003', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'manage', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('Unbound activity (manage) still works with any view', + EXISTS(SELECT 1 FROM morbac.rules WHERE id = 'b0000000-0000-0000-0000-000000000003'), + TRUE); + +-- --------------------------------------------------------------------------- +-- Section 3: Applies to cross_org_rules as well +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 3. Binding enforced on cross_org_rules ---' + +DO $$ +BEGIN + INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, context_id, modality) + VALUES ( + '10000000-0000-0000-0000-000000000001', + '10000000-0000-0000-0000-000000000002', + '20000000-0001-0000-0000-000000000004', + 'audit', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' + ); + RAISE NOTICE 'CHECK FAIL: Binding should have blocked cross_org_rule with unlisted view'; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'CHECK PASS: Binding blocks cross_org_rule with unlisted view'; +END; +$$; + +-- Cross-org rule with the bound view succeeds +INSERT INTO morbac.cross_org_rules (id, source_org_id, target_org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'b0000000-0000-0000-0000-000000000004', + '10000000-0000-0000-0000-000000000001', + '10000000-0000-0000-0000-000000000002', + '20000000-0001-0000-0000-000000000004', + 'audit', 'audit_logs', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('Binding allows cross_org_rule with listed view', + EXISTS(SELECT 1 FROM morbac.cross_org_rules WHERE id = 'b0000000-0000-0000-0000-000000000004'), + TRUE); + +-- --------------------------------------------------------------------------- +-- Section 4: Removing all bindings lifts the restriction +-- --------------------------------------------------------------------------- +\echo '' +\echo '--- 4. Removing all bindings lifts the restriction ---' + +DELETE FROM morbac.activity_view_bindings WHERE activity = 'audit'; + +SELECT morbac.t_eq('All audit bindings removed', + (SELECT COUNT(*) FROM morbac.activity_view_bindings WHERE activity = 'audit')::bigint, + 0); + +-- Now audit/documents should be insertable again +INSERT INTO morbac.rules (id, org_id, role_id, activity, view, context_id, modality) +VALUES ( + 'b0000000-0000-0000-0000-000000000005', + '10000000-0000-0000-0000-000000000001', + '20000000-0001-0000-0000-000000000004', + 'audit', 'documents', + (SELECT id FROM morbac.contexts WHERE name = 'always'), + 'permission' +); + +SELECT morbac.t('Removing bindings: audit/documents rule now allowed', + EXISTS(SELECT 1 FROM morbac.rules WHERE id = 'b0000000-0000-0000-0000-000000000005'), + TRUE); + +-- Clean up +DELETE FROM morbac.rules +WHERE id IN ( + 'b0000000-0000-0000-0000-000000000002', + 'b0000000-0000-0000-0000-000000000003', + 'b0000000-0000-0000-0000-000000000005' +); + +DELETE FROM morbac.cross_org_rules WHERE id = 'b0000000-0000-0000-0000-000000000004'; + +\echo '' +\echo '=== Activity-View Binding Tests Completed ===' diff --git a/tools/build.sh b/tools/build.sh index b28b007..2db3106 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -23,6 +23,7 @@ BUILD_FILES=( "organizations.sql" "activities.sql" "views.sql" + "activity_view_bindings.sql" "roles.sql" "hierarchy_functions.sql" "materialized_views.sql"