A secret can reach a container's environment, and sit inside a config file

Two gaps found by writing the first real module's manifest rather than
by reasoning about one. Both are fields on existing shapes, so the
vocabulary is still nine.

**env-file on a container.** A declaration reaches a node over the
broker and `env` is plain text in it, so a password there is a password
the broker sees — the transitive trust refused everywhere else. A sealed
file arrives unreadable, the host writes it, the runtime reads it. It is
also simply how third-party software takes credentials: nothing shipping
in a container will read a path the mesh invented, and every one of them
reads its environment.

**secrets in a file's content.** A program wanting its token inside a
JSON document cannot be handed a file that is entirely a token, and the
mesh cannot compose the document because it discarded the value. So the
module supplies the document with `${secret:name}` in it, the mesh
delivers the value sealed, and the host is the only thing that ever
holds both.

Substitution is textual and the host learns no formats. Deliberate: a
mechanism that understood JSON would be asked to understand YAML next,
and then INI, which is how the arrangement this replaces became
something nobody could hold in their head. The module knows its own
format because it wrote the rest of the file. The sharp edge is stated
rather than left to be discovered — a value containing a quote is not
escaped for whatever surrounds it.

Refused in both directions, because both are somebody being wrong about
where a credential is: a placeholder with nothing to fill it would write
`${secret:x}` into a config file, and a secret the content never uses
means somebody believes a credential is in a file where it is not.

A file that carries one is 0600 unless the module said otherwise.
This commit is contained in:
2026-08-31 22:25:57 +02:00
parent f48e06473d
commit 8c248e3d7f
4 changed files with 207 additions and 5 deletions
+86
View File
@@ -13,6 +13,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -317,3 +318,88 @@ func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) {
"name and not the thing, so it does not tear one down and rebuild it", created)
}
}
// A secret inside a configuration file, substituted on the machine.
//
// **The one place a credential and a configuration meet.** A program wanting its token inside a
// JSON document cannot be handed a file that is entirely a token, and the mesh cannot compose the
// document because it discarded the value. So the module supplies the document with a hole, the
// mesh delivers the value sealed, and the host is the only thing that ever holds both.
func TestASealedValueIsPutIntoTheFileThatNamesIt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
d := declare(t, `{"id":"settings","type":"file","path":"`+path+`",`+
`"content":"{\"tracking\":\"on\",\"token\":\"${secret:atlassian}\"}",`+
`"secrets":{"atlassian":"SEALED"}}`)
open := func(blob string) ([]byte, error) {
if blob != "SEALED" {
return nil, fmt.Errorf("asked to open %q", blob)
}
return []byte("the-real-token"), nil
}
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, nil, nil, open); err != nil {
t.Fatal(err)
}
written, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(written), `"token":"the-real-token"`) {
t.Fatalf("the secret was not put in: %s", written)
}
if strings.Contains(string(written), "secret:") {
t.Fatalf("a placeholder survived into the file: %s", written)
}
// The rest of the document is untouched — this is substitution, not replacement.
if !strings.Contains(string(written), `"tracking":"on"`) {
t.Fatalf("the content around the secret was lost: %s", written)
}
// And it carries a credential, so it is not world-readable.
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("a file holding a credential is %v", info.Mode().Perm())
}
}
// Defends the reason env-file exists: a credential may not travel in `env`.
//
// A declaration reaches a node over the broker and `env` is plain text in it, so a password there
// is a password the broker sees. A sealed file arrives unreadable, the host writes it, and the
// runtime reads it.
func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) {
var ran []string
run := func(_ context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
if len(args) > 0 && args[0] == "inspect" {
return "", fmt.Errorf("no such container")
}
return "", nil
}
d := declare(t, `{"id":"app","type":"container","name":"umami",`+
`"image":"umami@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+
`"env-file":["/var/lib/umami/database.env","/var/lib/umami/app.env"]}`)
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{},
store.OriginDeclared, run, nil, nil)
var started string
for _, line := range ran {
if strings.Contains(line, "run ") {
started = line
}
}
for _, want := range []string{
"--env-file /var/lib/umami/database.env",
"--env-file /var/lib/umami/app.env",
} {
if !strings.Contains(started, want) {
t.Errorf("the container was started without %q:\n%s", want, started)
}
}
}