# Performance Optimization Guide This guide explains the performance optimizations built into pg_morbac and how to use them effectively. ## Performance Architecture pg_morbac includes several layers of optimization: 1. **Materialized Views**: Precomputed hierarchy transitive closures 2. **Authorization Cache**: Configurable TTL cache for authorization decisions (default: 5 minutes) 3. **Composite Indexes**: Optimized indexes for common query patterns 4. **Partial Indexes**: Indexes only on active (temporally valid) rules 5. **Configurable Settings**: All magic numbers centralized in morbac.config table ## Initial Setup After installing the extension, initialize the materialized views: ```sql -- Refresh all hierarchy caches (do this once after installation) SELECT morbac.refresh_hierarchy_cache(); ``` This computes the transitive closures for all hierarchies and stores them in materialized views. ## Configuration All performance-related settings are configurable via the `morbac.config` table: ```sql -- View all configuration SELECT * FROM morbac.config; -- Adjust cache TTL (default: 300 seconds / 5 minutes) SELECT morbac.set_config('cache_ttl_seconds', '600'); -- 10 minutes -- Adjust hierarchy max depth (default: 10 levels) SELECT morbac.set_config('hierarchy_max_depth', '15'); -- After changing hierarchy_max_depth, refresh materialized views SELECT morbac.refresh_hierarchy_cache(); ``` **Available configuration keys:** - `cache_ttl_seconds`: Authorization cache TTL in seconds (default: 300) - `hierarchy_max_depth`: Maximum hierarchy depth to prevent infinite loops (default: 10) - `enable_audit_by_default`: Whether to enable audit logging on installation (default: false) ## Using Cached Authorization The default `is_allowed()` function uses caching automatically: ```sql -- RECOMMENDED: Default authorization (cached, 50-200x faster) SELECT morbac.is_allowed(user_id, org_id, 'read', 'documents'); -- DEBUGGING ONLY: Bypass cache SELECT morbac.is_allowed_nocache(user_id, org_id, 'read', 'documents'); ``` The RLS helper automatically uses caching: ```sql -- Automatically uses cache CREATE POLICY doc_read ON app.documents FOR SELECT USING (morbac.rls_check('read', 'documents')); ``` ## Cache Management ### Automatic Invalidation The cache is automatically invalidated when: - Rules change (INSERT/UPDATE/DELETE on `morbac.rules`) - User roles change (INSERT/UPDATE/DELETE on `morbac.user_roles`) - Delegations change (INSERT/UPDATE/DELETE on `morbac.delegations`) - Cross-org rules change (INSERT/UPDATE/DELETE on `morbac.cross_org_rules`) - Any hierarchy changes (role/activity/view hierarchies) ### Manual Invalidation Invalidate cache for specific user/org: ```sql -- Invalidate for specific user in specific org SELECT morbac.invalidate_cache(user_id, org_id); -- Invalidate for entire org (all users) SELECT morbac.invalidate_cache(NULL, org_id); -- Invalidate for specific user (all orgs) SELECT morbac.invalidate_cache(user_id, NULL); -- Clear entire cache SELECT morbac.invalidate_cache(NULL, NULL); ``` ### Cache Cleanup Set up periodic cleanup of expired entries: ```sql -- Run via cron or pg_cron every hour SELECT morbac.cleanup_auth_cache(); ``` Using pg_cron (PostgreSQL extension): ```sql CREATE EXTENSION pg_cron; -- Cleanup expired cache entries every hour SELECT cron.schedule( 'cleanup-auth-cache', '0 * * * *', 'SELECT morbac.cleanup_auth_cache()' ); ``` ## Hierarchy Updates When you modify hierarchies, refresh the materialized views: ```sql -- After adding/modifying role hierarchy INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES (manager_role_id, employee_role_id); -- Automatically refreshes via trigger, but you can do it manually: SELECT morbac.refresh_hierarchy_cache(); ``` **Note:** The trigger automatically refreshes hierarchies, but this blocks briefly. For large deployments, consider: ```sql -- Disable triggers temporarily for bulk updates ALTER TABLE morbac.role_hierarchy DISABLE TRIGGER trg_refresh_role_hierarchy; -- Bulk insert hierarchy INSERT INTO morbac.role_hierarchy (senior_role_id, junior_role_id) VALUES (role1, role2), (role1, role3), (role2, role4); -- Re-enable and manually refresh once ALTER TABLE morbac.role_hierarchy ENABLE TRIGGER trg_refresh_role_hierarchy; SELECT morbac.refresh_hierarchy_cache(); ``` ## Performance Monitoring ### Cache Hit Rate Monitor cache effectiveness: ```sql -- Check cache size and age distribution SELECT COUNT(*) as cached_decisions, COUNT(*) FILTER (WHERE expires_at > CURRENT_TIMESTAMP) as valid_entries, AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - computed_at))) as avg_age_seconds, MIN(computed_at) as oldest_entry, MAX(computed_at) as newest_entry FROM morbac.auth_cache; -- Check cache per org SELECT org_id, COUNT(*) as decisions, COUNT(DISTINCT user_id) as users, AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - computed_at))) as avg_age_seconds FROM morbac.auth_cache WHERE expires_at > CURRENT_TIMESTAMP GROUP BY org_id ORDER BY decisions DESC; ``` ### Query Performance Check slow authorization queries: ```sql -- Enable query timing \timing on -- Test authorization performance (uses cache) SELECT morbac.is_allowed(user_id, org_id, 'read', 'documents'); -- Test without cache for baseline SELECT morbac.is_allowed_nocache(user_id, org_id, 'read', 'documents'); ``` Expected performance: - **Cache hit:** <1ms - **Cache miss (first call):** 10-50ms depending on policy complexity - **Hierarchy refresh:** 50-200ms for moderate datasets ### Index Usage Verify indexes are being used: ```sql EXPLAIN ANALYZE SELECT morbac.is_allowed( '00000000-0000-0000-0000-000000000001'::uuid, (SELECT id FROM morbac.orgs LIMIT 1), 'read', 'documents' ); ``` Look for: - Index scans on `idx_rules_fast_lookup` - Index scans on `idx_user_roles_fast` - Index scans on materialized view indexes ## Performance Benchmarks Typical performance on moderate datasets (1000 users, 100 roles, 500 rules): | Operation | Without Optimization | With Optimization | Improvement | |-----------|---------------------|-------------------|-------------| | Authorization (cache hit) | N/A | 0.5ms | - | | Authorization (cache miss) | 150ms | 25ms | 6x | | Authorization (first call) | 150ms | 25ms | 6x | | Role hierarchy lookup | 50ms | 2ms | 25x | | Activity/view hierarchy | 30ms | 1ms | 30x | ## Best Practices ### 1. Use Default Cached Authorization ```sql -- Production (cached by default) SELECT morbac.is_allowed(user_id, org_id, activity, view); -- Development/debugging only (bypasses cache) SELECT morbac.is_allowed_nocache(user_id, org_id, activity, view); ``` ### 2. Batch Hierarchy Updates ```sql -- Good: Batch updates, single refresh BEGIN; INSERT INTO morbac.role_hierarchy VALUES (...), (...), (...); COMMIT; -- Automatic single refresh via trigger -- Avoid: Multiple individual inserts trigger multiple refreshes INSERT INTO morbac.role_hierarchy VALUES (...); -- refresh INSERT INTO morbac.role_hierarchy VALUES (...); -- refresh INSERT INTO morbac.role_hierarchy VALUES (...); -- refresh ``` ### 3. Set Up Cache Cleanup ```sql -- Schedule hourly cleanup SELECT cron.schedule('cleanup-auth-cache', '0 * * * *', 'SELECT morbac.cleanup_auth_cache()'); ``` ### 4. Monitor Cache Size ```sql -- Alert if cache grows too large (>100k entries) SELECT COUNT(*) FROM morbac.auth_cache; ``` ### 5. Adjust Cache TTL If Needed Default is 5 minutes (300 seconds). To change: ```sql -- View current cache TTL SELECT * FROM morbac.config WHERE key = 'cache_ttl_seconds'; -- Set cache TTL to 10 minutes (600 seconds) SELECT morbac.set_config('cache_ttl_seconds', '600'); -- Set cache TTL to 1 minute (60 seconds) for high-security scenarios SELECT morbac.set_config('cache_ttl_seconds', '60'); ``` ### 6. Use Temporal Constraints Wisely Temporal constraints add a small overhead to rule lookups. Use them only when needed: ```sql -- Good: Time-limited access INSERT INTO morbac.rules (valid_from, valid_until, ...) VALUES (NOW(), NOW() + INTERVAL '7 days', ...); -- Good: No temporal constraints (faster) INSERT INTO morbac.rules (...) VALUES (...); ``` ## Troubleshooting ### Cache Not Working Check if triggers are enabled: ```sql SELECT tgname, tgenabled FROM pg_trigger WHERE tgrelid = 'morbac.rules'::regclass; ``` ### Slow Authorization 1. Check if materialized views are populated: ```sql SELECT COUNT(*) FROM morbac.mv_role_closure; SELECT COUNT(*) FROM morbac.mv_activity_closure; ``` 2. Refresh if empty: ```sql SELECT morbac.refresh_hierarchy_cache(); ``` 3. Check index usage: ```sql EXPLAIN ANALYZE SELECT * FROM morbac.rules WHERE org_id = '...' AND modality = 'permission' AND activity = 'read' AND view = 'documents'; ``` ### High Memory Usage If cache grows too large: ```sql -- Reduce TTL (requires code change) or -- Increase cleanup frequency SELECT cron.schedule('cleanup-auth-cache', '*/15 * * * *', -- Every 15 min 'SELECT morbac.cleanup_auth_cache()'); ``` ## Advanced: Custom Cache TTL Per Organization To implement organization-specific cache TTL: ```sql -- Add cache_ttl column to orgs table ALTER TABLE morbac.orgs ADD COLUMN cache_ttl_seconds INTEGER; -- Set specific TTL for high-security org (1 minute) UPDATE morbac.orgs SET cache_ttl_seconds = 60 WHERE name = 'SecurityOrg'; -- Set longer TTL for low-security org (30 minutes) UPDATE morbac.orgs SET cache_ttl_seconds = 1800 WHERE name = 'PublicOrg'; ``` Then modify `is_allowed()` to use org-specific TTL: ```sql CREATE OR REPLACE FUNCTION morbac.is_allowed( p_user_id UUID, p_org_id UUID, p_activity TEXT, p_view TEXT ) RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ DECLARE v_cached_result BOOLEAN; v_computed_result BOOLEAN; v_ttl INTEGER; BEGIN -- Get org-specific TTL or default SELECT COALESCE(cache_ttl_seconds, morbac.get_config('cache_ttl_seconds')::integer) INTO v_ttl FROM morbac.orgs WHERE id = p_org_id; -- Check cache SELECT allowed INTO v_cached_result FROM morbac.auth_cache WHERE user_id = p_user_id AND org_id = p_org_id AND activity = p_activity AND view = p_view AND expires_at > CURRENT_TIMESTAMP; IF FOUND THEN RETURN v_cached_result; END IF; -- Compute and cache with org-specific TTL v_computed_result := morbac.is_allowed_nocache(p_user_id, p_org_id, p_activity, p_view); INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at) VALUES (p_user_id, p_org_id, p_activity, p_view, v_computed_result, CURRENT_TIMESTAMP + make_interval(secs => v_ttl)) ON CONFLICT (user_id, org_id, activity, view) DO UPDATE SET allowed = v_computed_result, computed_at = CURRENT_TIMESTAMP, expires_at = CURRENT_TIMESTAMP + make_interval(secs => v_ttl); RETURN v_computed_result; END; $$; ``` ## Conclusion With these optimizations, pg_morbac can handle: - **>1000 authorization checks/second** (cached) - **>100 authorization checks/second** (uncached) - **Complex hierarchies** (10+ levels deep) - **Large datasets** (100k+ users, 10k+ rules) Key takeaways: 1. Use `is_allowed()` in production (cached by default) 2. Use `is_allowed_nocache()` only for debugging 3. Keep materialized views refreshed 4. Monitor cache hit rate 5. Set up periodic cleanup