Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions backend/pkg/server/auth/api_token_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type TokenCache struct {
cache sync.Map
ttl time.Duration
db *gorm.DB

storeMu sync.Mutex
epoch uint64
}

// NewTokenCache creates a new token cache instance
Expand Down Expand Up @@ -50,10 +53,14 @@ func (tc *TokenCache) GetStatus(tokenID string) (models.TokenStatus, []string, e
tc.cache.Delete(tokenID)
}

tc.storeMu.Lock()
epoch := tc.epoch
tc.storeMu.Unlock()

var token models.APIToken
if err := tc.db.Where("token_id = ? AND deleted_at IS NULL", tokenID).First(&token).Error; err != nil {
if gorm.IsRecordNotFoundError(err) {
tc.cache.Store(tokenID, tokenCacheEntry{
tc.storeIfCurrent(epoch, tokenID, tokenCacheEntry{
notFound: true,
expiresAt: time.Now().Add(tc.ttl),
})
Expand All @@ -75,7 +82,7 @@ func (tc *TokenCache) GetStatus(tokenID string) (models.TokenStatus, []string, e
// always add automation privilege for API tokens
privNames = append(privNames, PrivilegeAutomation)

tc.cache.Store(tokenID, tokenCacheEntry{
tc.storeIfCurrent(epoch, tokenID, tokenCacheEntry{
status: token.Status,
privileges: privNames,
notFound: false,
Expand All @@ -85,8 +92,23 @@ func (tc *TokenCache) GetStatus(tokenID string) (models.TokenStatus, []string, e
return token.Status, privNames, nil
}

// storeIfCurrent writes the entry only when no invalidation raced the DB read it
// came from, so a status read before a revocation is not cached past it.
func (tc *TokenCache) storeIfCurrent(epoch uint64, tokenID string, entry tokenCacheEntry) {
tc.storeMu.Lock()
defer tc.storeMu.Unlock()

if tc.epoch == epoch {
tc.cache.Store(tokenID, entry)
}
}

// Invalidate removes a specific token from cache
func (tc *TokenCache) Invalidate(tokenID string) {
tc.storeMu.Lock()
defer tc.storeMu.Unlock()

tc.epoch++
tc.cache.Delete(tokenID)
}

Expand All @@ -97,13 +119,21 @@ func (tc *TokenCache) InvalidateUser(userID uint64) {
return
}

tc.storeMu.Lock()
defer tc.storeMu.Unlock()

tc.epoch++
for _, token := range tokens {
tc.cache.Delete(token.TokenID)
}
}

// InvalidateAll clears the entire cache
func (tc *TokenCache) InvalidateAll() {
tc.storeMu.Lock()
defer tc.storeMu.Unlock()

tc.epoch++
tc.cache.Range(func(key, value any) bool {
tc.cache.Delete(key)
return true
Expand Down
24 changes: 24 additions & 0 deletions backend/pkg/server/auth/api_token_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,27 @@ func TestAPITokenCache_InvalidateUser_DropsEveryTokenOfTheUser(t *testing.T) {
assert.Equal(t, apiTokenCacheRevoked, apiTokenCacheGet(cache, tokenID), tokenID)
}
}

func TestAPITokenCache_GetStatus_DoesNotCacheAReadOverlappingAnInvalidation(t *testing.T) {
db := setupTestDB(t)
authStoreToken(t, db, "racetoken", 1, 2)
cache := NewTokenCache(db)

fired := false
db.Callback().Query().After("gorm:query").Register("test:revoke_mid_read", func(scope *gorm.Scope) {
if fired || scope.TableName() != "api_tokens" {
return
}
fired = true
require.NoError(t, db.Exec("UPDATE api_tokens SET status = 'revoked' WHERE token_id = 'racetoken'").Error)
cache.Invalidate("racetoken")
})

raced := apiTokenCacheGet(cache, "racetoken")
require.Equal(t, apiTokenCacheActive, raced)
require.True(t, fired, "the revocation has to land between the read and the store")

after := apiTokenCacheGet(cache, "racetoken")
assert.Equal(t, apiTokenCacheRevoked, after,
"a status read before a revocation must not be served for the cache's whole TTL")
}