Skip to content

Commit acd2afc

Browse files
refactor(lockdown): trim comments to non-obvious invariants
The cache changes carried explanatory comments that restated the code or narrated what each step did. Drop them and keep only what the code cannot express: that cache2go never reclaims a named table, that its own expiry slides on every read, that createdAt survives entry updates, and that RepoAccessOpts is shared across requests. Exported options keep a short doc comment. Comment-only; no behavior change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 566f483 commit acd2afc

4 files changed

Lines changed: 35 additions & 110 deletions

File tree

pkg/github/dependencies.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -463,12 +463,8 @@ func (d *RequestDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAcc
463463
return nil, err
464464
}
465465

466-
// Scope cache entries to the requesting identity so a trust decision
467-
// computed under one caller's credentials is never served to another.
468-
// RepoAccessOpts is built once at server startup and shared by every
469-
// request, so identity scoping has to be applied per request here. Copy
470-
// the slice before appending so concurrent requests never mutate the
471-
// shared backing array.
466+
// RepoAccessOpts is shared across requests, so copy before appending the
467+
// per-request identity scope.
472468
opts := d.RepoAccessOpts
473469
if tokenInfo, ok := ghcontext.GetTokenInfo(ctx); ok && tokenInfo.Token != "" {
474470
opts = append(append([]lockdown.RepoAccessOption{}, d.RepoAccessOpts...), lockdown.WithIdentity(tokenInfo.Token))

pkg/github/dependencies_test.go

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,8 @@ func TestRequestDepsScopesTokensToConfiguredHosts(t *testing.T) {
124124
assert.Empty(t, foreignAuth, "GraphQL redirect must not authenticate to a foreign host")
125125
}
126126

127-
// TestGetRepoAccessCacheIsolatesTrustDecisionsPerIdentity is a regression test
128-
// for issue #3107. It mirrors exactly how the HTTP server builds RequestDeps:
129-
// a single RepoAccessOpts slice is constructed once at startup (with no
130-
// per-identity WithCacheName) and reused across every request, and
131-
// GetRepoAccessCache is called fresh per request. Two different token
132-
// identities querying the same owner/repo/author must each perform their own
133-
// upstream lookups instead of one being served from the other's cached
134-
// decision, while repeated requests from the same identity must reuse a warm
135-
// cache.
127+
// Regression test for #3107: RequestDeps is built once at startup and shared,
128+
// so identity scoping has to happen per request in GetRepoAccessCache.
136129
func TestGetRepoAccessCacheIsolatesTrustDecisionsPerIdentity(t *testing.T) {
137130
t.Parallel()
138131

@@ -159,7 +152,7 @@ func TestGetRepoAccessCacheIsolatesTrustDecisionsPerIdentity(t *testing.T) {
159152
return gqlCalls, restCalls
160153
}
161154

162-
// Built once, exactly as pkg/http/server.go does today: no WithCacheName.
155+
// Built as pkg/http/server.go does: no per-identity options.
163156
deps := github.NewRequestDeps(
164157
newRequestDepsAPIHostResolver(t, server.URL),
165158
"test",
@@ -195,8 +188,6 @@ func TestGetRepoAccessCacheIsolatesTrustDecisionsPerIdentity(t *testing.T) {
195188
require.Equal(t, 2, gqlN, "a different identity's request must not be served from another identity's cached trust decision")
196189
require.Equal(t, 2, restN, "a different identity's request must not be served from another identity's cached trust decision")
197190

198-
// Repeating the same identity's token must reuse the warm per-identity
199-
// cache without any additional upstream calls.
200191
cacheAliceAgain, err := deps.GetRepoAccessCache(ctxAlice)
201192
require.NoError(t, err)
202193
_, err = cacheAliceAgain.IsSafeContent(ctxAlice, "mallory", "owner", "repo")

pkg/lockdown/lockdown.go

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,8 @@ type RepoAccessCache struct {
2727
ttl time.Duration
2828
logger *slog.Logger
2929
trustedBotLogins map[string]struct{}
30-
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-
35-
// now returns the current time and defaults to time.Now. Tests override it
36-
// to exercise bounded expiry deterministically without sleeping.
37-
now func() time.Time
30+
identityDigest string
31+
now func() time.Time
3832

3933
viewerMu sync.Mutex
4034
viewerLogin string
@@ -44,10 +38,7 @@ type repoAccessCacheEntry struct {
4438
isPrivate bool
4539
knownUsers map[string]bool // normalized login -> has push access
4640

47-
// createdAt is the wall-clock time this repository's trust decision was
48-
// first fetched. It is preserved across every subsequent update to the
49-
// entry (e.g. learning about a newly-seen author), so an entry's maximum
50-
// age is bounded from its original creation rather than reset by access.
41+
// Preserved across entry updates, so age is bounded from the first fetch.
5142
createdAt time.Time
5243
}
5344

@@ -66,12 +57,8 @@ const (
6657
type RepoAccessOption func(*RepoAccessCache)
6758

6859
// WithTTL overrides the default maximum age applied to cache entries. A
69-
// non-positive duration disables expiration.
70-
//
71-
// The TTL is a bounded, absolute age measured from when an entry's trust
72-
// decision was first fetched: repeated reads never extend it. This ensures
73-
// an actively-read entry is still refreshed once it reaches the maximum age,
74-
// rather than sliding its expiration forward indefinitely.
60+
// non-positive duration disables expiration. The age is absolute, measured
61+
// from an entry's first fetch: repeated reads never extend it.
7562
func WithTTL(ttl time.Duration) RepoAccessOption {
7663
return func(c *RepoAccessCache) {
7764
c.ttl = ttl
@@ -88,13 +75,9 @@ func WithLogger(logger *slog.Logger) RepoAccessOption {
8875
// WithCacheName overrides the cache table name used for storing entries.
8976
// Use this to isolate cache entries between tenants or in tests.
9077
//
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.
78+
// cache2go never reclaims a named table, so names must come from a bounded,
79+
// known set; never derive one from request data. Use WithIdentity instead to
80+
// isolate per request identity.
9881
func WithCacheName(name string) RepoAccessOption {
9982
return func(c *RepoAccessCache) {
10083
if name != "" {
@@ -103,21 +86,14 @@ func WithCacheName(name string) RepoAccessOption {
10386
}
10487
}
10588

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.
89+
// WithIdentity scopes cache entries to a single request identity, typically an
90+
// auth token, so a decision computed under one caller's credentials is never
91+
// served to another. Equal identities share a warm cache; an empty one is a
92+
// no-op.
11793
//
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.
94+
// 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
96+
// hashed so it never appears verbatim in a key.
12197
func WithIdentity(identity string) RepoAccessOption {
12298
return func(c *RepoAccessCache) {
12399
if identity == "" {
@@ -261,8 +237,7 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner
261237
users := make(map[string]bool, len(entry.knownUsers)+1)
262238
maps.Copy(users, entry.knownUsers)
263239
users[userKey] = hasPush
264-
// Preserve the entry's original createdAt: learning about a newly
265-
// seen author must not reset the entry's bounded maximum age.
240+
// Preserve createdAt: a new author must not reset the entry's age.
266241
c.cache.Add(key, c.ttl, &repoAccessCacheEntry{
267242
isPrivate: entry.isPrivate,
268243
knownUsers: users,
@@ -303,21 +278,16 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner
303278
}, nil
304279
}
305280

306-
// entryExpired reports whether entry has reached the cache's bounded maximum
307-
// age, measured from its original creation time rather than its last access
308-
// time. Unlike the underlying cache2go table's own sliding expiry (which
309-
// resets on every read), this check ensures a frequently-accessed entry is
310-
// still forced to refresh once it is old enough, so stale trust decisions
311-
// cannot be kept alive indefinitely by repeated reads.
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.
312284
func (c *RepoAccessCache) entryExpired(entry *repoAccessCacheEntry) bool {
313285
if c.ttl <= 0 {
314286
return false
315287
}
316288
return c.clock().Sub(entry.createdAt) >= c.ttl
317289
}
318290

319-
// clock returns the current time, using the injected now function if set
320-
// (tests use this to exercise bounded expiry deterministically).
321291
func (c *RepoAccessCache) clock() time.Time {
322292
if c.now != nil {
323293
return c.now()
@@ -390,10 +360,8 @@ func (c *RepoAccessCache) isTrustedBot(username string) bool {
390360
return ok
391361
}
392362

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.
363+
// cacheKey scopes the owner/repo key to this cache's identity, so identities
364+
// sharing a table cannot observe each other's entries.
397365
func (c *RepoAccessCache) cacheKey(owner, repo string) string {
398366
key := fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo))
399367
if c.identityDigest == "" {

pkg/lockdown/lockdown_test.go

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,8 @@ func TestRepoAccessCacheEvictsAfterTTL(t *testing.T) {
134134
require.EqualValues(t, 2, transport.CallCount())
135135
}
136136

137-
// TestRepoAccessCacheBoundedExpiryIgnoresRepeatedAccess is a regression test for
138-
// issue #3107: a sliding-expiry cache extends an entry's life on every read, so
139-
// a frequently-accessed entry never refreshes even once revoked access should
140-
// have invalidated it. With bounded expiry, an entry's maximum age is measured
141-
// from its original creation, not its last access, so repeated reads within
142-
// the TTL are served from cache but the entry is still forced to refresh once
143-
// its absolute age exceeds the TTL.
137+
// Regression test for #3107: sliding expiry would let a frequently-read entry
138+
// outlive revoked access, so age must be bounded from creation.
144139
func TestRepoAccessCacheBoundedExpiryIgnoresRepeatedAccess(t *testing.T) {
145140
ctx := t.Context()
146141

@@ -154,31 +149,23 @@ func TestRepoAccessCacheBoundedExpiryIgnoresRepeatedAccess(t *testing.T) {
154149
require.True(t, info.HasPushAccess)
155150
require.EqualValues(t, 1, transport.CallCount())
156151

157-
// Repeatedly access the entry well within the window. A sliding-expiry
158-
// cache would extend the entry's life on every one of these reads and
159-
// never refresh it; bounded expiry must keep serving it from cache
160-
// without making new upstream calls, since the absolute age is still
161-
// under the TTL.
152+
// Each read lands well inside the TTL; only their sum exceeds it.
162153
for range 4 {
163154
current = current.Add(20 * time.Second)
164155
_, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
165156
require.NoError(t, err)
166157
}
167158
require.EqualValues(t, 1, transport.CallCount(), "repeated access within the bounded window must still be served from cache")
168159

169-
// Cross the bound: total elapsed time since creation now exceeds the TTL,
170-
// even though every individual access happened well inside it.
171160
current = current.Add(30 * time.Second)
172161
info, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
173162
require.NoError(t, err)
174163
require.True(t, info.HasPushAccess)
175164
require.EqualValues(t, 2, transport.CallCount(), "entry must refresh once its absolute age exceeds the TTL, regardless of access frequency")
176165
}
177166

178-
// TestRepoAccessCacheNewUserDoesNotResetEntryAge ensures that learning about a
179-
// newly-seen author on an existing repo entry does not reset the entry's
180-
// bounded creation time, which would otherwise re-introduce sliding behavior
181-
// through a different code path.
167+
// A "known users" miss updates an existing entry, a second path that must not
168+
// reset its age.
182169
func TestRepoAccessCacheNewUserDoesNotResetEntryAge(t *testing.T) {
183170
ctx := t.Context()
184171

@@ -194,16 +181,11 @@ func TestRepoAccessCacheNewUserDoesNotResetEntryAge(t *testing.T) {
194181
require.NoError(t, err)
195182
require.EqualValues(t, 1, transport.CallCount())
196183

197-
// A different, previously-unseen user triggers a "known users" miss but
198-
// not a full entry miss, exercising the path that preserves createdAt.
199184
cache.now = func() time.Time { return start.Add(50 * time.Second) }
200185
_, err = cache.getRepoAccessInfo(ctx, "someone-else", testOwner, testRepo)
201186
require.NoError(t, err)
202187
require.EqualValues(t, 1, transport.CallCount(), "checking a new user against a cached repo entry must not re-query repo metadata")
203188

204-
// Total elapsed time since the entry's original creation now exceeds the
205-
// TTL. If the new-user update above had reset createdAt, this would still
206-
// be considered fresh (50s < 100s from the reset point); it must not be.
207189
cache.now = func() time.Time { return start.Add(120 * time.Second) }
208190
_, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
209191
require.NoError(t, err)
@@ -233,8 +215,6 @@ func TestRepoAccessCacheIsolatesViewerPerInstance(t *testing.T) {
233215
require.True(t, safe)
234216
}
235217

236-
// TestRepoAccessCacheIdentityScopedKeys covers the key derivation that keeps
237-
// identities isolated inside a single shared cache table.
238218
func TestRepoAccessCacheIdentityScopedKeys(t *testing.T) {
239219
restClient := newMockRESTServer(t, "write")
240220
gqlClient, _ := newMockGQLClient(testUser, false)
@@ -259,13 +239,8 @@ func TestRepoAccessCacheIdentityScopedKeys(t *testing.T) {
259239
"identity scoping must preserve owner/repo case-insensitivity")
260240
}
261241

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.
242+
// Regression test for #3107: a table per identity leaks, so isolation must come
243+
// from the entry key inside one table.
269244
func TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable(t *testing.T) {
270245
ctx := t.Context()
271246

@@ -291,19 +266,14 @@ func TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable(t *testing.T) {
291266
require.EqualValues(t, 2, table.Count(),
292267
"per-identity entries must be stored in one shared table rather than a table per identity")
293268

294-
// Repeating the same identity must hit the warm cache and must not
295-
// allocate additional storage.
296269
_, err = newCache(aliceGQL, "token-alice").getRepoAccessInfo(ctx, testUser, testOwner, testRepo)
297270
require.NoError(t, err)
298271
require.EqualValues(t, 1, aliceTransport.CallCount(), "repeated requests from the same identity should reuse the warm cache")
299272
require.EqualValues(t, 2, table.Count(), "a repeated request from a known identity must not add another entry")
300273
}
301274

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.
275+
// Key-scoped entries stay bounded because ordinary TTL cleanup reclaims them;
276+
// a table per identity could not shrink this way.
307277
func TestRepoAccessCacheIdentityScopedEntriesAreReclaimed(t *testing.T) {
308278
ctx := t.Context()
309279

0 commit comments

Comments
 (0)