Permission Cache
Multi-level permission caching with local, Redis, and OpenFGA fallback for high-performance authorization checks.
Overview
Based on pkg/v2/infrastructure/permission/cache/, the permission cache provides:
- Three-Level Cache: Local (sync.Map) → Redis → OpenFGA
- Distributed Consistency: Event-driven cache invalidation across instances
- High Performance: Sub-millisecond local cache lookups
- Cache Statistics: Hit/miss tracking for monitoring
- Pattern-Based Invalidation: Wildcard pattern support for cache clearing
Architecture
Multi-Level Cache Strategy
Level 1: Local Cache (sync.Map)
├─ Fastest: In-memory, no network
├─ TTL: 1 second
└─ Scope: Single AppServer instance
Level 2: Redis Cache
├─ Fast: Distributed, network call
├─ TTL: 5 minutes
└─ Scope: All AppServer instances
Level 3: OpenFGA (Source of Truth)
├─ Slowest: Authorization service
├─ No TTL: Always authoritative
└─ Scope: Platform-wide
Cache Flow
Permission Check Request
↓
Check Local Cache
├─ HIT → Return immediately
└─ MISS ↓
Check Redis Cache
├─ HIT → Store in Local, Return
└─ MISS ↓
Query OpenFGA
├─ Result → Store in Redis and Local
└─ Return
Implementation
Based on cache.go:
MultiLevelCache Structure
type MultiLevelCache struct {
local *LocalCache
redis *RedisCache
checker *authz.PermissionChecker
stats *Stats
logger telemetry.Logger
}
Configuration
type Config struct {
RedisAddr string
RedisPassword string
RedisDB int
EnableRedis bool // Allow disabling for testing
}
cache, err := NewMultiLevelCache(checker, config, logger)
Cache Operations
CheckRouteAccess
Check if user can access a route:
allowed, err := cache.CheckRouteAccess(ctx, authCtx, routeID)
Cache Key Format:
{subject}:accessible_by:route:{routeID}
Examples:
user:123:accessible_by:route:550e8400-e29b-41d4-a716-446655440000
app:todos-app:accessible_by:route:550e8400-e29b-41d4-a716-446655440000
CheckPermission
Check if user has a specific permission:
allowed, err := cache.CheckPermission(ctx, authCtx, "todos:read")
Cache Key Format:
{subject}:granted_to:permission:{scope}
Examples:
user:123:granted_to:permission:todos:read
app:todos-app:granted_to:permission:users:read
CheckHookAccess
Check if user can access a hook:
allowed, err := cache.CheckHookAccess(ctx, authCtx, hookID, "trigger")
CheckActivityAccess
Check if user can access an activity:
allowed, err := cache.CheckActivityAccess(ctx, authCtx, activityID, "execute")
Local Cache
Based on local.go:
LocalCache Structure
type LocalCache struct {
cache sync.Map // key: string, value: CacheEntry
}
type CacheEntry struct {
Value bool
ExpiresAt time.Time
}
Configuration
- TTL: 1 second
- Implementation:
sync.Mapfor lock-free reads - Eviction: Lazy (on access) + periodic cleanup
Operations
// Get from local cache
result, found := local.Get(ctx, key)
// Set in local cache (1s TTL)
err := local.Set(ctx, key, allowed)
// Clear all entries
local.Clear()
// Delete by pattern
local.InvalidatePattern(pattern) // e.g., "user:123:*"
Redis Cache
Based on redis.go:
RedisCache Structure
type RedisCache struct {
client *redis.Client
ttl time.Duration // 5 minutes
}
Operations
// Get from Redis
result, found, err := redis.Get(ctx, key)
// Set in Redis (5min TTL)
err := redis.Set(ctx, key, allowed)
// Delete by pattern (SCAN + DEL)
err := redis.InvalidatePattern(ctx, "user:123:*")
Pattern Matching:
- Uses Redis SCAN for pattern-based deletion
- Supports wildcards:
user:*,*:route:123 - Atomic operations via pipelining
Cache Invalidation
Based on invalidation.go:
Event-Driven Invalidation
Subscribe to Invalidation Events:
eventBus.Subscribe("permission.invalidated", func(ctx context.Context, evt event.Event) error {
var payload event.PermissionInvalidatePayload
json.Unmarshal(evt.Payload(), &payload)
// Invalidate by subject
if payload.Subject != "" {
cache.Invalidate(ctx, payload.Subject)
}
// Invalidate by pattern
if payload.Pattern != "" {
cache.InvalidatePattern(ctx, payload.Pattern)
}
return nil
})
Invalidation Strategies
1. Subject-Based Invalidation:
// Invalidate all permissions for a user
cache.Invalidate(ctx, "user:123")
// Clears keys like:
// - user:123:accessible_by:route:*
// - user:123:granted_to:permission:*
2. Pattern-Based Invalidation:
// Invalidate all route access checks
cache.InvalidatePattern(ctx, "*:accessible_by:route:*")
// Invalidate specific app permissions
cache.InvalidatePattern(ctx, "app:todos-app:*")
3. Full Cache Clear:
// Clear entire cache (conservative approach)
cache.InvalidatePattern(ctx, "*")
Invalidation Triggers
App Uninstallation:
// Invalidate all permissions for uninstalled app
evt := event.NewPermissionInvalidateEvent("", fmt.Sprintf("app:%s:*", appName))
eventBus.Publish(ctx, evt)
Permission Changes:
// Invalidate specific user permissions
evt := event.NewPermissionInvalidateEvent(fmt.Sprintf("user:%s", userID), "")
eventBus.Publish(ctx, evt)
Cache Statistics
Based on stats.go:
Stats Structure
type Stats struct {
LocalHits atomic.Int64
RedisHits atomic.Int64
OpenFGAHits atomic.Int64
}
Metrics
stats := cache.GetStats()
fmt.Printf("Local Hits: %d\n", stats.LocalHits.Load())
fmt.Printf("Redis Hits: %d\n", stats.RedisHits.Load())
fmt.Printf("OpenFGA Hits: %d\n", stats.OpenFGAHits.Load())
// Calculate cache hit ratio
total := stats.LocalHits.Load() + stats.RedisHits.Load() + stats.OpenFGAHits.Load()
cacheHits := stats.LocalHits.Load() + stats.RedisHits.Load()
hitRatio := float64(cacheHits) / float64(total) * 100
Typical Ratios (production):
- Local Hits: ~90-95%
- Redis Hits: ~4-9%
- OpenFGA Hits: ~1-5%
Performance Characteristics
Latency
Local Cache:
- Latency: < 1ms
- Throughput: Millions of ops/sec
Redis Cache:
- Latency: 1-5ms (local network)
- Throughput: Thousands of ops/sec
OpenFGA:
- Latency: 10-50ms (depends on network and load)
- Throughput: Hundreds of ops/sec
Cache Efficiency
Cache Hit Rate:
- Target: > 95% combined (local + Redis)
- Typical: 95-99% in production
Memory Usage:
- Local: ~1-10MB per instance
- Redis: ~100MB-1GB shared across instances
Best Practices
Caching Strategy
✅ DO:
- Use pattern-based invalidation for related permissions
- Invalidate conservatively (clear entire cache if unsure)
- Monitor cache hit ratios
- Use short TTLs for frequently changing permissions
❌ DON'T:
- Cache negative results for too long
- Assume cache is always consistent
- Skip OpenFGA on cache hits (cache is optimization, not source of truth)
Invalidation
✅ DO:
- Publish invalidation events after permission changes
- Use specific patterns when possible (more efficient)
- Invalidate across all AppServer instances via events
❌ DON'T:
- Invalidate synchronously (use events for async invalidation)
- Over-invalidate (causes unnecessary OpenFGA queries)
- Under-invalidate (causes stale permission data)
Error Handling
✅ DO:
- Fall back to OpenFGA on cache errors
- Log cache failures
- Continue operation even if cache unavailable
❌ DON'T:
- Fail requests on cache errors
- Silently ignore cache errors
- Assume cache availability
Code References
| Component | File | Purpose |
|---|---|---|
| MultiLevelCache | cache.go | Main cache implementation |
| LocalCache | local.go | In-memory cache |
| RedisCache | redis.go | Distributed cache |
| Invalidation | invalidation.go | Cache invalidation logic |
| Stats | stats.go | Hit/miss tracking |
Related Topics
- Permission System - OpenFGA integration
- Event Bus - Invalidation events
- HTTP Proxy & Routing - Permission checks before proxying
- Platform Architecture - Infrastructure layer