Skip to content

fix(lockdown): isolate repo-access cache per caller identity - #3113

Merged
SamMorrowDrums merged 8 commits into
mainfrom
sammorrowdrums-issue-3107-use-bounded-isolated-expiry-for-lockdown-c56a37
Aug 19, 2026
Merged

fix(lockdown): isolate repo-access cache per caller identity#3113
SamMorrowDrums merged 8 commits into
mainfrom
sammorrowdrums-issue-3107-use-bounded-isolated-expiry-for-lockdown-c56a37

Conversation

@SamMorrowDrums

@SamMorrowDrums SamMorrowDrums commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

In HTTP mode, the pkg/lockdown repo-access cache was shared across callers.

RequestDeps.GetRepoAccessCache builds a fresh *lockdown.RepoAccessCache per request, but entries were keyed on owner/repo alone inside a process-wide cache2go table. 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 RepoAccessCache for a single process-wide identity.

Fix

  • Added 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.GetRepoAccessCache applies WithIdentity(tokenInfo.Token) per request. RepoAccessOpts is 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. cache2go refreshes 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/lint and script/test (go test -race ./...) both pass.

Fixes #3107

Acknowledgments

Thanks @YuvalElbar6 for the report that led to this hardening.

…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>
@SamMorrowDrums
SamMorrowDrums requested a review from a team as a code owner August 19, 2026 12:22
Copilot AI balanced review requested due to automatic review settings August 19, 2026 12:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread pkg/github/dependencies.go Outdated
SamMorrowDrums and others added 7 commits August 19, 2026 14:50
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.
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>
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 SamMorrowDrums changed the title fix(lockdown): bound repo-access cache expiry and isolate it per identity fix(lockdown): isolate repo-access cache per caller identity Aug 19, 2026
@SamMorrowDrums
SamMorrowDrums merged commit 95b347e into main Aug 19, 2026
19 checks passed
@SamMorrowDrums
SamMorrowDrums deleted the sammorrowdrums-issue-3107-use-bounded-isolated-expiry-for-lockdown-c56a37 branch August 19, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Isolate lockdown repository access cache per request identity

2 participants