Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Workload Identity Issuance

Update 2026-09-22: the Light CLI no longer uses this service. It is open source and downloadable anywhere, so any credential it could be enrolled with is as public as the download; it is a public client that signs the user in and calls the Gateway with the user’s token alone (see Light CLI). The issuer remains for workloads deployed in a controlled pipeline (agents, runners), where the delivery of the credential can be trusted.

Status: design proposal, 2026-09-19. Nothing below is implemented. Today, per-workload mTLS credentials are produced by hand-run scripts (prepare.py, prepare-portal-ingress.py in portal-config-loc/all-in-lt) that generate a fixture CA, mint leaf certificates with multi-year validity, and require an operator to re-run them and redeploy whenever a credential expires or a new caller needs to be added. This document proposes replacing that with an automated, SPIFFE/SPIRE-shaped issuance service. The current implementation boundary is recorded at the end of this document.

Why This Is Needed Now, Not Later

Two failures in the same operating session motivated this: an ingress credential minted with a 1-day validity expired and silently broke a caller with no alert, and a separate A2 activation appeared inert because a config-file mount and a compose overlay had drifted out of sync with no automated check that they matched. Neither failure was a design flaw in crates/light-security/src/dual_identity.rs — the authentication contract held correctly in both cases, fail-closed as designed. The gap is entirely operational: nothing renews a credential before it expires, and nothing mints a new one without a human running a script against a live container.

This is the same problem the Light CLI design already identified and deferred as “Slice 4: Multi-Install” — exact-leaf pinning in AppProfile.peer_sha256 does not scale past a handful of long-lived services. That deferral was correct for a single-install pilot. It stops being correct as soon as any caller population grows past what a human can re-mint by hand, which is already true for the current five-service fixture and would be untenable at 1000 agents or 1000 CLI installs.

Decision And Scope

Introduce a dedicated issuance service that holds CA signing authority, issues short-lived leaf certificates to workloads on request, and expects every workload to renew automatically before expiry using its current, still-valid certificate as proof of possession. This is the SPIFFE/SPIRE model (workload API, rotating SVIDs), adopted in shape: purpose-built rather than an embedded SPIRE server, so VM and Kubernetes deployment are both handled through this issuer’s own bootstrap contract rather than SPIRE’s platform-specific attestation plugins (see Decisions).

Explicitly not config-server’s job. Config-server distributes policy — RoutePolicy, AppProfile maps, endpoint rules, CA trust bundles — as a broadcast snapshot every service already pulls at startup. Minting a private key and signing a certificate is a narrower, per-instance, authenticated, individually-audited operation with different sensitivity. Keeping them separate means config-server’s blast radius stays what it is today (wrong policy served) and does not grow to include “wrong key signed.” Config-server’s role in this design is limited to distributing the CA trust bundle and the issuer’s reachable address — both of which are genuinely policy, not key material.

Out of scope: a general-purpose PKI product; hardware attestation; issuance for anything outside this system’s own service-to-service and CLI-to-Gateway boundary; replacing OAuth bearer tokens for the user leg, which this does not touch.

Architecture

flowchart LR
    subgraph Issuer[Identity issuance service]
        CA[CA signing key<br/>HSM/KMS-backed]
    end
    CS[config-server] -- CA trust bundle + issuer address --> W1
    CS -- CA trust bundle + issuer address --> W2
    W1[Workload: light-gateway] -- CSR + bootstrap token, once --> Issuer
    W1 -- CSR + current cert as proof of possession, on renewal --> Issuer
    Issuer -- short-lived leaf cert --> W1
    W2[Workload: CLI install] -- CSR + pairing-derived token, once --> Issuer
    Issuer -- short-lived leaf cert --> W2
    W1 -- mTLS, CA-chain trust --> W2

Each workload:

  1. Generates its own key pair locally. The private key never leaves the host, matching the existing enrollment precedent in controller-rs’s runner admission and the CLI design’s enrollment section.
  2. Authenticates to the issuer once, at first bootstrap, using whatever credential establishes “this install is allowed to exist” for its category — an enrollment token for a service, a pairing-derived grant for a CLI install (reusing the pairing flow already proposed in Light CLI).
  3. Receives a short-lived leaf certificate (hours, not years) chained to the CA, carrying a certificate attribute that binds the leaf to a specific install identity — the CA-based peer trust option already named as recommended in the CLI design.
  4. Renews automatically, well before expiry, by presenting its still-valid certificate back to the issuer as proof of possession and receiving a new one. No human, no redeploy, no script re-run.

Revocation of one install does not touch any other install’s credential. With short-enough lifetimes, non-renewal is sufficient; a compromised install can additionally be denied at the next renewal via a revocation list the issuer checks, scoped to one install ID.

One Cert Per Instance, Never Shared

At any fleet size — five services today, 1000 agents or 1000 CLI installs tomorrow — every instance holds its own unique leaf, never a cert shared across instances. Sharing collapses two properties this system depends on:

  • Revocation granularity. Pulling one compromised install must not require rotating a credential every other install also presents.
  • Audit attribution. An action’s audit record must be traceable to the one install that performed it, matching the CLI design’s requirement that “the audit record carries the install ID alongside the user.”

What scales is not the number of trusted leaves but the shape of the trust rule. Today, AppProfile.peer_sha256 is an explicit, exact-match list — appropriate for five long-lived services, unworkable for a fleet. Under this design, the Gateway-side rule changes from “this exact leaf” to “any certificate chaining to this CA, whose bound attribute matches this route’s expected service/role” — a rule that does not grow with fleet size, while the underlying leaf population still grows one-per-instance. This is the CA-based AppProfile variant already proposed, not weakened, in the Light CLI design.

What Config-Server Continues To Own

  • The CA trust bundle (public certificates only — never a signing key).
  • The issuer’s reachable address, so a workload with a live config connection can always find where to renew.
  • The RoutePolicy/AppProfile role-matching rules that consume the identity this system issues.

This preserves the existing property that a live config-server snapshot is the single source of truth for “what does this service currently trust,” without asking it to also hold or use signing key material.

Relationship To Existing Manual Tooling

prepare.py and prepare-portal-ingress.py are not being thrown away immediately; they are the fixture this design replaces once the issuer exists, and remain the right tool for a from-scratch local CA bootstrap in the interim. The distinction to preserve: those scripts mint credentials by hand-signing with a locally generated fixture CA key and a DB-extracted Portal signing key; this design’s issuer holds one CA consistently and signs on authenticated request, with rotation as a first-class behavior rather than an operator-run step.

Decisions

These were open questions during design and are now settled.

Build, not adopt SPIRE

Purpose-built, as one Rust crate/service reusing light-security’s existing verification code. Driving reason: this must support both VM deployment (the current pilot shape) and Kubernetes deployment on the same issuance contract, and a purpose-built issuer keeps that a matter of how a workload obtains its bootstrap credential (below), not a dependency on SPIRE’s own Kubernetes-specific attestation plugins and operational model.

CA key custody

On disk for now, with the interface shaped so the signing operation can be swapped to AWS KMS or Google Cloud KMS/HSM without changing any workload- or Gateway-facing contract. Concretely: isolate “sign this CSR with the CA key” behind one narrow internal call in the issuer, so the only code that changes when moving to a managed HSM/KMS is that one call’s implementation, not the enrollment flow, the renewal flow, or any consuming service.

Bootstrap credential: reuse the existing long-lived Portal token, once

Every instance today — gateway, workflow, agent, or otherwise — already obtains a long-lived token from Portal to authenticate to config-server and Controller, plus a CA bundle (bootstrapCaCertPath in startup.yml) used only to verify config-server’s own TLS server certificate. There is no existing client-identity certificate to preserve; the token is the only credential actually doing authentication today, which makes it a reasonable “this install is authorized to exist” fact to reuse.

The refinement: use it for first issuance only, not for every renewal. The issuer accepts the long-lived Portal token exactly once per install to mint the first short-lived leaf certificate. Every renewal after that authenticates by proof-of-possession of the previously issued certificate’s private key, not by presenting the token again. This matters because a bearer token is portable — anyone holding it can replay it from anywhere — while a private key that must prove possession is not. Reusing the token repeatedly for renewal would just relocate the CLI’s original bearer-token weakness into this system; reusing it once, to bootstrap a possession-bound credential, does not.

The CLI does not have a pre-provisioned Portal service token the way a container workload does, so its bootstrap path stays what Light CLI already proposed — Portal-initiated pairing — while service workloads bootstrap from their existing long-lived token. Both terminate at the same issuer and the same first-issuance behavior.

Configurable lifetime and renewal lead time, per deployment environment

Made part of the issuer’s own configuration, distributed like any other environment-scoped value (matching the existing envTag convention — loc/dev/prod), for example:

  • portal-config-loc (pilot, VM): 10-day leaf lifetime, renew 1 day before expiry.
  • Production: 1-day leaf lifetime, renew 1 hour before expiry.

The environment is bound into the certificate. An issuer that serves several environments takes the environment from the bootstrap token at first issuance and writes it into the certificate’s subject (O=<envTag>; the URI SAN is unchanged). Gateway caTrust profiles name the expected environment and match it against this verified subject attribute, in addition to the issuer digest, role, and service ID. Renewal is refused with EnvironmentMismatch (HTTP 403) unless the request names the environment in the presented certificate, so a holder of a production certificate cannot renew into the development policy’s ten-day lifetime by asking for it. A certificate with no environment in it (issued before this) cannot be renewed and must enroll again.

The issuer digest is verified, not assumed. caTrust.issuerSha256 is matched against the certificate after the leaf in the chain the client sends. The client chooses that order and content, so the Gateway (the pingora-core patch) uses it only when that certificate really signed the leaf: the names match and the leaf’s signature verifies under its key. Otherwise a leaf from one trusted CA could carry a different trusted CA’s certificate to satisfy that CA’s profile.

Reconsidered from an initial 1-hour/5-minute proposal: the future production revocation path is specified separately in Service Identity and Revocation, but is not implemented yet. Until Portal and Config Server distribute that policy, expiry is the effective revocation bound. A 1-day lifetime bounds a leaked key’s usable window far tighter than today’s multi-year certs, at roughly 1/24th the renewal volume of an hourly cycle — a meaningful difference at 1000+ instances. The lead time should be read as “renewal must be complete by,” not “start trying at”; the renewal loop should begin attempting well before the 1-hour mark, with backoff and retry, so a transient issuer or network blip has room to resolve before anything actually expires. Tighten these figures later once the renewal loop has proven reliable at scale, rather than starting at the tightest setting.

Sequencing: build the issuer first, CLI is the first consumer

The CLI is a clean slate with no existing credential model to migrate, unlike Gateway/Workflow/agents, which already have hand-mounted certs from prepare.py in a working (if manual) state. Proving the issuer against a new consumer first, then migrating the existing A2 fixture credentials to it afterward, is lower risk than changing the load-bearing service-to-service path first.

Open Questions

  • Renewal loop robustness at the production 1-day lifetime. Retry policy, backoff, and alerting when a renewal attempt fails, given the smaller margin for error than the pilot’s 10-day/1-day figures.
  • Revocation list distribution. Whether the per-install revocation list the issuer checks at renewal is itself distributed through config-server (consistent with “config-server owns policy”) or held only by the issuer.
  • Relationship to the CLI’s Slice 4. Slice 4 becomes “adopt this issuer” rather than “build CA-based trust from scratch” — the Light CLI plan should be updated to reflect that once this service exists.

Current Implementation Boundary

Checked 2026-09-19 against the working tree.

CapabilityCurrent boundary
Certificate issuanceManual, via prepare.py / prepare-portal-ingress.py in portal-config-loc/all-in-lt; no automated issuer exists
Peer trustExact leaf pinning only (AppProfile.peer_sha256); no CA-based attribute matching
RotationNone; credential lifetime is fixed at mint time and expiry requires a manual re-run
RevocationRepublishing the policy snapshot with the entry removed; no per-install revocation list
Config-server’s roleDistributes RoutePolicy/AppProfile snapshots today; does not distribute a CA trust bundle as a distinct artifact
Bootstrap/enrollmentcontroller-rs runner admission is the closest existing precedent for issuing a durable per-instance identity bound to an approved peer

References