Skip to content

Commit 218819c

Browse files
fix(lockdown): scope repo-access cache per identity via entry keys
Isolating identities by deriving a cache2go table name per token grew a process-wide registry that is never reclaimed: cache2go creates each named table on first use and never evicts it, so every distinct bearer token — including invalid ones, since the table was built before GitHub validated the token — permanently added a table. Keep a single cache table and scope entries instead. WithIdentity stores a SHA-256 digest of the identity and prefixes each entry key with it, so different identities still cannot observe each other's trust decisions, while per-identity state is reclaimed by the table's ordinary TTL cleanup. WithCacheName stays for tenant/test isolation, with docs warning against deriving names from request data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 64a43bc commit 218819c

3 files changed

Lines changed: 129 additions & 66 deletions

File tree

pkg/github/dependencies.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -455,20 +455,15 @@ func (d *RequestDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAcc
455455
return nil, err
456456
}
457457

458-
// Scope the cache table to the requesting identity so a trust decision
458+
// Scope cache entries to the requesting identity so a trust decision
459459
// computed under one caller's credentials is never served to another.
460-
// cache2go.Cache(name) returns a process-wide singleton keyed by name, so
461-
// without this every request sharing d.RepoAccessOpts (built once at
462-
// server startup) would hit the same default-named table regardless of
463-
// which token issued the request. Deriving the name from the token keeps
464-
// repeated requests from the same identity on a warm cache while
465-
// isolating different identities from one another. Copy RepoAccessOpts
466-
// before appending so concurrent requests never mutate the shared slice.
460+
// RepoAccessOpts is built once at server startup and shared by every
461+
// request, so identity scoping has to be applied per request here. Copy
462+
// the slice before appending so concurrent requests never mutate the
463+
// shared backing array.
467464
opts := d.RepoAccessOpts
468-
if tokenInfo, ok := ghcontext.GetTokenInfo(ctx); ok {
469-
if name := lockdown.CacheNameForIdentity(tokenInfo.Token); name != "" {
470-
opts = append(append([]lockdown.RepoAccessOption{}, d.RepoAccessOpts...), lockdown.WithCacheName(name))
471-
}
465+
if tokenInfo, ok := ghcontext.GetTokenInfo(ctx); ok && tokenInfo.Token != "" {
466+
opts = append(append([]lockdown.RepoAccessOption{}, d.RepoAccessOpts...), lockdown.WithIdentity(tokenInfo.Token))
472467
}
473468

474469
// Create repo access cache

pkg/lockdown/lockdown.go

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ type RepoAccessCache struct {
2828
logger *slog.Logger
2929
trustedBotLogins map[string]struct{}
3030

31+
// identityDigest scopes this instance's entry keys to a single request
32+
// identity. Empty means entries are unscoped. See WithIdentity.
33+
identityDigest string
34+
3135
// now returns the current time and defaults to time.Now. Tests override it
3236
// to exercise bounded expiry deterministically without sleeping.
3337
now func() time.Time
@@ -84,12 +88,13 @@ func WithLogger(logger *slog.Logger) RepoAccessOption {
8488
// WithCacheName overrides the cache table name used for storing entries.
8589
// Use this to isolate cache entries between tenants or in tests.
8690
//
87-
// cache2go.Cache(name) returns a process-wide singleton table keyed by name,
88-
// so any two RepoAccessCache instances constructed with the same name share
89-
// every cached trust decision. In deployments that serve multiple request
90-
// identities from one process (e.g. the HTTP server), callers MUST derive a
91-
// distinct name per identity — see CacheNameForIdentity — or one identity's
92-
// cached decision can be served to another.
91+
// cache2go.Cache(name) returns a process-wide singleton table that is created
92+
// on first use and never reclaimed, so the set of names a process passes here
93+
// must be bounded and known ahead of time. Never derive a name from
94+
// request-supplied data such as an auth token: the table registry would grow
95+
// without bound, retaining every distinct value seen for the lifetime of the
96+
// process. To isolate cached decisions per request identity, use WithIdentity,
97+
// which keeps a single table and scopes individual entries instead.
9398
func WithCacheName(name string) RepoAccessOption {
9499
return func(c *RepoAccessCache) {
95100
if name != "" {
@@ -98,22 +103,29 @@ func WithCacheName(name string) RepoAccessOption {
98103
}
99104
}
100105

101-
// CacheNameForIdentity derives a stable cache table name scoped to a single
102-
// request identity (typically an auth token). Two calls with the same
103-
// identity always return the same name, so repeated requests from the same
104-
// identity keep sharing a warm cache; two calls with different identities
105-
// always return different names, so their cached trust decisions cannot mix.
106+
// WithIdentity scopes this cache's entries to a single request identity
107+
// (typically an auth token), so a trust decision computed under one caller's
108+
// credentials is never served to another. Two instances configured with the
109+
// same identity share a warm cache; instances with different identities
110+
// cannot observe each other's entries.
111+
//
112+
// Isolation is applied to the entry key rather than the cache table: entries
113+
// are stored in the shared table under a key prefixed with a digest of the
114+
// identity. This keeps storage bounded, because per-identity entries are
115+
// reclaimed by the same TTL cleanup as any other entry. Allocating a table
116+
// per identity instead would leak, since cache2go never evicts tables.
106117
//
107-
// The identity is hashed so it never appears verbatim in cache-table names,
108-
// logs, or metrics. An empty identity returns an empty string, which
109-
// WithCacheName treats as a no-op (falling back to the default shared name);
110-
// callers that need isolation must ensure a non-empty identity is supplied.
111-
func CacheNameForIdentity(identity string) string {
112-
if identity == "" {
113-
return ""
114-
}
115-
sum := sha256.Sum256([]byte(identity))
116-
return "repo-access:" + hex.EncodeToString(sum[:])
118+
// The identity is hashed so it never appears verbatim in cache keys, logs, or
119+
// metrics. An empty identity is a no-op, leaving this instance's entries
120+
// unscoped; callers that need isolation must supply a non-empty identity.
121+
func WithIdentity(identity string) RepoAccessOption {
122+
return func(c *RepoAccessCache) {
123+
if identity == "" {
124+
return
125+
}
126+
sum := sha256.Sum256([]byte(identity))
127+
c.identityDigest = hex.EncodeToString(sum[:])
128+
}
117129
}
118130

119131
// NewRepoAccessCache creates a RepoAccessCache bound to the supplied clients.
@@ -222,7 +234,7 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner
222234
return RepoAccessInfo{}, fmt.Errorf("nil repo access cache")
223235
}
224236

225-
key := cacheKey(owner, repo)
237+
key := c.cacheKey(owner, repo)
226238
userKey := strings.ToLower(username)
227239

228240
// Entries are immutable once added: the cache table is shared across instances,
@@ -378,6 +390,14 @@ func (c *RepoAccessCache) isTrustedBot(username string) bool {
378390
return ok
379391
}
380392

381-
func cacheKey(owner, repo string) string {
382-
return fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo))
393+
// cacheKey returns the entry key for owner/repo, prefixed with this cache's
394+
// identity digest when one is configured. Instances sharing a cache table are
395+
// kept isolated by this prefix rather than by separate tables, so every
396+
// identity's entries remain subject to the table's ordinary TTL cleanup.
397+
func (c *RepoAccessCache) cacheKey(owner, repo string) string {
398+
key := fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo))
399+
if c.identityDigest == "" {
400+
return key
401+
}
402+
return c.identityDigest + ":" + key
383403
}

pkg/lockdown/lockdown_test.go

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import (
55
"errors"
66
"net/http"
77
"net/http/httptest"
8+
"strings"
89
"sync"
910
"testing"
1011
"time"
1112

1213
"github.com/github/github-mcp-server/internal/githubv4mock"
1314
gogithub "github.com/google/go-github/v89/github"
15+
"github.com/muesli/cache2go"
1416
"github.com/shurcooL/githubv4"
1517
"github.com/stretchr/testify/require"
1618
)
@@ -231,54 +233,100 @@ func TestRepoAccessCacheIsolatesViewerPerInstance(t *testing.T) {
231233
require.True(t, safe)
232234
}
233235

234-
func TestCacheNameForIdentity(t *testing.T) {
235-
t.Run("deterministic for the same identity", func(t *testing.T) {
236-
require.Equal(t, CacheNameForIdentity("token-a"), CacheNameForIdentity("token-a"))
237-
})
236+
// TestRepoAccessCacheIdentityScopedKeys covers the key derivation that keeps
237+
// identities isolated inside a single shared cache table.
238+
func TestRepoAccessCacheIdentityScopedKeys(t *testing.T) {
239+
restClient := newMockRESTServer(t, "write")
240+
gqlClient, _ := newMockGQLClient(testUser, false)
241+
242+
newCache := func(opts ...RepoAccessOption) *RepoAccessCache {
243+
return NewRepoAccessCache(gqlClient, restClient, opts...)
244+
}
238245

239-
t.Run("distinct for different identities", func(t *testing.T) {
240-
require.NotEqual(t, CacheNameForIdentity("token-a"), CacheNameForIdentity("token-b"))
241-
})
246+
unscoped := newCache().cacheKey(testOwner, testRepo)
247+
alice := newCache(WithIdentity("token-alice")).cacheKey(testOwner, testRepo)
248+
aliceAgain := newCache(WithIdentity("token-alice")).cacheKey(testOwner, testRepo)
249+
bob := newCache(WithIdentity("token-bob")).cacheKey(testOwner, testRepo)
242250

243-
t.Run("empty identity yields empty name", func(t *testing.T) {
244-
require.Empty(t, CacheNameForIdentity(""))
245-
})
251+
require.Equal(t, alice, aliceAgain, "the same identity must map to the same key so it keeps a warm cache")
252+
require.NotEqual(t, alice, bob, "different identities must map to different keys")
253+
require.NotEqual(t, alice, unscoped, "a scoped identity must not collide with unscoped entries")
254+
require.NotContains(t, alice, "token-alice", "the raw identity must never appear in a cache key")
246255

247-
t.Run("never contains the raw identity", func(t *testing.T) {
248-
name := CacheNameForIdentity("super-secret-token")
249-
require.NotContains(t, name, "super-secret-token")
250-
})
256+
require.Equal(t, unscoped, newCache(WithIdentity("")).cacheKey(testOwner, testRepo),
257+
"an empty identity must leave entries unscoped")
258+
require.Equal(t, alice, newCache(WithIdentity("token-alice")).cacheKey(strings.ToUpper(testOwner), strings.ToUpper(testRepo)),
259+
"identity scoping must preserve owner/repo case-insensitivity")
251260
}
252261

253-
// TestRepoAccessCacheIdentityScopedNamesPreventCrossIdentityLeakage is a
254-
// regression test for issue #3107. It mirrors how the HTTP server must
255-
// construct a RepoAccessCache per request: reusing the same
256-
// lockdown.RepoAccessOption slice across requests but scoping the cache table
257-
// name to CacheNameForIdentity(token). Two different identities querying the
258-
// same owner/repo/author must each hit their own upstream clients rather than
259-
// one being served from the other's cached decision.
260-
func TestRepoAccessCacheIdentityScopedNamesPreventCrossIdentityLeakage(t *testing.T) {
262+
// TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable is a regression
263+
// test for issue #3107. Isolating identities by allocating a cache2go table
264+
// per token grows a process-wide registry that is never reclaimed, so
265+
// isolation must instead come from the entry key inside a single table. This
266+
// asserts both halves: different identities cannot see each other's trust
267+
// decisions, and their entries share one table so ordinary TTL cleanup can
268+
// reclaim them.
269+
func TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable(t *testing.T) {
261270
ctx := t.Context()
262271

263272
restClient := newMockRESTServer(t, "write")
273+
table := cache2go.Cache(t.Name())
274+
t.Cleanup(table.Flush)
275+
276+
newCache := func(gqlClient *githubv4.Client, identity string) *RepoAccessCache {
277+
return NewRepoAccessCache(gqlClient, restClient, WithCacheName(t.Name()), WithIdentity(identity))
278+
}
264279

265280
aliceGQL, aliceTransport := newMockGQLClient("alice", true)
266-
aliceCache := NewRepoAccessCache(aliceGQL, restClient, WithCacheName(CacheNameForIdentity("token-alice")))
267-
_, err := aliceCache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
281+
_, err := newCache(aliceGQL, "token-alice").getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
268282
require.NoError(t, err)
269283
require.EqualValues(t, 1, aliceTransport.CallCount())
270284

271285
bobGQL, bobTransport := newMockGQLClient("bob", true)
272-
bobCache := NewRepoAccessCache(bobGQL, restClient, WithCacheName(CacheNameForIdentity("token-bob")))
273-
_, err = bobCache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
286+
_, err = newCache(bobGQL, "token-bob").getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
274287
require.NoError(t, err)
275-
require.EqualValues(t, 1, bobTransport.CallCount(), "a different identity must fetch its own trust decision, not reuse another identity's cached entry")
288+
require.EqualValues(t, 1, bobTransport.CallCount(),
289+
"a different identity must fetch its own trust decision, not reuse another identity's cached entry")
290+
291+
require.EqualValues(t, 2, table.Count(),
292+
"per-identity entries must be stored in one shared table rather than a table per identity")
276293

277-
// The same identity repeating a request must still hit the warm cache.
278-
aliceCacheAgain := NewRepoAccessCache(aliceGQL, restClient, WithCacheName(CacheNameForIdentity("token-alice")))
279-
_, err = aliceCacheAgain.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
294+
// Repeating the same identity must hit the warm cache and must not
295+
// allocate additional storage.
296+
_, err = newCache(aliceGQL, "token-alice").getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
280297
require.NoError(t, err)
281298
require.EqualValues(t, 1, aliceTransport.CallCount(), "repeated requests from the same identity should reuse the warm cache")
299+
require.EqualValues(t, 2, table.Count(), "a repeated request from a known identity must not add another entry")
300+
}
301+
302+
// TestRepoAccessCacheIdentityScopedEntriesAreReclaimed proves the storage held
303+
// for distinct identities is bounded: because identity scoping lives in the
304+
// entry key, per-identity state is removed by the cache table's ordinary TTL
305+
// cleanup. A table-per-identity design could not shrink this way, since
306+
// cache2go retains every named table for the life of the process.
307+
func TestRepoAccessCacheIdentityScopedEntriesAreReclaimed(t *testing.T) {
308+
ctx := t.Context()
309+
310+
restClient := newMockRESTServer(t, "write")
311+
table := cache2go.Cache(t.Name())
312+
t.Cleanup(table.Flush)
313+
314+
identities := []string{"token-a", "token-b", "token-c"}
315+
for _, identity := range identities {
316+
gqlClient, _ := newMockGQLClient(testUser, false)
317+
cache := NewRepoAccessCache(gqlClient, restClient,
318+
WithCacheName(t.Name()),
319+
WithIdentity(identity),
320+
WithTTL(500*time.Millisecond),
321+
)
322+
_, err := cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
323+
require.NoError(t, err)
324+
}
325+
326+
require.EqualValues(t, len(identities), table.Count(), "each identity should hold exactly one entry in the shared table")
327+
328+
require.Eventually(t, func() bool { return table.Count() == 0 }, 30*time.Second, 10*time.Millisecond,
329+
"per-identity entries must be reclaimed by ordinary TTL cleanup so cache storage stays bounded")
282330
}
283331

284332
type flakyTransport struct {

0 commit comments

Comments
 (0)