Enrollment, workload identity, and credential issuance
- Status
- Accepted
Context
ADR-0004 fixed the identity model: token-gated enrollment with no trust-on-first-use window, a control-plane-assigned identity carried in a URI SAN, short-lived credentials, and a signing authority behind an interface. ADR-0015 then fixed the wire contract that carries it: a provisioning token scoped to a label set that may enroll many workloads, a certificate signing request on enrollment and renewal, and identity derived only from the connection credential.
Implementing both made three things concrete that ADR-0004 left open or stated differently, and each is the kind of detail a later change could quietly undo: the exact form of the identity URI, the exact rule for what a signing request may contribute to a certificate, and where on the listener the authentication boundary is enforced. This ADR restates the identity model in full with those decisions fixed, so that one record is the source of truth. It implements ADR-0004's identity semantics at the signing boundary; ADR-0004 remains Accepted, and everything in it not made precise here is carried forward.
Decision
-
Signing goes through one interface, and a signing request contributes only its public key. Certificate issuance is behind
ca.Authority, whose two operations areSign(csr, identity, ttl)andBundle(). The interface belongs to the enrollment domain: enrollment and renewal are written against it and nothing outside a backend depends on how that backend keeps its key. Every implementation reads the request through one function that returns the public key and nothing else, after verifying the request's signature as proof of possession. The subject, every subject alternative name, and every extension the enrollee asked for are discarded, because identity is granted by the control plane and never requested by the enrollee. A test that submits a request naming another workload, the control plane's own hostname, a CA constraint, and arbitrary private extensions, and receives a certificate containing only what was granted, is the executable statement of this rule and stays in the repository for as long as the rule holds. Version 1 ships exactly one backend: a key and self-signed root on the control-plane host's disk, created byinnerwall ca init, with the key file at mode 0600 and the directory at 0700. Backends that keep the key elsewhere are additive. -
Identity is one URI SAN of the form
innerwall://workload/<uuid>. An issued credential carries exactly one URI subject alternative name and no DNS, IP, or email names. The scheme names this system; the host component is a namespace (workload) rather than a network location, so that other kinds of principal can be named under the same scheme later without ambiguity; the path is the workload identity, a version 7 UUID assigned by the control plane at enrollment, in its canonical 36-character spelling and no other. A single package formats and parses this URI and extracts it from a certificate, refusing certificates with zero or more than one URI SAN; every other component uses that package and none builds or inspects the string itself. The common name mirrors the identity for human readers and is never read for authorization. Everything mutable about a workload (hostname, labels, enforcement state) lives in the registry, never in the credential: the certificate authenticates, the registry authorizes. -
Provisioning tokens are opaque, hashed at rest, scoped, expiring, revocable, and reusable within scope. A token is the prefix
iw_followed by 32 random bytes in URL-safe base64 without padding; the fixed prefix lets a secret scanner recognize a leaked token without knowing which deployment minted it. The control plane stores the SHA-256 digest, which is what a presented token is looked up by, and a listing hint, the prefix and the first eight characters of the random body, which is what a listing shows so tokens can be told apart; it shows the plaintext exactly once, at mint. A token carries the label set it assigns, an expiry (30 days unless the operator sets otherwise), and a revocation timestamp. It may enroll any number of workloads while valid, so one token can be baked into an image or a provisioning pipeline; a use is recorded, never consumed. A rejected token reports precisely why (malformed, unknown, expired, revoked, or assigning labels outside the label grammar of ADR-0022, which is reported only once the token has authenticated), because the operator who minted it needs to know, and an attacker guessing values learns nothing from "unknown" they did not already know. Tokens are minted, listed, and revoked by the control-plane command line and by the operator surface (ADR-0021), through the same domain functions. (Amended 2026-10-06 and 2026-10-08; see Amendments.) "Provisioning token" is the wire contract's name for what earlier documents call a join token; they are the same object, and the constraints ADR-0004 placed on an enrollment policy are carried on the token itself in this version. -
Credentials are short-lived; renewal is load-bearing from the first version. An issued workload credential lives 24 hours unless configured otherwise; the file-backed root lives ten years, because rotating a root means re-enrolling every workload while rotating a leaf costs one round trip. Renewal is the
RenewCredentialoperation of the agent service: the workload being renewed is whatever identity the connection credential carries, the same URI SAN is reissued with a fresh serial, and the signing request is subject to the same rule as at enrollment. The agent exposes renewal as a command; the daemon renews automatically when less than one third of the lifetime remains, at a jittered point, through the same renewal path, judging the lifetime by its wall clock against the certificate and by the monotonic time elapsed since it obtained the credential, whichever says renew first, so a host whose clock runs behind does not sleep through its window. A credential that expires before it renews may still renew, and do nothing else, for a bounded grace after its expiry (72 hours unless the operator configures otherwise), provided it is valid in every other respect: it chains to the authority, carries a parseable identity, and names a workload the registry still holds. Every other method keeps requiring a credential within its validity, because what those carry is acted on as coming from the identity now. A late renewal is logged at warning level, naming the workload and how late it was, and recorded on the workload. Beyond the grace a workload has no usable credential and re-enrolls with a new token, and the refusal says so in the words the agent uses for the same state. Expiry is never fatal to the daemon, at start or mid-run: it keeps enforcing the persisted policy (ADR-0011), keeps retrying renewal with backoff until it succeeds or the operator intervenes, reports the renewal's failure and the credential's expiry in every heartbeat that reaches the control plane, and resumes everything else on the renewed credential. Enrollment overwrites a stored credential that has expired, because the refusal it otherwise makes exists to protect a live identity and an expired credential is not one; a valid stored credential is still refused. (Amended 2026-10-14; see Amendments.) Validity begins a few minutes in the past so a host with a slow clock accepts a credential it was just issued. -
One listener, authentication decided per service. The control plane serves the enrollment service and the agent service on a single TLS listener. The listener asks for a client certificate and verifies it against the authority bundle when one is presented, but does not require one at the transport layer, because the enrollment service is authenticated by the token and a workload that has not enrolled has no certificate to present. A server interceptor then enforces the boundary by service name: the enrollment service requires no client certificate; every other method on the listener requires a verified client certificate whose leaf carries a parseable workload identity, which the interceptor places in the request context. The transport verifies the presented chain against the bundle, and possession of the leaf's key, but not the leaf's validity period; the interceptor verifies the chain again and applies decision 4's validity rule per method, so a credential within its validity may call any agent method and one expired within the grace may call renewal alone, and either refusal reaches the agent as a reason rather than an anonymous handshake failure. (Amended 2026-10-14; see Amendments.) The default is to require a certificate, so a service added later cannot land on the unauthenticated side by omission. Handlers read identity from that context value and from nowhere else: not from request fields, not from the connection, not from a certificate they parse themselves.
-
The bootstrap trust anchor travels with the token. At enrollment the agent holds no credential and no bundle, so it has nothing of its own with which to verify the control plane. The operator therefore distributes the authority's certificate out of band together with the token, and the agent presents it as the trust anchor for that first connection. The token proves the agent to the control plane; the anchor proves the control plane to the agent. When the control plane presents a certificate the host already trusts, the anchor may be omitted and the host's trust store is used. After enrollment the agent stores the bundle it received and uses it for every later connection.
-
The signing key is operator-provisioned configuration, not replica state. The control plane keeps all durable state in Postgres so that replicas are stateless and interchangeable. A private signing key is the one thing that does not belong there: a key in the database is readable by everything that can read the database, and the point of a signing boundary is that it is not. The file-backed authority therefore holds its key on the host, and the authority directory is configuration the operator provisions identically on every replica, in the same class as the database connection string and the listener certificate (which
ca initwrites beside the key). Replicas configured identically remain interchangeable: every one signs with the same key and verifies against the same bundle.ca initis run once and its output distributed;serve --init-caexists for a single-replica development stack and creates nothing when a directory already holds an authority. Nothing else durable lives on a replica, and a backend that keeps the key in a secrets manager or a hardware module removes even this from the host while leaving the interface unchanged. ADR-0017 restates the control-plane shape with this exception in it; this decision records the reasoning at the point of use.
Consequences
- ADR-0004 stands, implemented here at the signing boundary. Its identity model survives in full; the URI form it named is made precise by the one above, and its token consumption rule gives way to ADR-0015's reusable scoped token.
- ADR-0005 is superseded by ADR-0017 to carry decision 7: signing-key material is operator-provisioned on each replica rather than stored in Postgres. Two replicas that each generate their own authority are misconfigured, not two valid deployments; the deploy guide says so, and an external key backend removes the question.
- The signing-request rule is enforced in one function and tested by one hostile-request test. A backend that bypasses that function is a reviewable diff, not a subtle drift.
- The identity string has one owner. Changing its form is a change to one package and its round-trip tests, and every consumer follows.
- Token plaintext exists only at mint and at enrollment: in the mint response, shown once (the operator's terminal, or the console's shown-once dialog through the surface), and in the agent's enrollment call. It is never stored and never logged, and the repository's tests check that a store lookup by plaintext finds nothing. (Amended 2026-10-06; see Amendments.)
- Short lifetimes make renewal a hot path and the authority availability-critical for renewals. Enforcement is unaffected by either (ADR-0011). Revocation of a token stops future enrollments and nothing else. A deny-list of workload identities, for locking out an enrolled workload before its credential expires, and duplicate-identity detection on connect are version 2 controls, not version 1 ones (ADR-0004 as amended); until then an enrolled workload is locked out by its credential lapsing beyond the renewal grace. (Amended 2026-10-06 and 2026-10-14; see Amendments.)
- The renewal grace turns an outage of the control plane that spans the renewal window from fleet-wide re-enrollment (a new identity per host, its history orphaned) into a late renewal per host with no operator action. Its cost is that a key whose credential stopped renewing stays useful, for renewal alone, for the length of the grace after expiry. That buys an attacker nothing a key to a live credential does not already give, since such a key renews today, and an expired credential can do nothing but renew; the grace is configurable for a deployment that weighs it differently. Flow reports stay refused on an expired credential, because they are the evidence enforcement decisions rest on (ADR-0001) and the agent buffers them across the gap. (Added 2026-10-14; see Amendments.)
- Adding a method to the agent service requires nothing of the boundary; adding a service reachable without a credential requires a deliberate change to the interceptor's one exception, which is the review point.
- The bootstrap anchor is one more artifact for the installer to carry. Its absence in a development setting is a usability cost accepted so that a token is never sent to an unverified endpoint by default.
Amendments
(PR #5): relationship to ADR-0004. This record implements ADR-0004's identity semantics at the signing boundary; it does not supersede ADR-0004. The Supersedes: ADR-0004 header is removed, and the Context sentence that read "Where ADR-0004 and this ADR differ, this ADR governs" and the Consequences line that opened "ADR-0004 is superseded" are corrected in place to say so; the prior text is in git history. ADR-0004 remains Accepted.
(PR #5): automatic renewal. Decision 4 anticipated the daemon's renewal point as "roughly half of lifetime"; the sentence is corrected in place to the last third (prior text in git history). The daemon renews automatically in the last third of the certificate's lifetime, at a jittered point, through the existing RenewCredential path, and swaps the renewed credential in without dropping its stream. A failed renewal retries with backoff; an expired credential is reported and the daemon does not enroll itself.
(ADR-0021): cross-reference, no change. Decision 5 describes the agent listener and continues to. ADR-0021 adds a sibling listener in the same binary for the operator surface, with its own TLS configuration and its own authentication, because a browser holds no client certificate and does not trust the internal authority's root. Nothing in this record is amended; the boundary it draws is not extended to operators.
(PR #12): listing hint. Additive detail on decision 3, corrected in place (prior text in git history): beside the digest, a token now keeps a listing hint, its iw_ prefix and the first eight characters of its random body, written at mint, so a listing can tell tokens apart. This is the shape ADR-0021 gives operator tokens, which it describes as mirroring this one. Eight characters of base64 disclose 48 of the body's 256 bits and leave the remainder far beyond guessing, and lookup is still by digest alone. Tokens minted before the hint was kept hold only their digest, from which no hint can be recovered; they are listed with none, and nothing is fabricated for them. Hashed at rest, shown once, scoped, expiring, and revocable all stand unchanged.
(PR #25, conformance-audit absorption). The core decision stands. Three consequences and one decision sentence that described the future are corrected in place now that it is known: the provisioning-token management API shipped on the operator surface, so token plaintext also appears once in that mint response and the console's shown-once dialog; and the deny-list of workload identities, which was to land with the sync stream, did not, and is recorded with duplicate-identity detection as a version 2 control (ADR-0004 as amended).
(ADR-0022, label grammar). Decision 3's list of reasons a token is rejected gains one, as additive detail: a token whose stored labels are outside the label grammar ADR-0022 defines is refused at enrollment, after it authenticates and before any credential is issued, with a reason naming the grammar. Only a token minted before the grammar was admitted can carry such labels; a new mint is held to the grammar. The refusal reveals nothing about other token values. The core decision stands.
(PR #102, issue #93): renewal grace, renew-only expired credentials, expiry never fatal to the agent. Additive detail on decisions 4 and 5, corrected in place (prior text in git history); the core decision, short-lived credentials renewed by authenticated re-CSR with identity read only from the connection credential, stands. An outage of the control plane that spanned the agents' renewal window lapsed every credential, and nothing could come back: the listener refused an expired client certificate at the handshake, and a restarted daemon exited on finding one, so recovery was a re-enrollment per host, each minting a new identity. Now the renewal operation accepts a credential expired by at most a configurable grace (innerwall serve --renewal-grace, 72 hours by default) that is valid in every other respect, and every other surface keeps requiring a valid credential, so an expired credential may do exactly one thing: renew. "Valid in every other respect" includes the workload being registered; version 1 has no deny-list of workload identities (see Consequences), and when one lands it applies to a late renewal as to any other. A late renewal is logged at warning level and recorded on the workload (migration 00014), which the operator surface reports as the credential's last_late_renewal. Beyond the grace the refusal names re-enrollment. The daemon no longer treats expiry as fatal at start or mid-run, which collapses three inconsistent behaviors (a running daemon retried forever, a restarted one exited, enrollment refused) into one: retry renewal until it succeeds or the operator intervenes. Enrollment over an expired stored credential is allowed; a running daemon whose state directory is enrolled again underneath it stops rather than renew the old identity, so the restarted daemon runs as the new one. The renewal point is also judged on monotonic elapsed time, so a host whose clock runs behind renews on time. The amendment note of 2026-09-13 on automatic renewal said an expired credential is reported and the daemon does not enroll itself; the second half stands, and the first is this note's never-fatal retry. ADR-0004 and ADR-0015 take matching notes.