ADR: Agent enrollment & SVID issuance
Status: Accepted (2026-06-07). The token-mint surface is both the admin API and the operator CLI.
This is the decision record. An ADR — architecture decision record — is a dated note capturing a decision and its why, kept even after the code moves on: it records what was decided then, and gains status addenda when anything changes since. The operator how-to (mint a token, enroll an agent, rotate, revoke) lives in
docs/agent/enrollment.md. If you want to run enrollment, read that; if you want to know why it works this way, read this.
The plain version
An agent only gets to talk to the control plane if it presents a valid mTLS client certificate — mutual TLS, where both ends of the connection prove themselves with certificates — whose SPIFFE identity (a standard URI scheme for naming workloads) names a tenant and an agent. The server reads the tenant/agent from that verified certificate — never from the request body — so a result can never lie about which tenant it belongs to.
There was a hole: the code verifies those certificates, but nothing in the
repo issued them. The trust root was an operator running gen-cert by hand
and copying files around. So the single strongest isolation mechanism in the
product rested on an undocumented manual step. This ADR closes that hole: a
repo-managed certificate authority (a CA — the signer whose signature
makes a certificate trusted) issues short-lived agent identities (SVIDs,
SPIFFE Verifiable Identity Documents), bootstrapped by a one-time join token.
What already existed (this ADR builds on, not around)
- mTLS transport with a SPIFFE URI identity, verified on every connection
(
internal/agenttransport,crypto.ServerMTLSConfigRevocable), with a handshake-checked revocation list. crypto.AgentSPIFFEID(tenant, agent)— the identity schemespiffe://probectl/tenant/<tenant>/agent/<agent>, pinned to one trust domain.crypto.CA— an ECDSA P-256 CA with SPIFFE-SAN leaf issuance (dev/test only, until this ADR).crypto.ClientMTLSConfigRotating— the agent client already hot-reloads its certificate material, so rotation needs an issuer, not a transport change.- Registration: the tenant/agent are read from the VERIFIED certificate, never the request.
Decision 1 — Bootstrap: single-use, tenant-scoped join tokens
An operator — through the agent.write-gated, audited admin API or the
database-direct CLI — mints an enrollment token scoped to a tenant (and
optionally pinned to a fixed agent id): 32 random bytes via internal/crypto,
shown once, stored only as a hash — the same pattern as session tokens,
so the database can confirm a presented token but can never reproduce one.
The agent presents it exactly once; the row is consumed atomically
(UPDATE ... WHERE used_at IS NULL, so a replay finds no row and is refused).
That pre-tenant consume is exposed only through an exact-hash
SECURITY DEFINER function. Direct application-role access with no tenant GUC
sees and changes zero enrollment rows.
Tokens expire (default 1h), and an operator can void an unused token before use
with probectl-control revoke-enroll-token. Cancellation enters the separate
provider role and invokes a provider-only database function; it does not reopen
the token table to the application role.
Why not cloud-IID/OIDC attestation now: attestation here means proving
facts about the machine an agent runs on — e.g. presenting a cloud-signed
instance-identity document — instead of presenting a shared secret. probectl's
primary deployments are sovereign/air-gapped, where there is no cloud identity
document to attest against — a join token works everywhere. The enrollment
endpoint is left as a seam: an attestor field on the request leaves room for
aws-iid / gcp / oidc attestors later without changing the shape of the
issued identity. (Today only join-token, or empty, is accepted;
internal/enroll/enroll.go rejects anything else.)
Decision 2 — CA hierarchy: repo-managed root → intermediate → leaf
The shape is a passport system: master printing plates in a vault (the root), a passport office that prints daily (the intermediate), and passports that expire in a day (the leaves) — losing the office never forces re-cutting the plates.
- Agent root CA (10y, P-256): generated once by
probectl-control agent-ca init; signs ONLY intermediates (MaxPathLen=1). The root key is printed at init for offline custody and is not stored — runtime operation never needs it. - Issuing intermediate (1y default): held by the control plane and
sealed at rest through
internal/tenantcrypto(the deployment envelope / BYOK — the same posture as every other secret). Signs leaves only. - Leaf SVIDs (default TTL — time-to-live — 24h): issued from a CSR — a certificate
signing request, which carries only the public key, so the private key is
generated on the agent and never leaves it. The leaf carries the SPIFFE URI
SAN (subject alternative name — the certificate field holding the identity)
binding
tenant + agent, with client-auth EKU only (extended key usage — the field restricting what a certificate may be used for; client-auth-only means an SVID can never pose as a server). - The CA bundle (root + intermediate) is what transports trust: agents get it at enrollment and on every rotation, so an intermediate roll-over is picked up automatically on the next rotation.
trustctl(the sibling certificate-lifecycle product) can later replace the issuing intermediate; the enrollment/rotation API is the integration seam.
Decision 3 — Issuance flows
Enroll (pre-identity, HTTPS): POST /enroll/agent on the control API. This
route is off /v1 on purpose — /v1 is the RBAC-gated session API, whereas
this is a bootstrap surface like /auth. The agent has no certificate yet, so
the channel is server-auth TLS only (HTTPS-by-default recipes make that safe);
the request is authenticated by the join token. The server: consumes the token
→ derives tenant from the TOKEN (never the request) → assigns or verifies the
agent id → signs a leaf → records the issued identity (serial, SPIFFE id,
expiry) in the registry → returns the leaf plus the CA bundle. The registry now
vouches for the tenant/agent pair, but the row remains registered with no
last_seen_at: operational readiness becomes true only after the holder opens
the authenticated mTLS transport and registers or heartbeats.
Rotate (identified, HTTPS): before expiry (at roughly 2/3 of TTL) the
agent calls POST /enroll/agent/rotate on the same HTTPS bootstrap surface —
not the mTLS data channel — carrying its CURRENT leaf in the request.
Authentication is cryptographic rather than channel-level: the presented cert
must chain to our hierarchy and be time-valid, its serial must be one we
recorded at issuance, and the request must prove possession of the current key
(an ECDSA signature over the new CSR — producible only by the key's holder).
The server checks the revocation list, then issues a fresh leaf for the PROVEN
identity — the SAN is set server-side and CSR-requested names are ignored, so
identity can never change on rotation — and records it.
ClientMTLSConfigRotating hot-swaps the files and connections re-handshake
naturally.
Token revocation: a leaked-but-unredeemed join token can be voided early with
probectl-control revoke-enroll-token -id <token-id>(the id is printed at mint time). The command assumesprobectl_providerand can execute only the narrow unused-token cancellation function. Single-use semantics and the ~1h expiry remain the defense-in-depth behind it — even an unrevoked leak self-destructs on first use or at expiry. Issued certificates are a separate surface, revoked viarevoke-agent.
Agent CLI: probectl-agent enroll --server https://control:8443 --token <jt> --dir /var/lib/probectl-agent/identity [--ca-pin <sha256>]
writes key/cert/bundle (0600) and exits; the runtime config points at those
paths and rotates them automatically. --ca-pin (printed at token mint when
the CLI can read the serving certificate) authenticates the SERVER on first
contact in self-signed / quickstart deployments — trust-on-first-use is
refused when a pin is provided and mismatched.
Threat-model delta (what changes, honestly)
Each row is a concrete attack; before/after is the honest delta — several threats are bounded by this design, not eliminated.
| Threat | Before | After |
|---|---|---|
| Forged tenant claim on the bus | possible with another tenant's REGISTERED agent id over pooled bus creds | agent ids exist only via enrollment; identity is cryptographic end-to-end — registry rows now have an issuance provenance |
| Token theft in transit/at rest | n/a (no tokens) | single-use + short expiry + hash-at-rest + tenant-scoped: a stolen UNUSED token is a bounded window; a USED one is inert; API mints are audited, and every issuance is recorded with provenance (serial, SPIFFE id, expiry) |
| Control-plane DB read | cert material was operator-managed, outside the DB | the intermediate CA key is sealed via tenantcrypto (envelope/BYOK); a DB read without the KEK yields ciphertext; token hashes are one-way |
| Stolen agent key | manual revocation of a manually-tracked cert | the 24h leaf TTL bounds exposure; every serial is recorded at issuance and feeds the handshake revocation list |
| Rogue/compromised control plane | already total (it terminates ingest) | unchanged — the issuer is the control plane; keeping the root key offline limits blast radius to the intermediate's lifetime |
| Enrollment endpoint abuse | n/a | unauthenticated callers can only burn CPU: every request requires a valid unconsumed token before any signing, and the route is rate-limited like the login surfaces |
| Wrong-tenant enrollment | operator error distributes a cert with the wrong SPIFFE | the tenant comes ONLY from the token; an agent cannot request a tenant |
Stated residuals
- There is no workload attestation beyond possession of the join token at first boot — cloud/OIDC attestors are the documented extension seam (Decision 1).
- The bus credential itself (e.g. Kafka ACLs) is unchanged — the consumer-side tenant verification plus cryptographic issuance is the compensating pair until per-tenant siloed lanes exist.
Out of scope (deliberate)
External CA integration (the trustctl seam is documented above), cloud
attestors, CRL/OCSP distribution (the standard protocols for publishing
revocations to third parties — here the in-process revocation list is the
mechanism), and per-probe identities.