feat: add audit logs and temporal constraints
This commit is contained in:
+9
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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'
|
|
||||||
|
|||||||
Reference in New Issue
Block a user