Files
mesh-controller/internal/catalogue/secrets_into_files.go
T
jschoubben 4531f2244f A secret reaches a process as a file (ADR 0086)
The broker settings take a _FILE twin like the store connections; the
catalogue engine refuses a secret placeholder in a container's env and a
secret-carrying env-file unless the container says why with
secrets-in-environment, which stays in the catalogue and never reaches the
machine.
2026-09-21 10:10:33 +02:00

213 lines
8.4 KiB
Go

package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// A credential and a configuration file meeting.
//
// **The gap this closes.** A granted credential arrives as a file whose entire content is the
// password. That is what a program reading a password file wants — and most programs do not read
// one. They read `KEY=value`, or a JSON document with the password at some path inside it, or a
// YAML file in a home directory. Before this, a module in that position could be handed the bare
// value or nothing, and both are useless.
//
// The mesh cannot compose the document, because it discarded the value (novox/hq ADR 0024). So
// the module supplies the document with a hole in it, the mesh delivers the value sealed beside
// it, and the host — the only thing that ever sees both — puts one into the other on the machine.
//
// The host has always been able to do this. Nothing filled the values in, so the hole could be
// written and never closed, and the host refused the file. That refusal was correct and the
// feature was unreachable.
// placeholder is what a module's file content says where a sealed value belongs: ${secret:name}.
//
// The same expression the host matches, written out again rather than shared: they are separate
// repositories and this is a wire format, like the shape of the declaration itself.
//
// **Nothing here can check that they agree, and the claim that something did was wrong.** A unit
// test in this repository can only assert what this repository already believes. What proves it is
// the lab, where a real host receives a real declaration and the file arrives filled — and where
// the two expressions disagreeing shows up as a placeholder written through to a machine.
var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`)
// secretsUsed are the names a file's content asks for, in the order they first appear.
func secretsUsed(content string) []string {
var used []string
seen := map[string]bool{}
for _, m := range placeholder.FindAllStringSubmatch(content, -1) {
if !seen[m[1]] {
seen[m[1]] = true
used = append(used, m[1])
}
}
return used
}
// sealedFor is every credential a module may name from inside one of its own files.
//
// **Exactly what it already declared, and nothing else.** A module reaches its own secrets and the
// credentials it was granted for what it requires — both written down in its own manifest. It
// cannot name another module's, which is not an oversight: two modules on one machine are as
// separate as two on different machines, and letting one read the other's credential by guessing a
// name would end that, to save writing a file.
func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, error) {
sealed := map[string]string{}
for name := range m.OwnSecrets {
if value := with.Needed[m.Module][name]; value != "" {
sealed[name] = value
}
}
for _, to := range sortedKeys(m.Secrets) {
if _, taken := sealed[to]; taken {
// A module whose own secret and whose requirement share a name. Refused rather than
// settled by precedence: whichever won, the manifest would read as though the other
// had, and the file would hold the credential for the wrong thing while every check
// passed.
return nil, fmt.Errorf(
"%s has a secret of its own called %q and also requires %q, so a file saying "+
"${secret:%s} could mean either — rename one of them", m.Module, to, to, to)
}
for i := range needs {
// `For == m.Module`, not name alone: on a node with two modules requiring the same
// provision, both appear in `needs`, and matching by name would fill ${secret:X} with
// whichever came last — the other module's credential (novox/hq 04-ISSUES/022). The
// `secrets:`-map path already guards this way; the ${secret:…} placeholder path did not.
if needs[i].Name == to && needs[i].For == m.Module && needs[i].Sealed != "" {
sealed[to] = needs[i].Sealed
}
}
}
return sealed, nil
}
// SecretsInEnvironment is the key a container carries to say, out loud, that a secret reaches
// its process through the environment — and why.
//
// **A secret reaches a process as a file** (novox/hq ADR 0086). The mesh seals a value to the
// machine and the host writes it at 0600; a container that then reads it from an env-file hands
// it to the runtime, which prints it in `docker inspect` and keeps it in the process's /proc entry
// for anything on the machine that can talk to the runtime (novox/hq 04-ISSUES/041). Some software
// reads its configuration from the environment and nothing else, and for that this key exists: a
// reason, on the container, so a reader can tell from the manifest which secrets are exposed that
// way and which are not. Without it, a container whose env-file carries a secret is refused.
//
// Catalogue-level: the host never sees this key.
const SecretsInEnvironment = "secrets-in-environment"
// refuseSecretsInEnvironment is the check. `secretFiles` is every file of this module whose
// content names a secret.
func refuseSecretsInEnvironment(resource map[string]any, secretFiles map[string]bool, module string) error {
if fmt.Sprint(resource["type"]) != "container" {
return nil
}
name := fmt.Sprint(resource["name"])
if env, ok := resource["env"].(map[string]any); ok {
for key, value := range env {
if strings.Contains(fmt.Sprint(value), "${secret:") {
return fmt.Errorf(
"%s's container %s puts ${secret:…} in its env (%s). A secret is never filled into an "+
"environment variable: put it in a file the module declares and mount that, or name the "+
"file in env-file and say %q why", module, name, key, SecretsInEnvironment)
}
}
}
reason, _ := resource[SecretsInEnvironment].(string)
if strings.TrimSpace(reason) != "" {
return nil
}
if files, ok := resource["env-file"].([]any); ok {
for _, f := range files {
if secretFiles[fmt.Sprint(f)] {
return fmt.Errorf(
"%s's container %s reads %s as an env-file, and that file carries a secret, so the secret "+
"reaches the process environment — readable in `docker inspect` and /proc (novox/hq "+
"04-ISSUES/041). Mount the secret's file and point the program at it, or, if the program "+
"reads only its environment, say so on the container: %q: \"<why>\"",
module, name, f, SecretsInEnvironment)
}
}
}
return nil
}
// secretFilesOf is the paths of a module's file resources whose content names a secret.
func secretFilesOf(resources []map[string]any) map[string]bool {
out := map[string]bool{}
for _, r := range resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if content, ok := r["content"].(string); ok && len(secretsUsed(content)) > 0 {
out[fmt.Sprint(r["path"])] = true
}
}
return out
}
// intoFile gives a file the sealed values its content asks for.
//
// A name the module never declared is refused here rather than on the machine. The host would
// refuse it too — but it would do so having already been handed a declaration, which reads as the
// mesh sending something broken, and the name it could not find is a manifest's typo.
func intoFile(resource map[string]any, sealed map[string]string, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
used := secretsUsed(content)
if len(used) == 0 {
return nil
}
into := map[string]any{}
for _, name := range used {
value := sealed[name]
if value == "" {
return fmt.Errorf(
"%s has a file that says ${secret:%s}, and %s has no secret of its own by that "+
"name and requires nothing called that either. A file may name %s",
module, name, module, namesOr(sealed))
}
into[name] = value
}
resource["secrets"] = into
return nil
}
// namesOr says what a module could have written, because the answer to "that name is wrong" is
// almost always one of two or three right ones.
func namesOr(sealed map[string]string) string {
if len(sealed) == 0 {
return "nothing — it has no secrets of its own and requires nothing that grants one"
}
var names []string
for name := range sealed {
names = append(names, fmt.Sprintf("%q", name))
}
sort.Strings(names)
return join(names)
}
func join(names []string) string {
switch len(names) {
case 1:
return names[0]
case 2:
return names[0] + " or " + names[1]
}
out := ""
for i, n := range names[:len(names)-1] {
if i > 0 {
out += ", "
}
out += n
}
return out + " or " + names[len(names)-1]
}