feat: add audit logs and temporal constraints

This commit is contained in:
2026-02-20 00:23:06 +01:00
parent 6d74edd570
commit 3696665892
7 changed files with 550 additions and 24 deletions
+15 -15
View File
@@ -41,7 +41,7 @@ FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant ability to manage policies -- Grant ability to manage policies
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id) INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT SELECT
o.id, o.id,
r.id, r.id,
'manage', 'manage',
@@ -54,7 +54,7 @@ WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Grant ability to manage roles -- Grant ability to manage roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id) INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT SELECT
o.id, o.id,
r.id, r.id,
'manage', 'manage',
@@ -67,7 +67,7 @@ WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Grant ability to assign ALL roles (wildcard) -- Grant ability to assign ALL roles (wildcard)
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id) INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT SELECT
o.id, o.id,
r.id, r.id,
'assign_role', 'assign_role',
@@ -80,7 +80,7 @@ WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
-- Assign someone as org admin -- Assign someone as org admin
INSERT INTO morbac.user_roles (user_id, role_id, org_id) INSERT INTO morbac.user_roles (user_id, role_id, org_id)
SELECT SELECT
'alice-uuid'::uuid, 'alice-uuid'::uuid,
r.id, r.id,
o.id o.id
@@ -101,7 +101,7 @@ FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant ability to assign specific roles -- Grant ability to assign specific roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id) INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT SELECT
o.id, o.id,
r.id, r.id,
'assign_role', 'assign_role',
@@ -115,7 +115,7 @@ WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
-- Prohibit assigning admin roles -- Prohibit assigning admin roles
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id) INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT SELECT
o.id, o.id,
r.id, r.id,
'assign_role', 'assign_role',
@@ -139,7 +139,7 @@ FROM morbac.orgs WHERE name = 'Acme Corp';
-- Grant policy management -- Grant policy management
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id) INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT SELECT
o.id, o.id,
r.id, r.id,
'manage', 'manage',
@@ -223,7 +223,7 @@ BEGIN
p_role_id, p_role_id,
p_org_id p_org_id
); );
v_result := json_build_object( v_result := json_build_object(
'success', true, 'success', true,
'message', 'Role assigned successfully' 'message', 'Role assigned successfully'
@@ -234,7 +234,7 @@ BEGIN
'error', SQLERRM 'error', SQLERRM
); );
END; END;
RETURN v_result; RETURN v_result;
END; END;
$$; $$;
@@ -290,7 +290,7 @@ INSERT INTO morbac.contexts (name, description, evaluator) VALUES
-- HR can only assign roles during business hours -- HR can only assign roles during business hours
INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id) INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, modality, context_id)
SELECT SELECT
o.id, o.id,
r.id, r.id,
'assign_role', 'assign_role',
@@ -318,8 +318,8 @@ WHERE o.name = 'Acme Corp' AND r.name = 'hr_manager';
INSERT INTO morbac.orgs (name) VALUES ('Acme Corp'); INSERT INTO morbac.orgs (name) VALUES ('Acme Corp');
-- 2. Create roles -- 2. Create roles
INSERT INTO morbac.roles (org_id, name) INSERT INTO morbac.roles (org_id, name)
SELECT id, name FROM morbac.orgs, SELECT id, name FROM morbac.orgs,
(VALUES ('org_admin'), ('hr_manager'), ('employee'), ('manager')) AS roles(name) (VALUES ('org_admin'), ('hr_manager'), ('employee'), ('manager')) AS roles(name)
WHERE morbac.orgs.name = 'Acme Corp'; WHERE morbac.orgs.name = 'Acme Corp';
@@ -328,7 +328,7 @@ INSERT INTO morbac.admin_rules (org_id, role_id, admin_activity, admin_target, m
SELECT o.id, r.id, activity, target, 'permission', c.id SELECT o.id, r.id, activity, target, 'permission', c.id
FROM morbac.orgs o FROM morbac.orgs o
JOIN morbac.roles r ON r.org_id = o.id JOIN morbac.roles r ON r.org_id = o.id
CROSS JOIN (VALUES CROSS JOIN (VALUES
('manage', 'policies'), ('manage', 'policies'),
('manage', 'roles'), ('manage', 'roles'),
('assign_role', '*') ('assign_role', '*')
@@ -356,7 +356,7 @@ WHERE o.name = 'Acme Corp' AND r.name = 'org_admin';
SELECT morbac.admin_assign_role( SELECT morbac.admin_assign_role(
'alice-uuid'::uuid, 'alice-uuid'::uuid,
'bob-uuid'::uuid, 'bob-uuid'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'hr_manager' AND org_id = (SELECT id FROM morbac.roles WHERE name = 'hr_manager' AND org_id =
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')), (SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')),
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp') (SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')
); );
@@ -365,7 +365,7 @@ SELECT morbac.admin_assign_role(
SELECT morbac.admin_assign_role( SELECT morbac.admin_assign_role(
'bob-uuid'::uuid, 'bob-uuid'::uuid,
'charlie-uuid'::uuid, 'charlie-uuid'::uuid,
(SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id = (SELECT id FROM morbac.roles WHERE name = 'employee' AND org_id =
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')), (SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')),
(SELECT id FROM morbac.orgs WHERE name = 'Acme Corp') (SELECT id FROM morbac.orgs WHERE name = 'Acme Corp')
); );
+9 -1
View File
@@ -21,12 +21,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Advanced features: - Advanced features:
- Organization, role, activity, and view hierarchies with transitive closure - Organization, role, activity, and view hierarchies with transitive closure
- Temporal delegation with time bounds - Temporal delegation with time bounds
- Temporal constraints on rules with validity periods (valid_from, valid_until)
- Negative role assignments - Negative role assignments
- Separation of Duty (SoD) constraints - Separation of Duty (SoD) constraints
- Role cardinality constraints (min/max users) - Role cardinality constraints (min/max users)
- Derived roles (computed via functions) - Derived roles (computed via functions)
- Cross-organizational rules - Cross-organizational rules with temporal support
- Administration rules (meta-policies) - Administration rules (meta-policies)
- Audit logging for security-critical operations
- Audit logging system:
- Generic audit trigger for tracking INSERT/UPDATE/DELETE operations
- Comprehensive audit log with JSONB support for before/after states
- Helper functions: `enable_audit()`, `disable_audit()`
- Field-level change tracking
- Client connection metadata capture (IP, application name, session user)
- Policy DSL with idempotent compiler - Policy DSL with idempotent compiler
- RLS helper functions for PostgREST integration - RLS helper functions for PostgREST integration
- Comprehensive test suite with 20 test scenarios - Comprehensive test suite with 20 test scenarios
+141
View File
@@ -446,6 +446,147 @@ VALUES (org_id, sec_admin_role_id, true);
SELECT morbac.is_admin_allowed(user_uuid, org_id, 'manage_policies'); SELECT morbac.is_admin_allowed(user_uuid, org_id, 'manage_policies');
``` ```
### Temporal Constraints
Rules and cross-organizational rules support optional temporal validity periods. Rules are only active within their specified time window.
**Adding temporal constraints to rules:**
```sql
-- Rule active only in 2024
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from, valid_until)
VALUES (org_id, contractor_role_id, 'read', 'projects', ctx_id, 'permission',
'2024-01-01 00:00:00+00', '2024-12-31 23:59:59+00');
-- Rule active starting from a specific date (no end date)
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from)
VALUES (org_id, employee_role_id, 'read', 'handbook', ctx_id, 'permission',
'2024-06-01 00:00:00+00');
-- Rule expires at a specific date
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_until)
VALUES (org_id, temp_role_id, 'write', 'reports', ctx_id, 'permission',
'2024-12-31 23:59:59+00');
```
**Temporal constraints on cross-org rules:**
```sql
INSERT INTO morbac.cross_org_rules (
source_org_id, target_org_id, role_id, activity, view, modality,
valid_from, valid_until
) VALUES (
parent_org_id, subsidiary_org_id, auditor_role_id,
'read', 'financial_data', 'permission',
'2024-01-01 00:00:00+00', '2024-03-31 23:59:59+00'
);
```
**Checking rule validity manually:**
```sql
SELECT morbac.is_rule_valid(valid_from, valid_until);
-- Returns: TRUE if current time is within bounds (or no constraints set)
-- FALSE if outside validity period
```
**Behavior:**
- Both `valid_from` and `valid_until` are optional (can be NULL)
- If both are NULL, rule is always active
- If only `valid_from` is set, rule is active from that time onwards
- If only `valid_until` is set, rule is active until that time
- If both are set, rule is active only during that window
- `is_allowed()` automatically checks temporal validity for all rules
- Expired rules are effectively inactive but remain in database for audit purposes
### Audit Logging
Comprehensive audit trail for security-critical operations. The audit system tracks all INSERT, UPDATE, and DELETE operations on specified tables.
**Audit log table structure:**
```sql
morbac.audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ, -- When change occurred
user_id UUID, -- User making change (if applicable)
org_id UUID, -- Organization context
table_name TEXT, -- Table being modified
operation TEXT, -- INSERT, UPDATE, DELETE
record_id UUID, -- ID of affected record
old_data JSONB, -- Record state before change (UPDATE/DELETE)
new_data JSONB, -- Record state after change (INSERT/UPDATE)
changed_fields TEXT[], -- List of modified columns (UPDATE)
session_user TEXT, -- PostgreSQL session user
client_addr INET, -- Client IP address
application_name TEXT, -- Application name from connection
metadata JSONB -- Additional custom metadata
)
```
**Enabling audit logging:**
```sql
-- Enable auditing on user_roles table
SELECT morbac.enable_audit('user_roles');
-- Enable auditing on multiple tables
SELECT morbac.enable_audit('rules');
SELECT morbac.enable_audit('delegations');
SELECT morbac.enable_audit('admin_rules');
```
**Disabling audit logging:**
```sql
SELECT morbac.disable_audit('user_roles');
```
**Querying audit log:**
```sql
-- Recent changes to user_roles
SELECT
timestamp,
operation,
session_user,
old_data->>'role_id' AS old_role,
new_data->>'role_id' AS new_role
FROM morbac.audit_log
WHERE table_name = 'user_roles'
ORDER BY timestamp DESC
LIMIT 20;
-- All changes by specific user
SELECT * FROM morbac.audit_log
WHERE user_id = '123e4567-e89b-12d3-a456-426614174000'
ORDER BY timestamp DESC;
-- Changes to specific record
SELECT * FROM morbac.audit_log
WHERE table_name = 'rules' AND record_id = rule_uuid
ORDER BY timestamp;
-- Detect field-level changes
SELECT
timestamp,
record_id,
changed_fields,
old_data,
new_data
FROM morbac.audit_log
WHERE table_name = 'rules'
AND operation = 'UPDATE'
AND 'modality' = ANY(changed_fields);
```
**Best practices:**
- Enable auditing on security-critical tables: `user_roles`, `rules`, `delegations`, `admin_rules`, `sod_conflicts`
- Create indexes on frequently queried columns: `CREATE INDEX ON morbac.audit_log(table_name, timestamp DESC)`
- Implement retention policies to archive old audit logs
- Use JSONB operators to query `old_data` and `new_data` efficiently
- Consider partitioning `audit_log` by timestamp for large installations
## API Reference ## API Reference
### Authorization Functions ### Authorization Functions
+16
View File
@@ -14,10 +14,12 @@ A PostgreSQL extension implementing the Multi-OrBAC access control model - enabl
- Activity and view hierarchies with transitive permission inheritance - Activity and view hierarchies with transitive permission inheritance
- Prohibition precedence over permissions - Prohibition precedence over permissions
- Temporal delegation with time bounds - Temporal delegation with time bounds
- Temporal constraints on rules with validity periods
- Separation of duty constraints - Separation of duty constraints
- Derived roles computed from application logic - Derived roles computed from application logic
- Cross-organizational policies - Cross-organizational policies
- Context-aware rules - Context-aware rules
- Audit logging for security-critical operations
- Row-level security integration - Row-level security integration
### Prerequisites ### Prerequisites
@@ -109,6 +111,11 @@ CREATE POLICY doc_access ON app.documents
### Advanced Features ### Advanced Features
```sql ```sql
-- Temporal rules: Rule active only during business hours or specific period
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from, valid_until)
VALUES (org_id, role_id, 'write', 'sensitive_data', ctx_id, 'permission',
'2024-01-01 00:00:00', '2024-12-31 23:59:59');
-- Delegation: Alice delegates role to Bob for 1 week -- Delegation: Alice delegates role to Bob for 1 week
INSERT INTO morbac.delegations (delegator_user_id, delegate_user_id, role_id, org_id, valid_until) INSERT INTO morbac.delegations (delegator_user_id, delegate_user_id, role_id, org_id, valid_until)
VALUES (alice_id, bob_id, role_id, org_id, NOW() + INTERVAL '7 days'); VALUES (alice_id, bob_id, role_id, org_id, NOW() + INTERVAL '7 days');
@@ -120,6 +127,15 @@ VALUES (preparer_role_id, approver_role_id, org_id);
-- Cross-Org: Global auditor can access subsidiary -- Cross-Org: Global auditor can access subsidiary
INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, modality) INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, modality)
VALUES (global_org_id, subsidiary_org_id, auditor_role_id, 'read', 'reports', 'permission'); VALUES (global_org_id, subsidiary_org_id, auditor_role_id, 'read', 'reports', 'permission');
-- Audit logging: Track changes to user roles
SELECT morbac.enable_audit('user_roles');
-- Query audit log
SELECT * FROM morbac.audit_log
WHERE table_name = 'user_roles'
ORDER BY timestamp DESC
LIMIT 10;
``` ```
## Testing ## Testing
+18 -2
View File
@@ -98,8 +98,24 @@ AS $$ ... $$;
### 4. Audit Critical Operations ### 4. Audit Critical Operations
```sql ```sql
-- Log policy changes and authorization decisions -- Enable audit logging on security-critical tables
-- (morbac_pg does not include audit logging by default) SELECT morbac.enable_audit('user_roles');
SELECT morbac.enable_audit('rules');
SELECT morbac.enable_audit('delegations');
-- Query audit log for security review
SELECT
timestamp,
operation,
table_name,
session_user,
client_addr,
old_data,
new_data
FROM morbac.audit_log
WHERE table_name = 'user_roles'
ORDER BY timestamp DESC
LIMIT 100;
``` ```
### 5. Regular Updates ### 5. Regular Updates
+229 -1
View File
@@ -369,15 +369,19 @@ CREATE TABLE morbac.rules (
view TEXT NOT NULL REFERENCES morbac.views(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, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL, modality morbac.modality NOT NULL,
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB DEFAULT '{}'::jsonb, metadata JSONB DEFAULT '{}'::jsonb,
UNIQUE(org_id, role_id, activity, view, context_id, modality) 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)
); );
CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id); 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_activity_view ON morbac.rules(activity, view);
CREATE INDEX idx_rules_modality ON morbac.rules(modality); 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_lookup ON morbac.rules(org_id, role_id, activity, view, modality);
CREATE INDEX idx_rules_validity ON morbac.rules(valid_from, valid_until);
COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
COMMENT ON COLUMN morbac.rules.org_id IS 'Organization scope'; COMMENT ON COLUMN morbac.rules.org_id IS 'Organization scope';
@@ -386,6 +390,42 @@ COMMENT ON COLUMN morbac.rules.activity IS 'Activity (abstract action)';
COMMENT ON COLUMN morbac.rules.view IS 'View (abstract object category)'; COMMENT ON COLUMN morbac.rules.view IS 'View (abstract object category)';
COMMENT ON COLUMN morbac.rules.context_id IS 'Context condition'; COMMENT ON COLUMN morbac.rules.context_id IS 'Context condition';
COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation'; COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
COMMENT ON COLUMN morbac.rules.valid_from IS 'Optional: Rule valid from this timestamp';
COMMENT ON COLUMN morbac.rules.valid_until IS 'Optional: Rule valid until this timestamp';
-- Helper function to check if a rule is currently valid
CREATE OR REPLACE FUNCTION morbac.is_rule_valid(
p_valid_from TIMESTAMPTZ,
p_valid_until TIMESTAMPTZ
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_now TIMESTAMPTZ := CURRENT_TIMESTAMP;
BEGIN
-- If no temporal constraints, rule is valid
IF p_valid_from IS NULL AND p_valid_until IS NULL THEN
RETURN TRUE;
END IF;
-- Check valid_from
IF p_valid_from IS NOT NULL AND v_now < p_valid_from THEN
RETURN FALSE;
END IF;
-- Check valid_until
IF p_valid_until IS NOT NULL AND v_now >= p_valid_until THEN
RETURN FALSE;
END IF;
RETURN TRUE;
END;
$$;
COMMENT ON FUNCTION morbac.is_rule_valid(TIMESTAMPTZ, TIMESTAMPTZ) IS
'Check if a rule is currently valid based on temporal constraints';
-- ============================================================================= -- =============================================================================
-- 10a. INTER-ORGANIZATIONAL RULES -- 10a. INTER-ORGANIZATIONAL RULES
@@ -403,17 +443,23 @@ CREATE TABLE morbac.cross_org_rules (
context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE, context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
modality morbac.modality NOT NULL, modality morbac.modality NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'::jsonb, metadata JSONB DEFAULT '{}'::jsonb,
CHECK (source_org_id != target_org_id), CHECK (source_org_id != target_org_id),
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
UNIQUE(source_org_id, target_org_id, role_id, activity, view, context_id, modality) UNIQUE(source_org_id, target_org_id, role_id, activity, view, context_id, modality)
); );
CREATE INDEX idx_cross_org_rules_source ON morbac.cross_org_rules(source_org_id, role_id); CREATE INDEX idx_cross_org_rules_source ON morbac.cross_org_rules(source_org_id, role_id);
CREATE INDEX idx_cross_org_rules_target ON morbac.cross_org_rules(target_org_id); CREATE INDEX idx_cross_org_rules_target ON morbac.cross_org_rules(target_org_id);
CREATE INDEX idx_cross_org_rules_temporal ON morbac.cross_org_rules(valid_from, valid_until);
COMMENT ON TABLE morbac.cross_org_rules IS 'Inter-organizational rules for cross-org access'; COMMENT ON TABLE morbac.cross_org_rules IS 'Inter-organizational rules for cross-org access';
COMMENT ON COLUMN morbac.cross_org_rules.source_org_id IS 'Organization where user has role'; COMMENT ON COLUMN morbac.cross_org_rules.source_org_id IS 'Organization where user has role';
COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Organization where resource resides'; COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Organization where resource resides';
COMMENT ON COLUMN morbac.cross_org_rules.valid_from IS 'Optional start time for rule validity';
COMMENT ON COLUMN morbac.cross_org_rules.valid_until IS 'Optional end time for rule validity';
-- ============================================================================= -- =============================================================================
-- 10b. ADMINISTRATION RULES -- 10b. ADMINISTRATION RULES
@@ -439,6 +485,179 @@ COMMENT ON TABLE morbac.admin_rules IS 'Administration rules - meta-policies for
COMMENT ON COLUMN morbac.admin_rules.admin_activity IS 'Admin action: create_rule, modify_rule, delete_rule, assign_role, etc.'; COMMENT ON COLUMN morbac.admin_rules.admin_activity IS 'Admin action: create_rule, modify_rule, delete_rule, assign_role, etc.';
COMMENT ON COLUMN morbac.admin_rules.admin_target IS 'What can be administered: rules, roles, users, orgs, etc.'; COMMENT ON COLUMN morbac.admin_rules.admin_target IS 'What can be administered: rules, roles, users, orgs, etc.';
-- =============================================================================
-- 10c. AUDIT LOG
-- =============================================================================
-- Optional audit logging for tracking changes to security-critical tables
-- Enable/disable per table with triggers
CREATE TABLE morbac.audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_id UUID,
org_id UUID,
table_name TEXT NOT NULL,
operation TEXT NOT NULL,
record_id UUID,
old_data JSONB,
new_data JSONB,
changed_fields TEXT[],
session_user TEXT DEFAULT SESSION_USER,
client_addr INET DEFAULT INET_CLIENT_ADDR(),
application_name TEXT DEFAULT CURRENT_SETTING('application_name', true),
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX idx_audit_log_timestamp ON morbac.audit_log(timestamp DESC);
CREATE INDEX idx_audit_log_user_id ON morbac.audit_log(user_id);
CREATE INDEX idx_audit_log_org_id ON morbac.audit_log(org_id);
CREATE INDEX idx_audit_log_table_operation ON morbac.audit_log(table_name, operation);
CREATE INDEX idx_audit_log_record_id ON morbac.audit_log(record_id);
COMMENT ON TABLE morbac.audit_log IS 'Audit trail for security-critical operations';
COMMENT ON COLUMN morbac.audit_log.user_id IS 'Application user (from morbac.current_user_id() if available)';
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.client_addr IS 'Client IP address';
-- Generic audit trigger function
CREATE OR REPLACE FUNCTION morbac.audit_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_old_data JSONB;
v_new_data JSONB;
v_changed_fields TEXT[];
v_user_id UUID;
v_org_id UUID;
v_record_id UUID;
BEGIN
-- Try to get current user/org context
BEGIN
v_user_id := morbac.current_user_id();
EXCEPTION WHEN OTHERS THEN
v_user_id := NULL;
END;
BEGIN
v_org_id := morbac.current_org_id();
EXCEPTION WHEN OTHERS THEN
v_org_id := NULL;
END;
-- Handle different operations
IF TG_OP = 'DELETE' THEN
v_old_data := row_to_json(OLD)::jsonb;
v_new_data := NULL;
v_record_id := (v_old_data->>'id')::uuid;
ELSIF TG_OP = 'INSERT' THEN
v_old_data := NULL;
v_new_data := row_to_json(NEW)::jsonb;
v_record_id := (v_new_data->>'id')::uuid;
ELSIF TG_OP = 'UPDATE' THEN
v_old_data := row_to_json(OLD)::jsonb;
v_new_data := row_to_json(NEW)::jsonb;
v_record_id := (v_new_data->>'id')::uuid;
-- Identify changed fields
SELECT array_agg(key)
INTO v_changed_fields
FROM jsonb_each(v_old_data)
WHERE v_old_data->key IS DISTINCT FROM v_new_data->key;
END IF;
-- Override org_id from record if available
IF v_org_id IS NULL THEN
IF v_new_data ? 'org_id' THEN
v_org_id := (v_new_data->>'org_id')::uuid;
ELSIF v_old_data ? 'org_id' THEN
v_org_id := (v_old_data->>'org_id')::uuid;
END IF;
END IF;
-- Insert audit record
INSERT INTO morbac.audit_log (
user_id,
org_id,
table_name,
operation,
record_id,
old_data,
new_data,
changed_fields
) VALUES (
v_user_id,
v_org_id,
TG_TABLE_NAME,
TG_OP,
v_record_id,
v_old_data,
v_new_data,
v_changed_fields
);
-- Return appropriate value
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$;
COMMENT ON FUNCTION morbac.audit_trigger() IS
'Generic audit trigger function - captures INSERT/UPDATE/DELETE operations';
-- Helper function to enable audit logging on a table
CREATE OR REPLACE FUNCTION morbac.enable_audit(p_table_name TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_trigger_name TEXT;
BEGIN
v_trigger_name := 'audit_' || p_table_name;
EXECUTE format(
'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON morbac.%I ' ||
'FOR EACH ROW EXECUTE FUNCTION morbac.audit_trigger()',
v_trigger_name,
p_table_name
);
RAISE NOTICE 'Audit logging enabled for morbac.%', p_table_name;
END;
$$;
COMMENT ON FUNCTION morbac.enable_audit(TEXT) IS
'Enable audit logging on a morbac table - creates audit trigger';
-- Helper function to disable audit logging on a table
CREATE OR REPLACE FUNCTION morbac.disable_audit(p_table_name TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_trigger_name TEXT;
BEGIN
v_trigger_name := 'audit_' || p_table_name;
EXECUTE format(
'DROP TRIGGER IF EXISTS %I ON morbac.%I',
v_trigger_name,
p_table_name
);
RAISE NOTICE 'Audit logging disabled for morbac.%', p_table_name;
END;
$$;
COMMENT ON FUNCTION morbac.disable_audit(TEXT) IS
'Disable audit logging on a morbac table - removes audit trigger';
-- ============================================================================= -- =============================================================================
-- 11. HIERARCHY FUNCTIONS -- 11. HIERARCHY FUNCTIONS
-- ============================================================================= -- =============================================================================
@@ -882,6 +1101,7 @@ BEGIN
-- - Activity hierarchy (senior activities imply junior) -- - Activity hierarchy (senior activities imply junior)
-- - View hierarchy (senior views imply junior) -- - View hierarchy (senior views imply junior)
-- - Comprehensive roles (direct, delegated, derived, inherited) -- - Comprehensive roles (direct, delegated, derived, inherited)
-- - Temporal validity (if rule has time constraints)
FOR v_rule IN FOR v_rule IN
SELECT r.context_id SELECT r.context_id
@@ -900,6 +1120,8 @@ BEGIN
AND r.role_id IN ( AND r.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
) )
-- Check temporal validity
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
LOOP LOOP
-- Evaluate context -- Evaluate context
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
@@ -920,6 +1142,8 @@ BEGIN
AND cr.role_id IN ( AND cr.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id) SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)
) )
-- Check temporal validity
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
RETURN FALSE; RETURN FALSE;
@@ -944,6 +1168,8 @@ BEGIN
AND r.role_id IN ( AND r.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id) SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
) )
-- Check temporal validity
AND morbac.is_rule_valid(r.valid_from, r.valid_until)
LOOP LOOP
-- Evaluate context -- Evaluate context
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
@@ -963,6 +1189,8 @@ BEGIN
AND cr.role_id IN ( AND cr.role_id IN (
SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id) SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)
) )
-- Check temporal validity
AND morbac.is_rule_valid(cr.valid_from, cr.valid_until)
LOOP LOOP
IF morbac.eval_context(v_rule.context_id) THEN IF morbac.eval_context(v_rule.context_id) THEN
RETURN TRUE; RETURN TRUE;
+122 -5
View File
@@ -127,7 +127,10 @@ INSERT INTO morbac.activities (name, description) VALUES
INSERT INTO morbac.views (name, description) VALUES INSERT INTO morbac.views (name, description) VALUES
('documents', 'Document resources'), ('documents', 'Document resources'),
('reports', 'Report resources'), ('reports', 'Report resources'),
('sensitive_data', 'Sensitive/confidential data'); ('sensitive_data', 'Sensitive/confidential data'),
('temp_documents', 'Temporary documents'),
('future_documents', 'Future documents'),
('active_documents', 'Active documents');
SELECT * FROM morbac.activities ORDER BY name; SELECT * FROM morbac.activities ORDER BY name;
SELECT * FROM morbac.views ORDER BY name; SELECT * FROM morbac.views ORDER BY name;
@@ -607,6 +610,122 @@ SELECT
'documents' 'documents'
) as allowed; ) as allowed;
\echo ''
\echo '=== Test 21: Temporal Constraints on Rules ==='
-- Create a time-limited rule (expires in the past)
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_until)
VALUES (
'11111111-1111-1111-1111-111111111111',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- employee role
'write',
'temp_documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
'2020-01-01 00:00:00+00' -- Expired rule
);
-- Create a future rule (not yet active)
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from)
VALUES (
'11111111-1111-1111-1111-111111111111',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'write',
'future_documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
'2030-01-01 00:00:00+00' -- Future rule
);
-- Create an active time-bounded rule
INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality, valid_from, valid_until)
VALUES (
'11111111-1111-1111-1111-111111111111',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'write',
'active_documents',
(SELECT id FROM morbac.contexts WHERE name = 'always'),
'permission',
'2020-01-01 00:00:00+00', -- Started in the past
'2030-12-31 23:59:59+00' -- Ends in the future
);
\echo 'Alice (employee) access to temporal-constrained documents:'
SELECT
'write to temp_documents (expired rule)' as action,
morbac.is_allowed(
'aaaaaaaa-0000-0000-0000-000000000001'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid,
'write',
'temp_documents'
) as allowed
UNION ALL
SELECT
'write to future_documents (not yet active)' as action,
morbac.is_allowed(
'aaaaaaaa-0000-0000-0000-000000000001'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid,
'write',
'future_documents'
) as allowed
UNION ALL
SELECT
'write to active_documents (currently valid)' as action,
morbac.is_allowed(
'aaaaaaaa-0000-0000-0000-000000000001'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid,
'write',
'active_documents'
) as allowed;
\echo ''
\echo '=== Test 22: Audit Logging ==='
-- Enable audit on user_roles table
SELECT morbac.enable_audit('user_roles');
\echo 'Audit trigger created for user_roles table'
-- Perform some auditable operations
INSERT INTO morbac.user_roles (user_id, role_id, org_id)
VALUES (
'ffffffff-0000-0000-0000-000000000001'::uuid,
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', -- employee role
'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
WHERE user_id = 'ffffffff-0000-0000-0000-000000000001'::uuid
AND role_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
\echo 'Audit log entries for user_roles operations:'
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,
session_user,
timestamp
FROM morbac.audit_log
WHERE table_name = 'user_roles'
ORDER BY timestamp;
\echo 'Disable audit on user_roles:'
SELECT morbac.disable_audit('user_roles');
\echo '' \echo ''
\echo '=== ALL TESTS COMPLETED SUCCESSFULLY ===' \echo '=== ALL TESTS COMPLETED SUCCESSFULLY ==='
\echo '' \echo ''
@@ -621,7 +740,5 @@ SELECT
\echo '- Cardinality constraints' \echo '- Cardinality constraints'
\echo '- Administration rules' \echo '- Administration rules'
\echo '- Cross-organizational rules' \echo '- Cross-organizational rules'
\echo '- Derived roles (framework ready)' \echo '- Temporal constraints on rules (valid_from, valid_until)'
\echo '- Complete authorization with all features' \echo '- Audit logging for security-critical operations'
\echo '- Prohibition precedence verified'
\echo '- Multi-organization support verified'