Skip to content

GitHub App: registration and token minting

Backlog task 13, design doc SS9 ("Where secrets live") and SS10. One GitHub App per Kapelle deployment, installed on each org's team repos; agents never see the App's own credentials, only 1-hour installation tokens narrowed to their team's repos.

Registering the App

Create the App at https://github.com/organizations/<org>/settings/apps/new (or https://github.com/settings/apps/new for a personal-account install). Settings that matter:

  • Webhook: active and pointed at the gateway's GitHub surface adapter endpoint (/webhooks/github, backlog task 31 wires the receiving side) for a real deployment. Not required to be reachable, or even active, for local development -- see "Local development" below. Set a webhook secret and store it at kapelle/surfaces/github (see kapelle_controller.openbao.layout.surface_path), not in this doc or the App's own config.
  • Permissions (repository, unless noted): | Permission | Access | Why | |---|---|---| | contents | Read & write | Clone, push to agent/<team>/* branches | | pull_requests | Read & write | Open/update PRs, read review state | | issues | Read & write | Read issues, comment, close | | checks | Read & write | Read CI status, post check runs | | metadata | Read (mandatory minimum) | Required by every App |

Grant nothing else. A minted token is further narrowed per team by kapelle_controller.github.minting.TeamGitHubConfig.permissions (never wider than the App's own grant here -- GitHub rejects a token request for a permission the App doesn't hold), so this table is the ceiling, not what every team gets. - Subscribe to events: issues, issue_comment, pull_request (consumed by the gateway's GitHub surface adapter, backlog task 31). - Where can this GitHub App be installed?: "Only on this account" for a single-org deployment; "Any account" only if Kapelle will serve multiple GitHub orgs from one App registration.

After creation:

  1. Generate a private key (App settings -> "Private keys" -> "Generate a private key") and store the PEM at kapelle/github/app's private_key field (see kapelle_controller.openbao.layout.GITHUB_APP) -- never on disk outside OpenBao, never committed.
  2. Note the App ID (top of the App's settings page) -- store it at the same path's app_id field.
  3. Install the App on the org, selecting only the repos Kapelle teams need -- "All repositories" defeats the point of per-team scoping. Note the resulting Installation ID (from the install URL, .../installations/<id>, or GET /app/installations) -- this is what kapelle_controller.github.minting.TeamGitHubConfig.installation_id needs for that org. One org's teams typically share one installation with access to all of that org's team repos; per-team narrowing happens at mint time via the repositories parameter (see below), not by installing the App separately per team.

Minting installation tokens

kapelle_controller.github.minting.GitHubTokenMinter.mint(team, repo) calls POST /app/installations/{installation_id}/access_tokens (authenticated with a fresh, short-lived App JWT signed from the stored private key -- kapelle_controller.github.app.RealGitHubAppClient), narrowed with:

{
  "repositories": ["<repo>"],
  "permissions": { /* that team's TeamGitHubConfig.permissions */ }
}

The response token is valid for 1 hour. mint caches it per (team, repo) and re-mints once fewer than 10 minutes remain (i.e. at the 50 minute mark), so a caller can call mint on every use without minting a fresh token each time. On team archive, GitHubTokenMinter.revoke_team calls DELETE /installation/token (authenticated with the token itself -- GitHub only lets a token revoke itself, not by installation ID) for every cached token, then drops them from the cache, so nothing survives archival even within its 1-hour window.

The GitHub API is called only through the GitHubAppClient protocol (kapelle_controller.github.app), so this can be swapped for a fake in tests without a real App registration or network access.

Local development

Registering a GitHub App requires a Homepage URL and, if the webhook is active, a reachable Webhook URL -- neither needs Kapelle to be publicly hosted:

  • Homepage URL: anything resolvable, e.g. the repo's own GitHub URL (https://github.com/<org>/<repo>). GitHub only displays it; nothing in this codebase reads it back.
  • Webhook: leave it inactive for local dev. Token minting (GitHubTokenMinter.mint, above) and the e2e vertical slice's PR step never receive a webhook -- they only need the App ID, private key, and installation ID to mint tokens, none of which involve the webhook path at all. An inactive webhook doesn't block anything except live inbound deliveries (issues/comments/PR events reaching the gateway's adapter).
  • To actually exercise live deliveries (the issues/issue_comment/ pull_request events the App subscribes to above, reaching kapelle_gateway.github.adapter.GitHubAdapter for real), forward them to your local gateway instead of activating and hosting a real endpoint. just github-webhook-forward wraps this (reading KAPELLE_GITHUB_OWNER/KAPELLE_GITHUB_REPO/KAPELLE_GITHUB_WEBHOOK_ SECRET from the environment, same as everything else in this doc) -- equivalent to:
gh webhook forward --repo <owner>/<repo> \
  --events issues,issue_comment,pull_request \
  --secret <the same value as KAPELLE_GITHUB_WEBHOOK_SECRET> \
  --url http://localhost:8100/webhooks/github

8100 is the gateway's own default KAPELLE_GATEWAY_HTTP_PORT (kapelle_gateway.config.GatewayConfig.http_port); /webhooks/github is the fixed route kapelle_gateway.webhooks.build_webhook_app mounts for this surface (alongside /webhooks/linear, /webhooks/jira) -- adjust the port if you've overridden it. gh webhook forward needs the gh webhook extension installed once (gh extension install cli/gh- webhook) and gh auth status authenticated against an account that can manage webhooks on the repo; it still needs the App's webhook marked active with a secret configured (GitHub requires one to sign deliveries) even though nothing else in local dev does -- use the same value you'd store at kapelle/surfaces/github (above) and pass it via KAPELLE_GITHUB_WEBHOOK_SECRET below (both to gh webhook forward's own --secret and to the running gateway process, which verifies deliveries against it -- they must match).

gateway-dev must already be running in another terminal for anything forwarded here to go anywhere (just gateway-dev).

  • Environment variables the gateway's GitHub surface actually reads (kapelle_gateway.github.tokens, verified against the code, not the App's own settings page): | Variable | Read by | Purpose | |---|---|---| | KAPELLE_GITHUB_APP_ID | tokens.py (RealGitHubTokenProvider fallback path) | The App ID, when minting locally instead of through the controller's gRPC (KAPELLE_CONTROLLER_GRPC_ADDR unset) | | KAPELLE_GITHUB_APP_KEY_FILE | same | Path to the App's private key PEM on disk | | KAPELLE_GITHUB_INSTALLATION_ID | same | The installation to mint tokens against | | KAPELLE_GITHUB_WEBHOOK_SECRET | tokens.py (required either way) | Verifies inbound webhook signatures -- not exposed over the controller's gRPC, always read directly | | KAPELLE_GITHUB_TEST_USER_ID / KAPELLE_GITHUB_TEST_USER_LOGIN | services/gateway/tests/github/test_github_live_integration.py only | The GitHub account the live integration test acts as (its @mention, sender.id/sender.login in synthetic webhook payloads) -- not read by any production code path |

The live integration test additionally needs KAPELLE_GITHUB_OWNER/ KAPELLE_GITHUB_REPO (a real throwaway repo) and either the three App variables above or a pre-minted KAPELLE_GITHUB_TOKEN; see that test's own module docstring for the full gate.

The throwaway repo must already have at least one commit on its default branch before either live test (this one, or eval/e2e/test_vertical_slice.py::test_pr_appears_with_closes_reference) can do anything -- confirmed live: a genuinely empty repo (0 branches) fails every check/commit/branch lookup with 409 Git Repository is empty, easy to mistake for the permissions gap below since both showed up in the same debugging session. An initial commit (e.g. a README) is enough; both tests create their own issues/branches/PRs against it from there.

Not implemented here

  • openbao-plugin-secrets-github was evaluated (design doc SS9) and rejected for now in favor of this small minting service -- the plugin is a smaller community project than OpenBao/Vault's own official plugins, and this minting service is a few hundred lines against a stable GitHub API, not worth taking on an extra OpenBao plugin dependency for.
  • Branch-scoping agent pushes to agent/<team>/* (design doc SS9, "Policy and audit") is a GitHub repository ruleset or gateway-side git-push inspection, neither implemented as part of task 13 -- the minted token's contents: write permission is repo-wide, not branch-scoped, until one of those lands.
  • ~~Exposing the minting service over the network (HTTP/gRPC) for credgw (Go) to call: out of scope here.~~ Done as of backlog task 16: the Controller gRPC service (contracts/proto/controller.proto, kapelle_controller.grpc_server.ControllerServicer.MintGitHubToken) exposes exactly this, on the controller's own gRPC port (default 127.0.0.1:8300) -- see "Consumers of the minting service" below for who calls it and how.

Consumers of the minting service

Two processes call MintGitHubToken today rather than talking to GitHub directly:

  • credgw (Go): credgw/creds.Minter's real implementation dials the controller's gRPC address.
  • The gateway's GitHub surface adapter (Python, backlog task 31): kapelle_gateway.github.tokens.github_token_provider_from_env() is the selector whoever wires that adapter into the real gateway process calls -- GrpcGitHubTokenProvider (this RPC) when KAPELLE_CONTROLLER_GRPC_ADDR is set (a bare host:port, e.g. 127.0.0.1:8300), or RealGitHubTokenProvider otherwise (mints straight from the same App id/private key/installation id below, over KAPELLE_GITHUB_APP_ID/ KAPELLE_GITHUB_APP_KEY_FILE/KAPELLE_GITHUB_INSTALLATION_ID -- no controller process needs to be reachable for this fallback). Either way, KAPELLE_GITHUB_WEBHOOK_SECRET is required directly (the webhook secret isn't exposed over gRPC -- it's a plain OpenBao KV read at kapelle/surfaces/github, per "Registering the App" above, not worth a minting round trip).