diff --git a/02-DECISIONS/0113-the-vault-makes-every-secret.md b/02-DECISIONS/0113-the-vault-makes-every-secret.md index c16404f..f0a7078 100644 --- a/02-DECISIONS/0113-the-vault-makes-every-secret.md +++ b/02-DECISIONS/0113-the-vault-makes-every-secret.md @@ -293,3 +293,12 @@ travel, which is the other half and was never in question. > request/reply over the bus, never through the vault and never as a file the host writes. ADR 0183 > states that as a bounded exception — one vendor, tokens that live hours, one recipient per message — > and a second such channel is a decision of its own. + +> **The mechanism changed — 2026-10-06, by [ADR 0228](0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md).** +> What stands: a delivered value the vault cannot replace, such as an external API key, is not rotated +> by the vault, and rotating it means an operator delivering a new one. What moved: the controller had +> read that as covering **every** value given to it, and refused to rotate any of them. 0228 says what +> cannot be replaced is a value an outside party issues, which a module's definition now marks +> (`"issued-by": "outside"`); a given value for a secret the module reads at start is rotated like a +> made one, and one given through `secret accept` is replaced on its own after the module's first good +> start under the mesh. diff --git a/02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md b/02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md new file mode 100644 index 0000000..30fd263 --- /dev/null +++ b/02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md @@ -0,0 +1,143 @@ +--- +topic: what runs on it +status: accepted +date: 2026-10-06 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0113-the-vault-makes-every-secret.md +--- + +# 228. A value given by hand lives only until its module's first good start + +## Context + +**Two of a module's own secrets leaked into logs on 2026-10-06**, and the operator approved replacing +both. Each module's definition says it reads the secret when it starts (`taken: at-start`), which is +the form the mesh rotates by making a new value and starting the module again +([ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md), +[to-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)). The controller refused both: + +> not rotated: … holds "…" as a value given to the mesh, not made by it, and the mesh will not replace +> what it cannot read (ADR 0113). Change it where it lives, then `secret accept …` with the new value + +Both values had been given by hand long before, when the modules were moved from an older setup, and +nothing outside the mesh uses either. The way out the refusal names has a person or an agent make a +value and feed it to `secret accept` — a secret passing through hands, which is the thing the mesh +exists to avoid. + +**The refusal reads [ADR 0113](0113-the-vault-makes-every-secret.md) wider than it decides.** 0113 says +*"A delivered value the vault cannot replace, such as an external API key, is not rotated by the vault: +rotating it means an operator delivering a new one"*. What the vault cannot replace is a value only an +outside party can issue — a vendor's key, a bot's token, a licence — because no value of the mesh's +would work in its place. A secret the module reads at start and nobody else holds is not that: the old +value is not needed to replace it, because the module is the only reader and it reads the new one when +it starts. The controller applied the rule to **every** value it had been given, because the store +records only *made* or *accepted*, and nothing in a module's definition says who issued a value. + +The operator, the same day: *"Our mesh should most definitely be able to rotate 'custom provided' +passwords — in fact, ideally we immediately rotate them after assigning and running the module for the +first time so the 'custom pwd' is gone"*, and *"a custom pwd is only useful in case we're adopting an +existing running container into our mesh."* + +Counted in the catalogue on 2026-10-06: 48 own secrets across 32 modules; 6 say `taken: at-start`, none +says `applied` and 42 say neither. Of the 6 at-start ones, one is an outside party's key (an OpenAI +key). Of the rest, at least ten are keys or tokens an outside party issues. + +## Considered Options + +1. **Keep the refusal; add a separate verb that turns a given value into a made one** (`secret mint`, + with a reason). Rejected: it keeps a step whose only effect is to say "yes, really" to a rotation + the definition already permits, and it leaves every given value in force until a person remembers + to take it. The operator asked for the opposite default. +2. **Rotate a given at-start value like a made one, and stop there.** Better, and still leaves the + given value in force indefinitely after an adoption — the value a person handled stays the live one + until somebody asks. +3. **Rotate it like a made one, and replace a newly given value on its own once the module has started + on it**, with the exception said where it belongs: in the module's definition, for a value an + outside party issues. Chosen. +4. **Refuse `secret accept` for a secret the mesh may make, except on an adopted machine.** Rejected: + the mesh cannot tell a fresh install from one carrying data in from elsewhere on a converged machine + — a restored volume holds the password it was made with — and the replacement after the first good + start already bounds what a needless given value costs. It is accepted and said. + +## Decision + +**A module's own secret says who may make its value.** By default the mesh may. An entry says +`"issued-by": "outside"` when only a party outside the mesh can issue the value — a vendor's API key, a +bot's token, a licence. The parser refuses any other word. + +**A secret the mesh may make** — read at start, and not issued outside — **is the mesh's to replace, +whoever gave the value it holds:** + +- **`secret rotate` replaces a given value as it replaces a made one**: made anew, sealed to the + machine and the operator, recorded as made, and the machine sent so the module starts on it. A + rotation may say why, and the why is recorded in the hand-act log. +- **A value given by hand lives only until the module's first good start under the mesh.** Given + through `secret accept`, it is marked; when the machine's clean report arrives — every resource + applied, nothing failed or refused — **for the declaration it was last sent, sent after the value + was given**, the controller replaces the value with one it makes, sends the machine, says so in its + log and states `secret-replaced` as the controller seat's fact, never the value. On an adopted + machine the module must also be taken: until then the mesh runs nothing of it. The mark is cleared + as the value is replaced, so it happens once. +- **A given value exists to adopt something already running that holds it.** A module the mesh + installs fresh needs none; `secret accept` for such a secret is accepted, says that the value lives + until the first good start, and says on a converged machine that a fresh install needs no value. + +**What stays as given, refused with the reason:** + +- a value issued outside the mesh — never replaced; `secret rotate` names the issuer as the one to ask + and says how old the given value is, and `secret accept` delivers the new one; +- a value the module **applies** to a backend that takes it once, until the staged rotation of + [ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md) exists; +- a value the mesh's own code accepted — a bus account it issued is its word to a broker, and is never + marked; +- a value given **before** this record, which is not marked: the mesh does not decide for a person + that something given long ago is used nowhere else. `secret rotate` replaces it when asked. + +**What this does not change.** A pair credential an operator delivers is still never replaced by a +made one ([ADR 0092](0092-an-operator-delivers-a-pair-credential.md)): it is held at both ends of a +provision, and this record is about a secret one module holds. A secret whose definition says neither +`at-start` nor `applied` is still not rotated. That the vault makes every shared secret, and its custody, +stand as 0113 decided. + +## Consequences + +- Rotating a leaked secret a module reads at start is one call, whoever gave it, with no value in + anybody's hands. +- An adoption leaves no hand-given value in force once the module runs under the mesh. A person who + needs the new value — a password typed at a login page — recovers it with the operator key, as for + any value the mesh makes. +- **The catalogue must mark every secret an outside party issues.** An at-start secret left unmarked + is one the mesh will replace with a random value. The two OpenAI keys in the catalogue are marked + with this change; the other outside keys say neither `at-start` nor `applied`, so they are not + rotated either way, and marking them is tidying, not a prerequisite. +- **The controller ships before the catalogue marks anything.** The parser refuses a field it does not + know, so a definition saying `issued-by` is refused by a controller older than this record. +- What got harder: the controller now acts on a machine's report by itself, sending it once more after + the first good start of a module given a value. A replacement that cannot be sent is kept sealed and + carried by the next push, and said. + +## How it is checked + +| Rule | Checked by | +|---|---| +| A given value read at start rotates like a made one | Controller inventory test: a value accepted for an at-start secret rotates, and is recorded as made. | +| A given value is replaced once, after the first good start on it | Controller inventory test: a report of a declaration sent before the value was given replaces nothing; a report of an older declaration replaces nothing; the clean report of the declaration sent after it replaces it; the same report again, and the next declaration's report, replace nothing. | +| An outside party's value is never replaced | Controller inventory tests: a value accepted for a secret marked `issued-by: outside` is not marked and not replaced after a start; `rotate` refuses it naming the issuer and the value's age; a definition changed to say `outside` after the value was given keeps it. | +| An applied value, and one the mesh's own code accepted, stay as given | Controller inventory test: neither is marked or replaced after a start; `rotate` of an applied value is refused as not stageable. | +| An adopted machine waits for the take | Controller inventory test: a module held as found keeps its given value through a clean report; after the take, the next clean report replaces it. A module no longer assigned is never replaced. | +| A good start is a clean account of a declaration | Controller test: a refusal, a failure, or a bare word that the machine is there is not one. | +| The definition says who issues a value | Catalogue test: `issued-by` is read, written back as read, and any word but `outside` is refused. | +| The fact is stated, and permitted | Broker test: the controller's grant and its seat's emits both name `secret-replaced`, and nothing else is added. | +| A rotation through the console carries why | Controller test: the `rotate` verb passes why and cause to `secret rotate`; why beside a provision is refused as passed over. | + +## References + +- [ADR 0113](0113-the-vault-makes-every-secret.md): the decision this extends — what the vault cannot + replace is what an outside party issued +- [ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md): read at start and applied, and + the staged rotation an applied secret waits for +- [ADR 0092](0092-an-operator-delivers-a-pair-credential.md): a delivered pair credential, unchanged +- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md): an adopted machine, and the take +- [To-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md): the design this amends +- [To-be 45](../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md) §7: the hand-act log a rotation's why is recorded in diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 85a998b..2b64cba 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -325,6 +325,7 @@ python3 00-META/checks/index.py fail if stale - **0216** — [The agent's configuration is registered through its module, at three scopes, and served as one plugin](0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md) - **0220** — [What a machine asks needs its uplink held, and the retired resolver pieces go](0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md) - **0225** — [A consumer's identity is bounded by the provision it requires, judged before merge, and never refuses its provider](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md) +- **0228** — [A value given by hand lives only until its module's first good start](0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md) ### How it is built diff --git a/03-DESIGN/01-to-be/13-credentials-and-their-rotation.md b/03-DESIGN/01-to-be/13-credentials-and-their-rotation.md index 0c11e22..ffed083 100644 --- a/03-DESIGN/01-to-be/13-credentials-and-their-rotation.md +++ b/03-DESIGN/01-to-be/13-credentials-and-their-rotation.md @@ -5,12 +5,15 @@ code: - mesh-controller internal/inventory/secrets.go - mesh-controller cmd/mesh-controller/rotate.go - mesh-controller examples/postgres-provisioner -updated: 2026-10-01 + - mesh-controller internal/inventory/given.go + - mesh-controller cmd/mesh-controller/given.go +updated: 2026-10-06 decisions: - 02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md - 02-DECISIONS/0009-modules-and-the-graph.md - 02-DECISIONS/0085-a-secret-is-a-provision.md - 02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md + - 02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md --- # 13 — Credentials, and moving them @@ -144,8 +147,35 @@ rotates only the first: `secret rotate ` makes it anew, se and the operator, and sends the machine, so the module starts again on it. A secret that says neither is refused with the word to write, because a credential rotated under software that never reads it again is the fault of issue 179 made deliberately; an applied one is refused until the staged form is -built; an accepted one is refused as ADR 0113 says. `rotate` is a verb on the controller's seat with +built. `rotate` is a verb on the controller's seat with both shapes, so the console asks for either. A provider that shares its one credential with every consumer ([ADR 0158](../../02-DECISIONS/0158-a-provider-with-one-credential-shares-it-with-every-consumer.md)) rotates the same way, with every holder's copy remade and every holding machine sent together. *How it is checked:* the tests named in issue 180, and a live rotation through the console of a secret a module reads at start. + +### A value given by hand + +*Amended 2026-10-06 by [ADR 0228](../../02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md).* +Until then a value given to the mesh for an own secret was refused rotation outright, read as ADR 0113's +*"the mesh will not replace what it cannot read"*. What the mesh cannot replace is a value only an +outside party can issue; a secret the module reads at start and nobody else holds is replaced without +reading the old value. So an own secret also says who may make its value: by default the mesh, and +`"issued-by": "outside"` for a vendor's key, a bot's token or a licence. + +For a secret the mesh may make — read at start, not issued outside: + +- `secret rotate` replaces a given value as it replaces a made one, and records it as made. It may say + why, through the console's `rotate` as well, and the why goes to the hand-act log. +- **A value given through `secret accept` lives only until the module's first good start under the + mesh.** The signal is the machine's clean report — everything applied, nothing failed or refused — of + the declaration it was last sent, when that declaration was sent after the value was given; on an + adopted machine, after the module is taken. The controller then makes a value, seals it, sends the + machine, logs it and states the seat fact `secret-replaced`, once. A given value exists to adopt + something already running that holds it; for a fresh install `secret accept` says none is needed. + +An outside party's value, a value the module applies, a value the mesh's own code accepted (a bus +account it issued), and a value given before 0228 stay as given. The first is refused with the issuer +named and its age; the last is replaced when a person asks `secret rotate`. A pair credential an operator +delivers is unchanged ([ADR 0092](../../02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md)). +*How it is checked:* the tests ADR 0228 names, and a live `rotate` of a given at-start secret through +the console.