diff --git a/02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md b/02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md new file mode 100644 index 0000000..5bf8e90 --- /dev/null +++ b/02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md @@ -0,0 +1,75 @@ +--- +topic: building it +status: accepted +date: 2026-09-21 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0085-a-secret-is-a-provision.md +--- + +# 86. A secret reaches a process as a file, and an exception is declared + +## Context + +The mesh seals a secret to the machine that uses it and discards the plaintext; the host unseals it +into a file at 0600 ([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)). Then +the module hands it to its container through an env-file, and the runtime puts it where anything +on the machine that can talk to the runtime can read it: `docker inspect` prints it, and +`/proc//environ` holds it for the life of the process +([issue 041](../04-ISSUES/041-a-sealed-credential-ends-up-in-the-process-environment/00-report.md)). + +Two facts made this a decision rather than a fix. **It is a supported shape**: placing +`${secret:…}` in a file a container reads as its environment is what playbook 06 shows, and 36 +containers in 25 catalogue modules do it, the controller's own among them. And **nothing says which +secrets are exposed**: a reader cannot tell from a manifest whether a credential is protected from +`inspect` or not, because the manifest looks the same either way. The vault +([ADR 0085](0085-a-secret-is-a-provision.md)) rests on the seal this undoes. + +## Considered Options + +1. **Leave it: the environment is where configuration goes.** Rejected — it gives the sealed value + back to a routine operation, and the care taken to seal it is then theatre. +2. **Refuse every secret in an environment.** Rejected — some software reads its configuration + from the environment and nothing else, and a rule the catalogue cannot obey is a rule that gets + switched off. +3. **A secret reaches a process as a file; an environment exception is declared, with a reason, + and refused otherwise.** Adopted. + +## Decision + +**A secret reaches a process as a file.** A module mounts the file the host wrote and points the +program at it; the mesh's own programs accept a `_FILE` twin for every variable that carries a +credential, the way the controller's store connections already did. + +**A container that reads a secret from its environment says so.** The manifest key +`secrets-in-environment` on the container carries the reason. It is catalogue-level — the host +never sees it — and it exists so a reader can tell from the manifest which secrets are exposed +that way and why. + +**Everything else is refused.** A `${secret:…}` placeholder inside a container's `env` is never +filled and is refused outright. A file carrying a secret that a container names in `env-file` is +refused unless the container declares the exception. + +## Consequences + +The property *a sealed credential is readable only where it is used* becomes a manifest-level +fact: true where no exception is declared, stated where one is. The controller reads all six of +its credentials from files; the 35 other containers are marked with their reason and convert one +by one where their software accepts a path, which is per-module work under +[issue 041](../04-ISSUES/041-a-sealed-credential-ends-up-in-the-process-environment/00-report.md). + +What got harder: a module author meets one more refusal, and the reason they write is only as +honest as they are. What is not decided: the reach of the exposure on a node — which identities can +talk to the runtime — which decides whether a declared exception is a hardening item or something +sharper. + +## How it is checked + +The catalogue engine refuses at composition, before anything reaches a machine, and its tests +refuse both shapes and prove the reason is stripped from what is sent. + +## References + +- [issue 041](../04-ISSUES/041-a-sealed-credential-ends-up-in-the-process-environment/00-report.md) +- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md), [ADR 0085](0085-a-secret-is-a-provision.md) +- [`03-DESIGN/01-to-be/13-credentials-and-their-rotation.md`](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md) diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 1207c69..0a51db1 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -153,6 +153,7 @@ python3 00-META/checks/index.py fail if stale - **0069** — [A module is a repository and a path within it](0069-a-module-is-a-repository-and-a-path.md) - **0076** — [The SDK is a published package, and the toolchain resolves it by version](0076-the-sdk-is-a-published-package.md) - **0082** — [The registry is reached by name, and the overlay is its security](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md) +- **0086** — [A secret reaches a process as a file, and an exception is declared](0086-a-secret-reaches-a-process-as-a-file.md) ### How it is checked diff --git a/03-DESIGN/00-as-is/06-configuration-and-secrets.md b/03-DESIGN/00-as-is/06-configuration-and-secrets.md index 5de0385..bd6a281 100644 --- a/03-DESIGN/00-as-is/06-configuration-and-secrets.md +++ b/03-DESIGN/00-as-is/06-configuration-and-secrets.md @@ -1,11 +1,12 @@ --- layer: as-is status: implemented -code: [hal] -updated: 2026-08-23 +code: [hal, mesh-controller, mesh-catalog, mesh-host] +updated: 2026-09-21 decisions: - 02-DECISIONS/0011-managed-files-are-generated-never-edited.md - 02-DECISIONS/0009-modules-and-the-graph.md + - 02-DECISIONS/0085-a-secret-is-a-provision.md --- # Configuration and secrets @@ -73,9 +74,31 @@ when the file is created, so regeneration left the previous mode in place. The c worth remembering beyond the instance: a permission set at creation is not a permission maintained. -**Rotation is not a mesh operation.** Secrets can be generated and granted; there is no -mechanism that rotates one and informs everything holding it. Where a rotation has been done, -it has been done by hand, and doing it wrong has taken services down. +**Rotation was not a mesh operation** in the mesh being replaced, and doing it by hand took +services down. On the mesh that exists now it is: `rotate ` discards a pair credential +and delivers both ends in one send, and a module's own secret is such a pair credential when the +module takes it from the vault — which, at the time of writing, one module does (redis). + +## The vault, as it runs + +Since 2026-09-21 the mesh runs `mesh-vault`, a foundation module installed at genesis beside the +adopted store and broker. It provides `secret`: a module that requires one receives a pair +credential the controller minted, and the vault's ledger records the holder and the value's +fingerprint, notices a rotation, and answers over the mesh by fingerprint only. It holds no value. + +Genesis makes the store's superuser and the broker's administrator rather than copying the +template's, keeps them at the paths the store and broker modules declare as their own secrets, and +makes an **operator sealing key** before the first secret is accepted: its private half is a file +beside the produced bundle, which the operator carries off the machine, and its public half is +what the mesh records. Every secret a module holds for itself and every pair credential is sealed +to that key as well as to its node. The export of those copies is written beside the key at the +end of genesis and kept by the vault on its own disk; the operator recovers any secret from it, off +the mesh, with `secret recover`. Secrets made before the key existed, or sealed to a replaced key, +are listed as such rather than passed off as recoverable. The produced bundle and the installer's +transcript carry no credential in the clear. + +Two things this does not yet do: a module with several own secrets cannot take them all from the +vault (issue 069), and an operator cannot hand a value into a pair credential (issue 070). ## Node-level and mesh-level values diff --git a/03-DESIGN/01-to-be/07-the-foundation.md b/03-DESIGN/01-to-be/07-the-foundation.md index 2a1c898..818c523 100644 --- a/03-DESIGN/01-to-be/07-the-foundation.md +++ b/03-DESIGN/01-to-be/07-the-foundation.md @@ -5,6 +5,9 @@ code: - mesh-host examples/foundation-first-node.lock - mesh-host internal/apply - mesh-host internal/bootstrap/phase3.go + - mesh-host internal/bootstrap/rootsecrets.go + - mesh-host internal/bootstrap/operator.go + - mesh-catalog modules/mesh-vault - mesh-catalog modules/postgres - mesh-catalog modules/lavinmq - mesh-lab test/integration/mesh.test.ts (a bare machine becomes a mesh) 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 47d1f33..b5db27e 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,11 +5,12 @@ code: - mesh-controller internal/inventory/secrets.go - mesh-controller cmd/mesh-controller/rotate.go - mesh-controller examples/postgres-provisioner -updated: 2026-09-20 +updated: 2026-09-21 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 --- # 13 — Credentials, and moving them @@ -88,6 +89,19 @@ reads exactly like a credential that was never delivered. That has now happened environment needs a person in the middle of the one path that exists so there is not one, and puts a superuser password where `docker inspect` prints it. +## A secret reaches a process as a file + +The care above ends at the container's door if the module then hands the value to the runtime as +an environment variable: `docker inspect` prints it, and the process's `/proc` entry holds it for +anything on the machine that can talk to the runtime. So a secret reaches a process **as a file** +([ADR 0086](../../02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md)): the module mounts +the file the host wrote and points the program at it, and the mesh's own programs take a `_FILE` +twin for every variable that carries a credential. Software that reads only its environment is +not forbidden; it is **declared**, on the container, with a reason, so the manifest says which +secrets are exposed that way. **Checked** at composition: the catalogue engine refuses a +`${secret:…}` in a container's `env` outright, and a secret-carrying file named in `env-file` +unless the container carries the declaration. + ## How it is checked Not by comparing two files. **Two ends holding a matching string proves they agree, not that either diff --git a/03-DESIGN/01-to-be/24-the-secrets-vault.md b/03-DESIGN/01-to-be/24-the-secrets-vault.md index badb34a..a865c05 100644 --- a/03-DESIGN/01-to-be/24-the-secrets-vault.md +++ b/03-DESIGN/01-to-be/24-the-secrets-vault.md @@ -1,8 +1,8 @@ --- layer: to-be -status: in-progress -code: [mesh-catalog, mesh-controller] -updated: 2026-09-20 +status: implemented +code: [mesh-catalog, mesh-controller, mesh-host] +updated: 2026-09-21 decisions: - 02-DECISIONS/0085-a-secret-is-a-provision.md - 02-DECISIONS/0031-the-control-plane-authenticates-nobody.md @@ -106,9 +106,9 @@ The vault's second job is to hold these, and it does so without holding a value: answered by it. **Genesis** makes the operator key first and mints real root secrets in place of the fixed ones the -foundation is raised with, so the mesh is handed over with nothing well-known in it. That step is -the installer's and is not yet built; until it is, the fixed credentials are the as-is and are -said so in [21](21-the-installation-in-full.md). +foundation is raised with, so the mesh is handed over with nothing well-known in it. The installer +does this ([21](21-the-installation-in-full.md)); what it runs is described in the as-is +([`00-as-is/06`](../00-as-is/06-configuration-and-secrets.md)). A module's vault-provided secret — the pair credential of [13](13-credentials-and-their-rotation.md) — is sealed to the operator the same way, as is every diff --git a/04-ISSUES/041-a-sealed-credential-ends-up-in-the-process-environment/00-report.md b/04-ISSUES/041-a-sealed-credential-ends-up-in-the-process-environment/00-report.md index d7e79d2..58fa584 100644 --- a/04-ISSUES/041-a-sealed-credential-ends-up-in-the-process-environment/00-report.md +++ b/04-ISSUES/041-a-sealed-credential-ends-up-in-the-process-environment/00-report.md @@ -1,9 +1,9 @@ --- -status: open +status: resolved opened: 2026-09-10 -located-in: [] -fixed-by: -amended-design: +located-in: [mesh-controller internal/catalogue, mesh-catalog modules/mesh-controller] +fixed-by: ADR 0086; mesh-controller feat/secret-not-in-environment (envfile twins for the broker settings, catalogue refusal of secrets in env / undeclared env-file); mesh-catalog (the controller reads its six credentials from files; 35 containers declare their exception); mesh-host (the installer delivers any MESH_…_FILE own secret) +amended-design: 03-DESIGN/01-to-be/13-credentials-and-their-rotation.md --- # 041 — A credential the mesh took care to seal ends up in the process environment @@ -65,3 +65,11 @@ environment; 28 catalogue manifests use env-file for a secret, and nothing in th engine refuses a `${secret:…}` placeholder in one. The vault work of [ADR 0085](../../02-DECISIONS/0085-a-secret-is-a-provision.md) rests on the seal this weakens. +## Resolved 2026-09-21 + +By [ADR 0086](../../02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md): a secret reaches +a process as a file, an environment exception is declared with a reason, and the catalogue engine +refuses the undeclared shape. The controller, the instance this was opened on, reads all six of +its credentials from files. The 35 other containers carry a declared reason; converting each where +its software accepts a path remains per-module work and is not this issue's. + diff --git a/04-ISSUES/071-the-foundation-is-raised-with-fixed-credentials/00-report.md b/04-ISSUES/071-the-foundation-is-raised-with-fixed-credentials/00-report.md index 0661961..ec026a8 100644 --- a/04-ISSUES/071-the-foundation-is-raised-with-fixed-credentials/00-report.md +++ b/04-ISSUES/071-the-foundation-is-raised-with-fixed-credentials/00-report.md @@ -2,7 +2,7 @@ status: resolved opened: 2026-09-20 located-in: [mesh-host internal/bootstrap, mesh-host examples/foundation-first-node.lock] -fixed-by: mesh-host feat/secrets-vault (ee0c8b8, genesis root credentials + operator key + vault); mesh-controller feat/secrets-vault (e140ed5, 565f144); mesh-catalog feat/secrets-vault; proven by the one-node genesis bed step V5 +fixed-by: mesh-host PR 14 (e30a6b0), mesh-controller PR 34 (6b695c8), mesh-catalog PR 30 (d03520f), mesh-lab PR 39 (7e2e97f); proven by the one-node genesis bed step V5, 22/22 amended-design: 03-DESIGN/01-to-be/24-the-secrets-vault.md --- diff --git a/04-ISSUES/072-the-controllers-manifest-exists-twice/00-report.md b/04-ISSUES/072-the-controllers-manifest-exists-twice/00-report.md new file mode 100644 index 0000000..da50482 --- /dev/null +++ b/04-ISSUES/072-the-controllers-manifest-exists-twice/00-report.md @@ -0,0 +1,39 @@ +--- +status: located +opened: 2026-09-21 +located-in: [mesh-controller module.json, mesh-catalog modules/mesh-controller/module.json, mesh-host internal/bootstrap] +fixed-by: +amended-design: +--- + +# The controller's manifest exists twice, and nothing keeps the copies equal + +## Symptom, as observed + +The control plane's manifest lives at the root of its own repository, where the mesh reads it +whenever it builds the controller from source, and again in the catalogue under +`modules/mesh-controller`, where the installer reads it at genesis. The two differ only in how the +image is named — a build section in one, a placeholder digest in the other — and are otherwise +meant to be the same document. + +Changing the catalogue copy alone (to make the controller read its credentials from files, +[ADR 0086](../../02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md)) produced a mesh that +installed correctly and then could not be pushed to: the first rebuild of the controller from +source replaced the mesh's record with the repository's copy, which still carried the old shape, +and every later push of the node was refused on the controller's behalf. `status` reported the +machine as doing what it was told throughout, because nothing had been sent. + +## Why it matters beyond this instance + +- **One module, two sources of truth.** [ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md) + says a module is a repository and a path; the controller has two paths, and which one the mesh + believes depends on which command last touched it. +- **The failure is silent at the wrong layer.** The divergence surfaces as a resolution refusal + on an unrelated push, not as a warning that the copies differ. +- **It will happen again** to whoever next edits the controller's manifest for any reason. + +## What would close it + +Either the catalogue copy goes and genesis reads the controller's manifest from the controller's +repository (the installer already has the checkout, since it builds from it), or a check refuses +two copies that differ outside the image lines. The first is the honest one.