Skip to content

broker: mint GitHub App installation tokens, so a pushing forge credential stops being a human action #2281

Description

@rubenvdlinde

Follow-up to #2277 (github-push), raised while clearing hydra's builder-write-access-hardening task 1.1.

What is actually blocked

github-push gives a pushing forge credential a compliant home. It does not give it an origin. Today the value must be a fine-grained PAT that a human creates at github.com, because GitHub has no API that creates a PAT.

I checked whether Doriath closes this, since it is the fleet's credential-custody service. It does not, and it says so in its own specs. Verified against doriath at 12ab3f9cf, with a positive control on each absence claim (grep -rn 'openssl_[a-z_]*(' lib/ returns 71 call sites across 7 files, so the method reaches the crypto code):

  • openssl_sign appears zero times. The only *_sign calls are openssl_csr_sign in CertificateAuthorityService (X.509 issuance from a CSR, against Doriath's own internal CA — github.com does not trust it).
  • JWSBuilder is absent from the app. JwtAuthService imports JWSVerifier only: it verifies inbound RS256 assertions and returns bin2hex(random_bytes(32)). It cannot produce a signed token.
  • No outbound call to any credential provider exists. grep -rn 'IClientService\|newClient()' lib/ returns exactly two egress sites — HIBP and the SIEM webhook.
  • The specs state the boundary outright: "Doriath stores static, zero-knowledge secrets (ADR-003) and cannot mint dynamic credentials like Vault; a lease bounds the access-grant lifetime … it is not credential generation" (openspec/specs/machine-secret-leases/spec.md:10). "Rotation" only flags for a human — markRotated() compares keyUpdatedAt and returns requiresRotation: true if nobody changed it.

Doriath provides custody, and it does that well: ciphertext-only storage, JWT-Bearer machine auth, leases with TTL/renew/revoke. That is the right division of labour. It is simply not an origin.

The one machine-mintable shape

POST /app/installations/{installation_id}/access_tokens — confirmed against the API docs — accepts a repositories array and a permissions object, and returns a token that expires in one hour. {"repositories": ["hydra"], "permissions": {"contents": "write"}} is task 1.1's requirement exactly, and it omits workflows by construction rather than by trusting whoever clicked the checkboxes.

⚠️ Omitting repositories/permissions inherits the full installation grant, so the narrowing must be sent explicitly on every mint. That is a fail-open default and the implementation must not rely on the caller remembering.

Correcting a claim I inherited

The blocker was recorded as "the broker cannot do an RSA signing operation". That is not the blocker. openssl_sign() is a PHP builtin and the broker is PHP; generic-jwt's own comment ("the app generates and signs the JWT itself — the broker cannot sign") describes a policy, not a missing primitive. The real blockers are:

  1. No GitHub App exists for ConductionNL's own use. GET /orgs/ConductionNL/installations returns 14 installations, all third-party (atlassian, codecov, cursor, …). There is nothing to mint from.
  2. Creating one cannot be done headlessly. There is no REST endpoint. Even the manifest flow requires POSTing an HTML form in a browser to https://github.com/organizations/ConductionNL/settings/apps/new, a human clicking Create GitHub App, and a redirect carrying a code valid for one hour — only then is POST /app-manifests/{code}/conversions server-to-server.

So the human step is not eliminated; it moves from per credential, per repo, per expiry to once, for the organisation. That is the whole win, and it is a large one.

What this issue asks for

  • A one-time org-admin registration of a Conduction GitHub App (Contents: Read and write, no Workflows), installed on selected repositories. Human, once. rubenvdlinde is an org admin.
  • Custody the App private key + app id + installation id in Doriath through the existing broker path — this is precisely what Doriath is for.
  • A broker provider that, on resolve, signs the RS256 JWT (openssl_sign, 10-minute exp, iss = app id), exchanges it at POST /app/installations/{id}/access_tokens always sending repositories and permissions explicitly, and returns the 1-hour token to resolveInjectable().
  • Keep feat(broker): a pushing forge credential finally has a compliant home (catalogue v1.8.0) #2277's absence probe as the acceptance test: attempt PUT /repos/{o}/{r}/contents/.github/workflows/probe.yml and require the refusal. A 201 means the grant is wrong.

Until then

The credential is a human action, and nothing available to an agent substitutes for it. For the record, the only forge credential an agent can reach on the dev host is the gh CLI OAuth token, whose scopes are gist, read:org, repo, workflow — account-wide and carrying workflow write. It is the exact credential task 1.1 forbids and must not be stored as github-push.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions