fix(lockdown): isolate repo-access cache per caller identity - #3113
Merged
SamMorrowDrums merged 8 commits intoAug 19, 2026
Merged
Conversation
…tity The repo-access cache used by lockdown mode relied on cache2go's sliding expiry: every read extends an entry's life, so a frequently-accessed entry could keep a stale trust decision (e.g. revoked push access) alive indefinitely instead of refreshing after its TTL. Separately, cache2go.Cache(name) returns a process-wide singleton table keyed by name. In HTTP mode, RequestDeps.GetRepoAccessCache built a new RepoAccessCache per request but always reused the same default-named table, so trust decisions computed under one caller's credentials could be served to a different caller for the same owner/repo, without ever validating the second caller's own access. Fixes: - Track each cache entry's original creation time and bound its maximum age from that fixed point, not from last access, so entries are refreshed after a fixed TTL regardless of read frequency. - Add lockdown.CacheNameForIdentity, which derives a stable, hashed cache-table name from a request identity (e.g. auth token). Two calls for the same identity return the same name (reusing a warm cache across a session's repeated requests); different identities always get different names (no shared cache state). - RequestDeps.GetRepoAccessCache now scopes each request's cache to the requesting token's identity via CacheNameForIdentity, closing the cross-identity leak in HTTP/multi-tenant deployments. Stdio mode is unaffected: it constructs a single RepoAccessCache for the whole process lifetime, as before. Tests added: - TestRepoAccessCacheBoundedExpiryIgnoresRepeatedAccess and TestRepoAccessCacheNewUserDoesNotResetEntryAge exercise bounded expiry deterministically via an injectable clock (no sleeps). - TestCacheNameForIdentity and TestRepoAccessCacheIdentityScopedNamesPreventCrossIdentityLeakage cover the naming helper and cross-identity isolation at the lockdown package level. - TestGetRepoAccessCacheIsolatesTrustDecisionsPerIdentity in pkg/github mirrors the HTTP server's exact construction pattern end-to-end and fails without the dependencies.go fix. Fixes #3107 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Bounds lockdown cache expiry and isolates trust decisions by request identity.
Changes:
- Adds absolute TTL enforcement with deterministic tests.
- Derives hashed, identity-scoped cache names.
- Applies identity isolation in HTTP request dependencies.
Show a summary per file
| File | Description |
|---|---|
pkg/lockdown/lockdown.go |
Implements bounded expiry and identity cache naming. |
pkg/lockdown/lockdown_test.go |
Tests expiry and identity isolation. |
pkg/github/dependencies.go |
Scopes request caches by token. |
pkg/github/dependencies_test.go |
Tests request-level isolation. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
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>
The golangci-lint action downloads a JSON schema from golangci-lint.run on every run to verify .golangci.yml. A blip reaching that host fails the job before any linter runs, as it did on this PR. Linting should depend only on the checked-out code, which is also what script/lint does locally.
…ed-expiry-for-lockdown-c56a37
This reverts commit d7d8dd2. The lint job failed on a transient timeout fetching the golangci-lint config schema, which is a CI infrastructure concern rather than a defect in this change. Disabling schema verification to work around it does not belong in a cache-hardening PR: it weakens a check for every future run, and its root cause is out of scope here. Leaving CI configuration untouched keeps this PR to the lockdown cache redesign. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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>
…-pr-3113-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>
SamMorrowDrums
deleted the
sammorrowdrums-issue-3107-use-bounded-isolated-expiry-for-lockdown-c56a37
branch
August 19, 2026 14:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
In HTTP mode, the
pkg/lockdownrepo-access cache was shared across callers.RequestDeps.GetRepoAccessCachebuilds a fresh*lockdown.RepoAccessCacheper request, but entries were keyed onowner/repoalone inside a process-widecache2gotable. A trust decision computed under one caller's credentials — is this repo private, does this author have push access — could therefore be served to a completely different caller, without that second caller's own credentials ever being checked.I verified this concretely with a throwaway reproduction before fixing it: a "bob" request was served "alice"'s cached decision, and bob's own REST client was never invoked.
Stdio mode was never affected. It builds one
RepoAccessCachefor a single process-wide identity.Fix
lockdown.WithIdentity(identity string). It stores a SHA-256 digest of the request identity (typically the auth token) and prefixes every entry key with it. Equal identities share a warm cache; different identities can no longer observe each other's entries. The raw identity never appears in a key.RequestDeps.GetRepoAccessCacheappliesWithIdentity(tokenInfo.Token)per request.RepoAccessOptsis built once at startup and shared by every request, so the slice is copied before appending.Isolation lives in the entry key, not in a cache table per identity.
cache2go.Cache(name)never reclaims a named table, so deriving a table name from request data would build a per-token registry that only ever grows. Key scoping keeps every identity in one bounded table, where ordinary idle-TTL cleanup reclaims entries as it always has.Expiry semantics are deliberately unchanged.
cache2gorefreshes an entry's TTL on access, so hot repos stay cached and idle entries fall out — the documented behaviour this cache has relied on, matching the hand-rolled cache it replaced. Whether repo-access decisions should also carry a freshness bound is a separate question: a fixed max age would make every hot repo refetch on a timer, so it wants singleflight or a shared store first. Out of scope here.Tests
TestRepoAccessCacheIdentityScopedKeys— same identity maps to the same key, different identities map to different keys, and the raw identity never appears in a key.TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable— two identities cannot share a trust decision, and both are stored in a single shared table rather than a table per identity.TestRepoAccessCacheIdentityScopedEntriesAreReclaimed— per-identity entries are reclaimed by ordinary idle-TTL cleanup, so cache storage stays bounded.TestGetRepoAccessCacheIsolatesTrustDecisionsPerIdentity(pkg/github) — mirrors the HTTP server's exact construction pattern end-to-end.I confirmed each of these fails against the pre-fix code and passes with the fix.
script/lintandscript/test(go test -race ./...) both pass.Fixes #3107
Acknowledgments
Thanks @YuvalElbar6 for the report that led to this hardening.