11 KiB
Performance Optimization Guide
This guide explains the performance optimizations built into pgmorbac and how to use them effectively.
Performance Architecture
pgmorbac includes several layers of optimization:
- Materialized Views: Precomputed hierarchy transitive closures
- Authorization Cache: Configurable TTL cache for authorization decisions (default: 5 minutes)
- Composite Indexes: Optimized indexes for common query patterns
- Partial Indexes: Indexes only on active (temporally valid) rules
- Configurable Settings: All magic numbers centralized in morbac.config table
Initial Setup
After installing the extension, initialize the materialized views:
-- 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:
-- 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:
-- 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:
-- 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:
-- 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:
-- Run via cron or pg_cron every hour
SELECT morbac.cleanup_auth_cache();
Using pg_cron (PostgreSQL extension):
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:
-- 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:
-- 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:
-- 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:
-- 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:
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
-- 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
-- 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
-- Schedule hourly cleanup
SELECT cron.schedule('cleanup-auth-cache', '0 * * * *',
'SELECT morbac.cleanup_auth_cache()');
4. Monitor Cache Size
-- 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:
-- 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:
-- 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:
SELECT tgname, tgenabled
FROM pg_trigger
WHERE tgrelid = 'morbac.rules'::regclass;
Slow Authorization
- Check if materialized views are populated:
SELECT COUNT(*) FROM morbac.mv_role_closure;
SELECT COUNT(*) FROM morbac.mv_activity_closure;
- Refresh if empty:
SELECT morbac.refresh_hierarchy_cache();
- Check index usage:
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:
-- 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:
-- 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:
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, pgmorbac 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:
- Use
is_allowed()in production (cached by default) - Use
is_allowed_nocache()only for debugging - Keep materialized views refreshed
- Monitor cache hit rate
- Set up periodic cleanup