Skip to content

fix: scope GitHub App tokens by owner#597

Merged
mariusvniekerk merged 8 commits into
mainfrom
fix/github-app-owner-scope
Jun 26, 2026
Merged

fix: scope GitHub App tokens by owner#597
mariusvniekerk merged 8 commits into
mainfrom
fix/github-app-owner-scope

Conversation

@mariusvniekerk

@mariusvniekerk mariusvniekerk commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator
  • Scope GitHub App installation tokens to the matching repository owner instead of the whole host.
  • Allow distinct same-host GitHub App credentials and target management commands by owner or app id.
  • Keep reload validation and nested GraphQL review-thread reads on the same owner-scoped token path.
  • Fix the manifest payload so GitHub accepts webhook-disabled private app creation.

GitHub App installation tokens are account-scoped, but middleman previously treated an app entry as if it covered an entire host. That made the install CLI warn about unrelated owner repos and risked using or validating an installation outside the repository owner it can actually read.

This keeps app candidates in the host credential chain while resolving them only when the request or startup validation carries a matching GitHub owner. Multiple installations for the same host can now coexist, and install no longer reports other-owner repos as unreachable.

Validation: go test ./internal/tokenauth ./internal/config ./internal/github/... ./internal/server/e2etest ./cmd/middleman ./cmd/middleman-github-app -shuffle=on; make test-short; ./middleman-github-app list; scripts/context-sync --check; git diff --check --cached

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
@roborev-ci

roborev-ci Bot commented Jun 25, 2026

Copy link
Copy Markdown

roborev: Combined Review (e0649fd)

Medium confidence: merge should wait on the owner-scoping gaps below; no concrete security regression was reported.

Medium

  • internal/server/config_reload.go:536 - Reload validation probes GitHub App token chains without applying plan.GitHubOwner. App-only configs accepted at startup can be rejected during config reload because owner-scoped app candidates are skipped and validation falls through to missing PAT/gh credentials. Mirror collectProviderTokenSources by wrapping the probe context with tokenauth.WithGitHubOwner when plan.GitHubOwner is set, and add a reload test for an app-only owner-scoped config.

  • internal/github/client.go:1808 - Paged review-thread comment GraphQL requests only include threadID/cursor, so the new request owner detector cannot scope token selection. GitHub-App-only installs will fail on review threads with more than 100 comments even though the matching installation token exists. Apply tokenauth.WithGitHubOwner(ctx, owner) before creating these requests, or otherwise pass the owner through token resolution, and cover the paged-comment path.

  • cmd/middleman-github-app/create.go:399 - install still short-circuits to refreshing the first recorded installation. Once one account is installed, there is no CLI path to add the same app on another owner despite the new config/token model supporting multiple installation rows per host. Add an explicit add-installation path or account/installation selector that bypasses refresh when the user wants a new owner installation, then append the new row.


Panel: ci_default_security | Synthesis: codex, 10s | Members: codex_default (codex/default, done, 6m16s), codex_security (codex/security, done, 3m29s) | Total: 9m55s

mariusvniekerk and others added 3 commits June 25, 2026 16:59
The GitHub App owner-scope context should preserve the invariant without baking a maintainer account name or low-level call path into the docs. Keep the operational rule generic and leave implementation-specific cache detail next to the tokenauth state it explains.

Validation: go test ./internal/tokenauth -shuffle=on; scripts/context-sync --check; git diff --check

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Private GitHub Apps are owned by one account in the setup middleman creates, so same-host multi-account support must come from multiple app credentials rather than treating one app as installable everywhere.

Management commands now disambiguate same-host apps by owner, deletion removes the selected app entry, and validation rejects duplicate app owners or installation accounts while allowing distinct same-host app credentials.

Validation: go test ./internal/config ./cmd/middleman-github-app -run 'TestGitHubAppsValidation|TestTokenSourceChainIncludesGitHubAppsForEachInstalledAccount|TestCreate|TestInstall|TestDelete|TestOpen|TestUninstall' -shuffle=on; go test ./internal/tokenauth ./internal/config ./internal/github/... ./internal/server/e2etest ./cmd/middleman ./cmd/middleman-github-app -shuffle=on; scripts/context-sync --check; git diff --check

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
Same-host GitHub App configs now model separate private app credentials, so management has to target the credential itself instead of treating installation account as a replacement key. Without that, a duplicate install could overwrite the wrong row and ownerless legacy rows had no stable selector.

Reload and GraphQL reads also need the repository owner carried into token resolution; otherwise app-only installs can fail during hot reload or nested review-thread pagination even though startup and top-level reads work.

Validation: go test ./internal/server ./internal/github ./cmd/middleman-github-app -run 'TestValidateReloadProviderTokenSourcesScopesGitHubAppByOwner|TestListPullRequestReviewThreadsScopesPaginatedCommentAuthByOwner|TestCreateAllowsOrgOwnedAppForSameHost|TestManageSameHostAppsByOwnerOrAppID|TestInstallRejectsDuplicateInstallationAccountAcrossApps|TestListReportsInstallationAndRateBudget' -shuffle=on; go test ./internal/server -run 'TestValidateReloadProviderTokenSourcesScopesGitHubAppByOwner' -shuffle=on; go test ./cmd/middleman-github-app -shuffle=on; go test ./internal/github -shuffle=on; GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go run ./cmd/testify-helper-check ./cmd/middleman-github-app; scripts/context-sync --check; git diff --check. Broader go test ./internal/tokenauth ./internal/config ./internal/github/... ./internal/server ./internal/server/e2etest ./cmd/middleman ./cmd/middleman-github-app -shuffle=on failed only in existing internal/server fleet tmux monitor tests under shuffle.

Generated with Codex
Co-authored-by: Codex <codex@openai.com>
@roborev-ci

roborev-ci Bot commented Jun 25, 2026

Copy link
Copy Markdown

roborev: Combined Review (6aa38c2)

Medium-risk issues remain in GitHub App install recovery and repository listing when app state is scoped by owner.

Medium

  • cmd/middleman-github-app/create.go:433
    install with the browser open marks every existing GitHub-side installation as known, so it only accepts a newly-created installation ID. If the app is already installed but not recorded locally, such as after a failed selected-repository coverage check or restored config, GitHub will usually reuse/configure the existing installation and the CLI will time out instead of recording it.
    Fix: Allow an existing matching installation to be selected/refreshed, or fall back to matching existing installations when no new ID appears. Add coverage for re-running install after an unrecorded existing installation.

  • internal/github/client.go:1375
    ListRepositoriesByOwner switches to /installation/repositories whenever any GitHub App is active on the host. For an owner without a matching app installation, token resolution falls through to PAT/gh credentials, but that endpoint requires an installation token, so glob/import repository listing fails for PAT-backed owners sharing a host with another owner’s app.
    Fix: Choose the installation repositories endpoint only when the active descriptor has a matching app candidate for the requested owner; otherwise use the normal authenticated/org/user repository listing path.


Panel: ci_default_security | Synthesis: codex, 9s | Members: codex_default (codex/default, done, 9m37s), codex_security (codex/security, done, 4m39s) | Total: 14m25s

mariusvniekerk and others added 2 commits June 25, 2026 19:52
…recovery

When a host carries a GitHub App installation for one owner, other owners
on that host stay on the PAT/gh chain. ListRepositoriesByOwner gated the
installation-repositories endpoint on any app being active on the host, so
listing repos for a PAT-backed owner that shares the host with another
owner's app hit an installation-token-only endpoint with a PAT and 403'd.
Gate that endpoint on whether reads for the requested owner actually
resolve to an app token, mirroring the per-owner scoping token resolution
already applies.

The install flow snapshots existing installations as known and waits only
for a brand-new id. But editing an installation's repository access or
re-running after a selected-repo coverage failure reconfigures the existing
installation without minting a new id, so the poll never completes -- the
dead-end the coverage error's own "re-run install" guidance would otherwise
lead users into. On poll timeout with exactly one installation present,
adopt it; multiple installations stay ambiguous and keep the timeout, and a
user interrupt is never treated as an adoptable timeout.

Validation: go test ./internal/tokenauth ./internal/github ./cmd/middleman-github-app ./internal/config -shuffle=on; go vet; golangci-lint run (0 issues)

Generated with Claude Code (Opus 4.8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GitHub App owner-scoping invariant already said reads must resolve app
tokens with the repository owner in context, but a host-wide gate on an
installation-token-only endpoint still slipped a PAT-backed owner onto an
endpoint its credential cannot use. Make the doc say owner scoping governs
endpoint selection, not just token resolution, so a future installation-token
endpoint is not gated on host-wide app presence and re-introduce the same
403.

Also note that re-running install after a coverage failure reconfigures the
existing installation without a new id, so the install flow must adopt an
already-present installation rather than waiting only for a new one -- the
recovery the coverage error's "re-run install" guidance depends on.

Generated with Claude Code (Opus 4.8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Jun 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (16d4a48)

Summary verdict: one Medium issue remains; no High or Critical findings were reported.

Medium

  • cmd/middleman/provider_startup.go:176 - GitHub read rate tracking and sync budget remain keyed only by platform + host, even though the change allows multiple owner-scoped GitHub App credentials on the same host. GitHub App limits are per installation, and PAT fallback is a separate credential, so one owner’s low or exhausted quota can throttle unrelated repos on the same host.

    Fix: Key rate tracking and budget gating by the effective credential scope, such as host plus installation account/app installation or PAT fallback identity, and use that scoped key consistently from repo scheduling through response tracking.


Panel: ci_default_security | Synthesis: codex, 7s | Members: codex_default (codex/default, done, 5m24s), codex_security (codex/security, done, 4m34s) | Total: 10m5s

Review of the install-recovery path surfaced two ways it could record the
wrong installation. First, recovery ran for any pollUntil error, so a
transient list-installations failure could be mistaken for "nothing
appeared" and immediately adopt a stale installation, hiding the API error.
pollUntil now wraps a sentinel on its deadline and recovery only runs for
that clean timeout; probe errors and user interrupts surface unchanged.

Second, adoption accepted any sole installation regardless of account. A
lone installation on an account that owns none of the configured repos
would be recorded while the CLI reported success, leaving the intended
owner on the PAT path. Adoption is now bounded by intent: the sole
installation's account must be the recorded installation account or own a
configured repo that resolves to the app. Multiple installations, an
unrelated lone account, a probe error, or an interrupt keep the timeout.

The owner-scoped repo-listing fix stays covered at the github client level
against a real HTTP server, where the installation-vs-org endpoint decision
lives; the server e2e harness injects a mock GitHub client that bypasses
that decision, so a full-stack case there would assert the mock rather than
the gating.

Validation: go test ./cmd/middleman-github-app ./internal/github ./internal/tokenauth ./internal/githubapp/... ./internal/config -shuffle=on; go vet; golangci-lint run (0 issues)

Generated with Claude Code (Opus 4.8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Jun 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (bc7cbfe)

Medium findings:

  • internal/github/client.go:1240 - GetRateLimitSnapshot is still ownerless, so scoped GitHub App candidates are skipped and /rate_limit falls back to the PAT/gh credential. RefreshRateLimitSnapshots then writes that PAT budget into the app-backed read tracker, which can pause app-token sync when the PAT is exhausted or hide an exhausted installation. Fix by scoping the snapshot to the relevant app owner/installation, or skip app-backed read tracker refreshes from ownerless snapshots and rely on app-authenticated responses.

  • cmd/middleman-github-app/create.go:433 - Existing installations are marked as known only when the browser is opened. With install --no-browser, the first poll treats any pre-existing installation as new, bypassing the new intent and ambiguity checks in adoptSoleInstallation, so a stale unrelated or ambiguous installation can be recorded immediately. Snapshot existing installations before polling regardless of browser mode, then let timeout adoption handle the sole intended existing install, or apply the same intent checks before accepting installs in no-browser mode.


Panel: ci_default_security | Synthesis: codex, 8s | Members: codex_default (codex/default, done, 8m20s), codex_security (codex/security, done, 2m43s) | Total: 11m11s

Marking a clean poll deadline by wrapping a sentinel in the returned error
leaked the marker text into the user-facing "timed out after ..." message
for both the install and delete CLIs. pollUntil now returns a
pollTimeoutError whose Error() keeps the plain message while Is() still
matches errPollDeadline, so recovery can branch on a clean timeout without
changing what the user sees.

The install-recovery invariant doc said probe errors and interrupts "keep
the timeout," which contradicts the behavior: those are not clean deadlines,
so they surface the original error or cancellation and never adopt. Only an
unrelated or ambiguous installation after a clean deadline leaves a timeout.
Reword the invariant to match, and strengthen tests: the probe-error case
now asserts the listing error surfaces (not a timeout), and a pollUntil unit
test pins that only a deadline matches errPollDeadline while probe errors and
cancellation pass through.

Validation: go test ./cmd/middleman-github-app ./internal/github ./internal/tokenauth ./internal/githubapp/... -shuffle=on; go vet; golangci-lint run (0 issues)

Generated with Claude Code (Opus 4.8)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Jun 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (4b068ef)

Review verdict: changes are mostly sound, but one medium-risk credential/rate-state bug and one medium-risk install-flow bug need attention.

Medium

  • internal/config/config.go:1807
    Rate/backoff state is still keyed only by host in cmd/middleman/provider_startup.go:176 and updated from whichever credential answered in internal/github/client.go:1203. Now that same-host repos can mix GitHub App tokens and PAT/gh credentials by owner, this can mix PAT and installation-token rate headers. An exhausted PAT could pause app-backed syncs, or an app response could hide PAT exhaustion for PAT-backed repos.
    Fix: either keep rejecting mixed app/PAT read credentials per host until rate tracking is owner/credential scoped, or key REST/GraphQL trackers and backoff checks by the same resolved owner/candidate used for token selection.

  • cmd/middleman-github-app/create.go:433
    Existing installations are only added to known when the browser is opened. With install --no-browser, a stale pre-existing installation is treated as new at line 461 and recorded immediately, bypassing the new sole-installation intent checks and allowing unrelated or ambiguous accounts to be saved.
    Fix: snapshot known installations before polling in both modes, then use the bounded adoption path to record only a sole intended existing installation.


Panel: ci_default_security | Synthesis: codex, 10s | Members: codex_default (codex/default, done, 8m18s), codex_security (codex/security, done, 4m20s) | Total: 12m48s

@mariusvniekerk mariusvniekerk merged commit 25800de into main Jun 26, 2026
17 checks passed
@mariusvniekerk mariusvniekerk deleted the fix/github-app-owner-scope branch June 26, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant