mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
75c570e951
- Secure CLI credential injection via AES-256-GCM encrypted env vars - API key management with fine-grained RBAC scopes - resolveAuth/requireAuth middleware across all 25+ HTTP handlers - In-memory API key cache with TTL, negative caching, pubsub invalidation - Sandbox-first execution (fails if unavailable, no silent fallback) - Credential scrubbing, constant-time token comparison, Admin-only CLI creds - SQL migration 000020: secure_cli_binaries + api_keys tables - 14 unit tests for cache and RBAC with race detector Closes #197
99 lines
2.6 KiB
Go
99 lines
2.6 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// cacheEntry holds a cached API key lookup result.
|
|
type cacheEntry struct {
|
|
key *store.APIKeyData // nil = negative cache (key not found)
|
|
role permissions.Role
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
// maxNegativeCacheEntries caps the number of negative cache entries to prevent
|
|
// memory exhaustion from token spraying attacks.
|
|
const maxNegativeCacheEntries = 10000
|
|
|
|
// apiKeyCache is a TTL cache for API key lookups, invalidated via pubsub.
|
|
type apiKeyCache struct {
|
|
mu sync.RWMutex
|
|
entries map[string]*cacheEntry // keyed by SHA-256 hash
|
|
ttl time.Duration
|
|
store store.APIKeyStore
|
|
}
|
|
|
|
func newAPIKeyCache(s store.APIKeyStore, ttl time.Duration) *apiKeyCache {
|
|
return &apiKeyCache{
|
|
entries: make(map[string]*cacheEntry),
|
|
ttl: ttl,
|
|
store: s,
|
|
}
|
|
}
|
|
|
|
// get returns a cached entry if it exists and is not expired.
|
|
func (c *apiKeyCache) get(hash string) (*store.APIKeyData, permissions.Role, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
entry, ok := c.entries[hash]
|
|
if !ok || time.Since(entry.fetchedAt) > c.ttl {
|
|
return nil, "", false
|
|
}
|
|
return entry.key, entry.role, true
|
|
}
|
|
|
|
// getOrFetch returns a cached entry or fetches from the store on cache miss.
|
|
func (c *apiKeyCache) getOrFetch(ctx context.Context, hash string) (*store.APIKeyData, permissions.Role) {
|
|
if key, role, ok := c.get(hash); ok {
|
|
return key, role
|
|
}
|
|
|
|
// Cache miss — fetch from DB
|
|
keyData, err := c.store.GetByHash(ctx, hash)
|
|
if err != nil || keyData == nil {
|
|
// Negative cache: avoid repeated DB misses for invalid tokens.
|
|
// Cap size to prevent memory exhaustion from token spraying.
|
|
c.mu.Lock()
|
|
if len(c.entries) < maxNegativeCacheEntries {
|
|
c.entries[hash] = &cacheEntry{fetchedAt: time.Now()}
|
|
}
|
|
c.mu.Unlock()
|
|
return nil, ""
|
|
}
|
|
|
|
scopes := make([]permissions.Scope, len(keyData.Scopes))
|
|
for i, s := range keyData.Scopes {
|
|
scopes[i] = permissions.Scope(s)
|
|
}
|
|
role := permissions.RoleFromScopes(scopes)
|
|
|
|
c.mu.Lock()
|
|
c.entries[hash] = &cacheEntry{
|
|
key: keyData,
|
|
role: role,
|
|
fetchedAt: time.Now(),
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
// Touch last-used in background (fire-and-forget with timeout)
|
|
go func() {
|
|
tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
c.store.TouchLastUsed(tctx, keyData.ID)
|
|
}()
|
|
|
|
return keyData, role
|
|
}
|
|
|
|
// invalidateAll clears all cached entries. Called on pubsub cache.invalidate events.
|
|
func (c *apiKeyCache) invalidateAll() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.entries = make(map[string]*cacheEntry)
|
|
}
|