🤖 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:
- It never expires. Rotation is manual, so the credential's lifetime is unbounded in practice.
- 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:
- 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)
- 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)
- Spike: verify the unknowns above — especially the rotating-credential behaviour of
provider-upjet-github, live. (gates everything; if it fails, re-shape the epic)
- Stand up one scoped
GithubAccessToken generator for a single low-risk consumer; confine the
PEM; verify the token works and refreshes.
- Migrate
provider-upjet-github to a scoped short-lived token; drop the PEM from
github-config.
- Sweep any remaining GitHub-API consumers onto their own scoped generator instances.
- 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
Size
L (epic). Child 1 is S and gates the rest — its outcome may reshape children 2–5.
Problem
Machine authentication to the GitHub API is backed by a long-lived GitHub App private key.
OpenBao holds
app_id/installation_id/pematsecret/infrastructure/github/app(seeded as placeholders by
vault-config/job.yaml, then set by hand), and an ExternalSecretmaterializes the PEM itself into the
github-confignamespace, whereprovider-upjet-githubauthenticates with it.
Two properties make this the wrong shape:
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:
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)
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'sSecretStore authenticates as its own ServiceAccount against OpenBao's
kubernetesauth mount: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
GithubAccessTokengenerator — same generator family the platform already runs(
generators.external-secrets.io/v1alpha1, cf. the Password generators invault-seed/):(ESO GithubAccessToken generator)
permissionsandrepositories—this is what gives every application its own client identity and least privilege, rather than a
shared all-powerful credential.
refreshIntervalmust be comfortably under it), never the PEM.Out of scope — and why
Dex's GitHub connector
client_secretcannot be federated and is not covered here. That is anOAuth 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).
provider-upjet-githubtolerate a rotating credential? The Terraform GitHub providersupports
tokenauth as well as app auth, but if the Crossplane provider reads its credentialonce 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.
auth.privateKey.secretRef, and is that anacceptable single trust boundary?
permissionsmap.Proposed children (each independently shippable)
provider-upjet-github, live. (gates everything; if it fails, re-shape the epic)GithubAccessTokengenerator for a single low-risk consumer; confine thePEM; verify the token works and refreshes.
provider-upjet-githubto a scoped short-lived token; drop the PEM fromgithub-config.a GitHub App private key and that no two consumers share a generator instance.
Acceptance criteria
own short-lived token, scoped to only the permissions/repositories it needs.
window, not inferred from config (static validation ≠ working).
Size
L (epic). Child 1 is S and gates the rest — its outcome may reshape children 2–5.