feat(activity): add binding to views
This commit is contained in:
@@ -80,10 +80,12 @@ erDiagram
|
|||||||
activities ||--o{ rules : "action"
|
activities ||--o{ rules : "action"
|
||||||
activities ||--o{ activity_hierarchy : "parent"
|
activities ||--o{ activity_hierarchy : "parent"
|
||||||
activities ||--o{ activity_hierarchy : "child"
|
activities ||--o{ activity_hierarchy : "child"
|
||||||
|
activities ||--o{ activity_view_bindings : "restricted_to"
|
||||||
|
|
||||||
views ||--o{ rules : "target"
|
views ||--o{ rules : "target"
|
||||||
views ||--o{ view_hierarchy : "parent"
|
views ||--o{ view_hierarchy : "parent"
|
||||||
views ||--o{ view_hierarchy : "child"
|
views ||--o{ view_hierarchy : "child"
|
||||||
|
views ||--o{ activity_view_bindings : "allowed_for"
|
||||||
|
|
||||||
contexts ||--o{ rules : "condition"
|
contexts ||--o{ rules : "condition"
|
||||||
|
|
||||||
@@ -199,6 +201,32 @@ Administration meta-policies for delegated management.
|
|||||||
|
|
||||||
**Behavior:** Enables organization-scoped administrators without database superuser privileges.
|
**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
|
## Core Concepts
|
||||||
|
|
||||||
### Deontic Modalities
|
### Deontic Modalities
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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 ==='
|
||||||
@@ -23,6 +23,7 @@ BUILD_FILES=(
|
|||||||
"organizations.sql"
|
"organizations.sql"
|
||||||
"activities.sql"
|
"activities.sql"
|
||||||
"views.sql"
|
"views.sql"
|
||||||
|
"activity_view_bindings.sql"
|
||||||
"roles.sql"
|
"roles.sql"
|
||||||
"hierarchy_functions.sql"
|
"hierarchy_functions.sql"
|
||||||
"materialized_views.sql"
|
"materialized_views.sql"
|
||||||
|
|||||||
Reference in New Issue
Block a user