Files
pgmorbac/docs/PERFORMANCE.md
T

429 lines
11 KiB
Markdown

# Performance Optimization Guide
This guide explains the performance optimizations built into morbac_pg and how to use them effectively.
## Performance Architecture
morbac_pg 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, morbac_pg 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