Skip to content

Commit 95b347e

Browse files
fix(lockdown): drop fixed-age expiry, keep per-identity cache isolation
The cache's idle/sliding TTL is cache2go's documented behaviour and was deliberate in both the original hand-rolled cache and the cache2go migration: a hot repo keeps serving from cache and only idle entries are reclaimed. Replacing it with a fixed max age traded that away for a periodic refetch on every hot repo, which is a freshness change rather than the isolation fix this issue is about. Remove createdAt, the injected clock, entryExpired, the createdAt preservation on entry updates, and the tests that only existed to prove bounded non-sliding expiry. Restore the original sliding semantics. Keep the per-caller isolation, which is the actual defect: entries were keyed on owner/repo alone in a process-wide table, so a trust decision computed under one caller's credentials could be served to another caller whose own credentials were never checked. Entry keys now carry a SHA-256 digest of the request identity, inside a single bounded table. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent acd2afc commit 95b347e

2 files changed

Lines changed: 32 additions & 123 deletions

File tree

pkg/lockdown/lockdown.go

Lines changed: 27 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ type RepoAccessCache struct {
2828
logger *slog.Logger
2929
trustedBotLogins map[string]struct{}
3030
identityDigest string
31-
now func() time.Time
3231

3332
viewerMu sync.Mutex
3433
viewerLogin string
@@ -37,9 +36,6 @@ type RepoAccessCache struct {
3736
type repoAccessCacheEntry struct {
3837
isPrivate bool
3938
knownUsers map[string]bool // normalized login -> has push access
40-
41-
// Preserved across entry updates, so age is bounded from the first fetch.
42-
createdAt time.Time
4339
}
4440

4541
// RepoAccessInfo captures repository metadata needed for lockdown decisions.
@@ -56,9 +52,8 @@ const (
5652
// RepoAccessOption configures RepoAccessCache at construction time.
5753
type RepoAccessOption func(*RepoAccessCache)
5854

59-
// WithTTL overrides the default maximum age applied to cache entries. A
60-
// non-positive duration disables expiration. The age is absolute, measured
61-
// from an entry's first fetch: repeated reads never extend it.
55+
// WithTTL overrides the default TTL applied to cache entries. A non-positive
56+
// duration disables expiration.
6257
func WithTTL(ttl time.Duration) RepoAccessOption {
6358
return func(c *RepoAccessCache) {
6459
c.ttl = ttl
@@ -92,7 +87,7 @@ func WithCacheName(name string) RepoAccessOption {
9287
// no-op.
9388
//
9489
// Scoping lives in the entry key rather than the table so per-identity state
95-
// stays bounded and is reclaimed by ordinary TTL cleanup. The identity is
90+
// stays bounded and is reclaimed by ordinary idle-TTL cleanup. The identity is
9691
// hashed so it never appears verbatim in a key.
9792
func WithIdentity(identity string) RepoAccessOption {
9893
return func(c *RepoAccessCache) {
@@ -217,44 +212,37 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner
217212
// so we publish a fresh entry with a cloned knownUsers map on every miss.
218213
if cacheItem, err := c.cache.Value(key); err == nil {
219214
entry := cacheItem.Data().(*repoAccessCacheEntry)
220-
221-
if !c.entryExpired(entry) {
222-
if cachedHasPush, known := entry.knownUsers[userKey]; known {
223-
c.logDebug(ctx, fmt.Sprintf("repo access cache hit for user %s to %s/%s", username, owner, repo))
224-
return RepoAccessInfo{
225-
IsPrivate: entry.isPrivate,
226-
HasPushAccess: cachedHasPush,
227-
}, nil
228-
}
229-
230-
c.logDebug(ctx, "known users cache miss, fetching permission")
231-
232-
hasPush, pushErr := c.checkPushAccess(ctx, username, owner, repo)
233-
if pushErr != nil {
234-
return RepoAccessInfo{}, pushErr
235-
}
236-
237-
users := make(map[string]bool, len(entry.knownUsers)+1)
238-
maps.Copy(users, entry.knownUsers)
239-
users[userKey] = hasPush
240-
// Preserve createdAt: a new author must not reset the entry's age.
241-
c.cache.Add(key, c.ttl, &repoAccessCacheEntry{
242-
isPrivate: entry.isPrivate,
243-
knownUsers: users,
244-
createdAt: entry.createdAt,
245-
})
246-
215+
if cachedHasPush, known := entry.knownUsers[userKey]; known {
216+
c.logDebug(ctx, fmt.Sprintf("repo access cache hit for user %s to %s/%s", username, owner, repo))
247217
return RepoAccessInfo{
248218
IsPrivate: entry.isPrivate,
249-
HasPushAccess: hasPush,
219+
HasPushAccess: cachedHasPush,
250220
}, nil
251221
}
252222

253-
c.logDebug(ctx, fmt.Sprintf("repo access cache entry for %s/%s exceeded max age, refreshing", owner, repo))
254-
} else {
255-
c.logDebug(ctx, fmt.Sprintf("repo access cache miss for user %s to %s/%s", username, owner, repo))
223+
c.logDebug(ctx, "known users cache miss, fetching permission")
224+
225+
hasPush, pushErr := c.checkPushAccess(ctx, username, owner, repo)
226+
if pushErr != nil {
227+
return RepoAccessInfo{}, pushErr
228+
}
229+
230+
users := make(map[string]bool, len(entry.knownUsers)+1)
231+
maps.Copy(users, entry.knownUsers)
232+
users[userKey] = hasPush
233+
c.cache.Add(key, c.ttl, &repoAccessCacheEntry{
234+
isPrivate: entry.isPrivate,
235+
knownUsers: users,
236+
})
237+
238+
return RepoAccessInfo{
239+
IsPrivate: entry.isPrivate,
240+
HasPushAccess: hasPush,
241+
}, nil
256242
}
257243

244+
c.logDebug(ctx, fmt.Sprintf("repo access cache miss for user %s to %s/%s", username, owner, repo))
245+
258246
isPrivate, viewerLogin, queryErr := c.queryRepoAccessInfo(ctx, owner, repo)
259247
if queryErr != nil {
260248
return RepoAccessInfo{}, queryErr
@@ -269,7 +257,6 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner
269257
c.cache.Add(key, c.ttl, &repoAccessCacheEntry{
270258
knownUsers: map[string]bool{userKey: hasPush},
271259
isPrivate: isPrivate,
272-
createdAt: c.clock(),
273260
})
274261

275262
return RepoAccessInfo{
@@ -278,23 +265,6 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner
278265
}, nil
279266
}
280267

281-
// entryExpired reports whether entry has reached the cache's maximum age,
282-
// measured from creation. cache2go's own expiry instead slides on every read,
283-
// which would let repeated reads keep a stale decision alive indefinitely.
284-
func (c *RepoAccessCache) entryExpired(entry *repoAccessCacheEntry) bool {
285-
if c.ttl <= 0 {
286-
return false
287-
}
288-
return c.clock().Sub(entry.createdAt) >= c.ttl
289-
}
290-
291-
func (c *RepoAccessCache) clock() time.Time {
292-
if c.now != nil {
293-
return c.now()
294-
}
295-
return time.Now()
296-
}
297-
298268
// queryRepoAccessInfo fetches repository visibility and the viewer login in a single GraphQL round-trip.
299269
func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, owner, repo string) (bool, string, error) {
300270
if c.client == nil {

pkg/lockdown/lockdown_test.go

Lines changed: 5 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,14 @@ func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache,
115115
func TestRepoAccessCacheEvictsAfterTTL(t *testing.T) {
116116
ctx := t.Context()
117117

118-
cache, transport := newMockRepoAccessCache(t, time.Minute)
119-
start := time.Now()
120-
cache.now = func() time.Time { return start }
121-
118+
cache, transport := newMockRepoAccessCache(t, 5*time.Millisecond)
122119
info, err := cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
123120
require.NoError(t, err)
124121
require.False(t, info.IsPrivate)
125122
require.True(t, info.HasPushAccess)
126123
require.EqualValues(t, 1, transport.CallCount())
127124

128-
cache.now = func() time.Time { return start.Add(2 * time.Minute) }
125+
time.Sleep(20 * time.Millisecond)
129126

130127
info, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
131128
require.NoError(t, err)
@@ -134,64 +131,6 @@ func TestRepoAccessCacheEvictsAfterTTL(t *testing.T) {
134131
require.EqualValues(t, 2, transport.CallCount())
135132
}
136133

137-
// Regression test for #3107: sliding expiry would let a frequently-read entry
138-
// outlive revoked access, so age must be bounded from creation.
139-
func TestRepoAccessCacheBoundedExpiryIgnoresRepeatedAccess(t *testing.T) {
140-
ctx := t.Context()
141-
142-
const ttl = 100 * time.Second
143-
cache, transport := newMockRepoAccessCache(t, ttl)
144-
current := time.Now()
145-
cache.now = func() time.Time { return current }
146-
147-
info, err := cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
148-
require.NoError(t, err)
149-
require.True(t, info.HasPushAccess)
150-
require.EqualValues(t, 1, transport.CallCount())
151-
152-
// Each read lands well inside the TTL; only their sum exceeds it.
153-
for range 4 {
154-
current = current.Add(20 * time.Second)
155-
_, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
156-
require.NoError(t, err)
157-
}
158-
require.EqualValues(t, 1, transport.CallCount(), "repeated access within the bounded window must still be served from cache")
159-
160-
current = current.Add(30 * time.Second)
161-
info, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
162-
require.NoError(t, err)
163-
require.True(t, info.HasPushAccess)
164-
require.EqualValues(t, 2, transport.CallCount(), "entry must refresh once its absolute age exceeds the TTL, regardless of access frequency")
165-
}
166-
167-
// A "known users" miss updates an existing entry, a second path that must not
168-
// reset its age.
169-
func TestRepoAccessCacheNewUserDoesNotResetEntryAge(t *testing.T) {
170-
ctx := t.Context()
171-
172-
const ttl = 100 * time.Second
173-
gqlClient, transport := newMockGQLClient(testUser, false)
174-
restClient := newMockRESTServer(t, "write")
175-
cache := NewRepoAccessCache(gqlClient, restClient, WithTTL(ttl), WithCacheName(t.Name()))
176-
177-
start := time.Now()
178-
cache.now = func() time.Time { return start }
179-
180-
_, err := cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
181-
require.NoError(t, err)
182-
require.EqualValues(t, 1, transport.CallCount())
183-
184-
cache.now = func() time.Time { return start.Add(50 * time.Second) }
185-
_, err = cache.getRepoAccessInfo(ctx, "someone-else", testOwner, testRepo)
186-
require.NoError(t, err)
187-
require.EqualValues(t, 1, transport.CallCount(), "checking a new user against a cached repo entry must not re-query repo metadata")
188-
189-
cache.now = func() time.Time { return start.Add(120 * time.Second) }
190-
_, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
191-
require.NoError(t, err)
192-
require.EqualValues(t, 2, transport.CallCount(), "entry age must be bounded from its original creation, not reset by learning about a new user")
193-
}
194-
195134
func TestRepoAccessCacheIsolatesViewerPerInstance(t *testing.T) {
196135
ctx := t.Context()
197136

@@ -272,8 +211,8 @@ func TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable(t *testing.T) {
272211
require.EqualValues(t, 2, table.Count(), "a repeated request from a known identity must not add another entry")
273212
}
274213

275-
// Key-scoped entries stay bounded because ordinary TTL cleanup reclaims them;
276-
// a table per identity could not shrink this way.
214+
// Key-scoped entries stay bounded because ordinary idle-TTL cleanup reclaims
215+
// them; a table per identity could not shrink this way.
277216
func TestRepoAccessCacheIdentityScopedEntriesAreReclaimed(t *testing.T) {
278217
ctx := t.Context()
279218

@@ -296,7 +235,7 @@ func TestRepoAccessCacheIdentityScopedEntriesAreReclaimed(t *testing.T) {
296235
require.EqualValues(t, len(identities), table.Count(), "each identity should hold exactly one entry in the shared table")
297236

298237
require.Eventually(t, func() bool { return table.Count() == 0 }, 30*time.Second, 10*time.Millisecond,
299-
"per-identity entries must be reclaimed by ordinary TTL cleanup so cache storage stays bounded")
238+
"per-identity entries must be reclaimed by ordinary idle-TTL cleanup so cache storage stays bounded")
300239
}
301240

302241
type flakyTransport struct {

0 commit comments

Comments
 (0)