From ea9c1d62f2e3ed372dabf19a173e1ac78c7f1a29 Mon Sep 17 00:00:00 2001 From: Jihyeon Gim Date: Sat, 29 Aug 2026 16:38:57 +0900 Subject: [PATCH] auth: add GetUserAccountFresh to bypass the IAM cache The cached GetUserAccount keeps serving a stale entry for the cache TTL after the backing IAM service changes, which delays credential revocation by up to the configured expiry. Add GetUserAccountFresh to the IAM cache, which reads directly from the underlying service and refreshes the cached entry with the result. Callers that need revocations to take effect immediately can use it instead of the cached path. Signed-off-by: Jihyeon Gim --- auth/iam_cache.go | 15 +++++++++ auth/iam_cache_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 auth/iam_cache_test.go diff --git a/auth/iam_cache.go b/auth/iam_cache.go index 35e67022..e1e68f1e 100644 --- a/auth/iam_cache.go +++ b/auth/iam_cache.go @@ -177,6 +177,21 @@ func (c *IAMCache) ResolveAccounts(accessKeyIDs []string) ([]string, error) { return resolveAccountsByLookup(accessKeyIDs, c.GetUserAccount) } +// GetUserAccountFresh bypasses the in-memory cache and fetches the +// account directly from the underlying IAM service, then refreshes +// the cached entry with the result. This gives callers that need +// revocation to take effect immediately (rather than after the +// cache TTL) a way to observe the backing store state. +func (c *IAMCache) GetUserAccountFresh(access string) (Account, error) { + a, err := c.service.GetUserAccount(access) + if err != nil { + return Account{}, err + } + + c.iamcache.set(access, a) + return a, nil +} + // DeleteUserAccount deletes account from IAM service and cache func (c *IAMCache) DeleteUserAccount(access string) error { err := c.service.DeleteUserAccount(access) diff --git a/auth/iam_cache_test.go b/auth/iam_cache_test.go new file mode 100644 index 00000000..74cbc4fb --- /dev/null +++ b/auth/iam_cache_test.go @@ -0,0 +1,70 @@ +package auth + +import ( + "testing" + "time" +) + +type freshTestService struct { + IAMService + accounts map[string]Account + fetches int +} + +func (s *freshTestService) GetUserAccount(access string) (Account, error) { + s.fetches++ + a, ok := s.accounts[access] + if !ok { + return Account{}, ErrNoSuchUser + } + return a, nil +} + +// GetUserAccountFresh must bypass a cached-but-stale entry and +// reflect the backing service state, refreshing the cache. +func TestIAMCacheGetUserAccountFresh(t *testing.T) { + svc := &freshTestService{accounts: map[string]Account{ + "acct": {Access: "acct", Secret: "old", Role: RoleUser}, + }} + c := NewCache(svc, time.Minute, time.Hour) + defer c.Shutdown() + + // Prime the cache with the old secret. + if _, err := c.GetUserAccount("acct"); err != nil { + t.Fatalf("prime: %v", err) + } + + // Change the backing store behind the cache. + svc.accounts["acct"] = Account{Access: "acct", Secret: "new", Role: RoleAdmin} + + // Cached read still serves the stale entry. + got, err := c.GetUserAccount("acct") + if err != nil { + t.Fatalf("cached read: %v", err) + } + if got.Secret != "old" { + t.Fatalf("cached read: expected stale secret, got %q", got.Secret) + } + + // Fresh read must observe the update. + got, err = c.GetUserAccountFresh("acct") + if err != nil { + t.Fatalf("fresh read: %v", err) + } + if got.Secret != "new" || got.Role != RoleAdmin { + t.Fatalf("fresh read: got %+v", got) + } + + // And the cache is refreshed as a side effect. + got, err = c.GetUserAccount("acct") + if err != nil { + t.Fatalf("post-fresh cached read: %v", err) + } + if got.Secret != "new" { + t.Fatalf("post-fresh cached read: cache not refreshed, got %q", got.Secret) + } + + if svc.fetches != 2 { + t.Fatalf("expected 2 backing fetches (prime + fresh), got %d", svc.fetches) + } +}