Skip to content

Roadmap: replace the long-lived GitHub App private key with per-app, workload-identity-brokered short-lived tokens #2664

Description

@devantler

🤖 Generated by the Daily AI Engineer

Problem

Machine authentication to the GitHub API is backed by a long-lived GitHub App private key.
OpenBao holds app_id / installation_id / pem at secret/infrastructure/github/app
(seeded as placeholders by vault-config/job.yaml, then set by hand), and an ExternalSecret
materializes the PEM itself into the github-config namespace, where provider-upjet-github
authenticates with it.

Two properties make this the wrong shape:

  1. It never expires. Rotation is manual, so the credential's lifetime is unbounded in practice.
  2. It is unscoped. A private key is the App: it carries the App's full permission set across
    every repository the App is installed on. There is no way to hand one consumer a narrower slice —
    whoever holds the PEM holds the whole App.

The goal: no long-lived GitHub credential in any application namespace, and each application
authenticating with its own identity, scoped to only what it needs.

🔴 Verified constraints — checked at source, please don't re-litigate these

Two intuitive designs are dead ends, both confirmed before writing this:

  1. There is no GitHub-side workload identity federation for GitHub App authentication.
    Authenticating as a GitHub App requires a JWT signed by the App's private key. GitHub's OIDC
    support makes GitHub an identity issuer (Actions → AWS/GCP/Azure), not a relying party that
    accepts an external workload identity in place of a private key. "Keyless GitHub App auth" does
    not exist today.
    (OIDC concepts,
    OIDC reference,
    create-github-app-token#82)
  2. OpenBao has no GitHub secrets engine. Available engines are Cubbyhole, Databases, Identity,
    Key/Value, Kubernetes, LDAP, PKI, RabbitMQ, SSH, TOTP, Transit. GitHub appears only as an auth
    method (logging in to OpenBao with a GitHub token) — the opposite direction. So
    "OpenBao mints App installation tokens" is not available off the shelf.
    (OpenBao secrets engines)

Therefore the honest target is not "zero secrets anywhere". It is: the private key exists in
exactly one trust boundary and is never handed to an application; applications authenticate with
their own workload identity and receive only short-lived, narrowly-scoped tokens.

✅ What already exists — the identity half is done

Per-app workload identity federation to OpenBao is already in place. github-config's
SecretStore authenticates as its own ServiceAccount against OpenBao's kubernetes auth mount:

# k8s/bases/apps/github-config/secret-store.yaml
auth:
  kubernetes:
    mountPath: "kubernetes"
    role: "github-config"
    serviceAccountRef:
      name: "github-config"

Each app has its own ServiceAccount and its own OpenBao role and policy.

So the gap is not the identity — it is what that identity is handed. A correctly federated,
per-app workload identity currently exchanges its SA token for the App's long-lived private key.
It should exchange it for a short-lived, scoped token.

Target

Use the GithubAccessToken generator — same generator family the platform already runs
(generators.external-secrets.io/v1alpha1, cf. the Password generators in vault-seed/):

apiVersion: generators.external-secrets.io/v1alpha1
kind: GithubAccessToken
spec:
  appID: "..."
  installID: "..."
  repositories: ["..."]      # scoped per instance
  permissions: { contents: read }   # scoped per instance
  auth:
    privateKey:
      secretRef: { name: github-app-pem, key: key }

(ESO GithubAccessToken generator)

  • One generator instance per consumer, each with its own permissions and repositories
    this is what gives every application its own client identity and least privilege, rather than a
    shared all-powerful credential.
  • Consumers receive a ~60-minute installation token (expiry is fixed and non-configurable, so
    refreshInterval must be comfortably under it), never the PEM.
  • The PEM is confined to a single trust boundary and never materialized into an app namespace.

Out of scope — and why

Dex's GitHub connector client_secret cannot be federated and is not covered here. That is an
OAuth authorization-code credential used for interactive user login, not app-to-API
authentication; an installation token cannot replace it. It is a separate (and likely irreducible)
secret — track it on its own rather than conflating the two.
Related: platform#2663 (per-client Dex identities).

⚠️ Verification unknowns — these gate the design (first child)

  • Does provider-upjet-github tolerate a rotating credential? The Terraform GitHub provider
    supports token auth as well as app auth, but if the Crossplane provider reads its credential
    once at startup, a 60-minute token silently breaks reconciliation an hour later. This must be
    confirmed behaviourally — run it and watch a rotation happen — not by reading the chart.
    If it caches, this design needs a different consumer strategy and the epic changes shape.
  • Which namespace holds the PEM for the generator's auth.privateKey.secretRef, and is that an
    acceptable single trust boundary?
  • Whether every consumer's required GitHub permissions can actually be expressed in the generator's
    permissions map.

Proposed children (each independently shippable)

  1. Spike: verify the unknowns above — especially the rotating-credential behaviour of
    provider-upjet-github, live. (gates everything; if it fails, re-shape the epic)
  2. Stand up one scoped GithubAccessToken generator for a single low-risk consumer; confine the
    PEM; verify the token works and refreshes.
  3. Migrate provider-upjet-github to a scoped short-lived token; drop the PEM from
    github-config.
  4. Sweep any remaining GitHub-API consumers onto their own scoped generator instances.
  5. Retire the PEM's app-namespace materialization; add a CI guard that no app namespace receives
    a GitHub App private key and that no two consumers share a generator instance.

Acceptance criteria

  • No application namespace holds a GitHub App private key.
  • Every GitHub-API consumer authenticates with its own workload identity and receives its
    own short-lived token, scoped to only the permissions/repositories it needs.
  • No two consumers share a token or a generator instance.
  • Token refresh is verified behaviourally — observed working across at least one expiry
    window, not inferred from config (static validation ≠ working).
  • A CI guard prevents regression to a long-lived or shared GitHub credential.
  • The residual trust boundary holding the PEM is documented, with its rotation path.

Size

L (epic). Child 1 is S and gates the rest — its outcome may reshape children 2–5.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Fields

    No fields configured for Epic.

    Projects

    Status
    📥 Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions