A module names who its secret files belong to (secrets-owner)

The control plane runs as 65534 and crash-looped on permission denied the
first time its credentials were mounted as files the host wrote as root at
0600 — the env-file shape hid this because the daemon reads an env-file on
the host side. The composer now gives a module's secret files the owner the
manifest names.
This commit is contained in:
2026-09-21 10:19:00 +02:00
parent 4531f2244f
commit 69bb0fcb67
3 changed files with 55 additions and 4 deletions
+12 -4
View File
@@ -271,9 +271,9 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
return nil, fmt.Errorf(
"%s needs a secret called %q and none was made for it", m.Module, name)
}
first = append(first, map[string]any{
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name], "sealed": sealed,
})
}))
}
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
// the module's own resources, and so before the container that mounts them: the host must
@@ -320,10 +320,10 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
// worse than none: something would read it and fail authenticating.
continue
}
first = append(first, map[string]any{
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
"id": SecretID(to), "type": "file", "path": m.Secrets[to],
"sealed": found.Sealed,
})
}))
}
for _, to := range sortedKeys(m.Grants) {
for _, g := range with.Grants {
@@ -799,6 +799,14 @@ func keptFile(dir string, kept *KeptExport) (map[string]any, error) {
}, nil
}
// ownedBy gives a secret file the owner the module named, when it named one (Manifest.SecretsOwner).
func ownedBy(owner string, file map[string]any) map[string]any {
if owner != "" {
file["owner"] = owner
}
return file
}
// sortedKeys is map iteration made repeatable, which everything written to a machine needs.
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
+12
View File
@@ -297,6 +297,18 @@ type Manifest struct {
// module, in a file anybody can read, for ever.
OwnSecrets map[string]string `json:"own-secrets,omitempty"`
// SecretsOwner is who the files holding this module's secrets belong to on the machine —
// `uid:gid`, or a name — when its process is not root.
//
// **A secret reaches a process as a file** (novox/hq ADR 0086), and a file the host writes at
// 0600 as root is a file a container running as another account cannot read: the control
// plane, `USER 65534` in a scratch image, crash-looped on `permission denied` the first time
// its credentials were mounted instead of read from an env-file (which the daemon reads, as
// root, on the host side — which is exactly why that shape hid the problem). Absent means
// root, which is what a process that runs as root needs and what a process that does not
// cannot use.
SecretsOwner string `json:"secrets-owner,omitempty"`
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
//
@@ -70,3 +70,34 @@ func TestAnEnvFileWithoutASecretNeedsNothing(t *testing.T) {
t.Fatal(err)
}
}
// A module whose process is not root names who its secret files belong to, and every secret file
// the composer writes for it carries that owner — the mounted file is readable where it is used.
func TestSecretFilesCarryTheOwnerTheModuleNamed(t *testing.T) {
m := aModuleWithAnEnvFileSecret("said")
m.SecretsOwner = "65534:65534"
out, err := declare(t, m)
if err != nil {
t.Fatal(err)
}
var seen bool
for _, r := range out {
if r["path"] != "/var/lib/app/token.secret" {
continue
}
seen = true
if r["owner"] != "65534:65534" {
t.Errorf("the secret file is owned by %v", r["owner"])
}
}
if !seen {
t.Fatal("no secret file in the declaration")
}
m.SecretsOwner = ""
out, _ = declare(t, m)
for _, r := range out {
if r["path"] == "/var/lib/app/token.secret" && r["owner"] != nil {
t.Errorf("an owner was invented: %v", r["owner"])
}
}
}