From 41216e4d4c0a733c8233f590599cf43206c397d1 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 14:39:20 +0200 Subject: [PATCH] Give a bundle module's bound credential to the account its runtime runs as (hq issue 624) A consumer's credential file was composed with the owner the module declared, root when it declared none. A module whose code is a bundle the node's runtime runs reads it as the operator's account, and a root-owned 0600 file is one it cannot read: the lab cloned the forge with no credential. The grant side was fixed the same way for issue 225; this is the consumer's side. A declared owner still wins. --- .../catalogue/consumer_secret_owner_test.go | 57 +++++++++++++++++++ internal/catalogue/declaration.go | 11 +++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 internal/catalogue/consumer_secret_owner_test.go diff --git a/internal/catalogue/consumer_secret_owner_test.go b/internal/catalogue/consumer_secret_owner_test.go new file mode 100644 index 00000000..e525866c --- /dev/null +++ b/internal/catalogue/consumer_secret_owner_test.go @@ -0,0 +1,57 @@ +package catalogue + +import "testing" + +// A consumer's credential is read by the consumer's own code, and that stopped being root for every +// module whose code is a bundle the node's runtime runs (novox/hq issue 624, the consumer's side of +// issue 225). The lab requires the package registry and reads its bound credential to clone the forge; +// the credential file was composed with no owner, so root's and 0600, and the lab — running as the +// operator's account — cloned with no credential and was refused, "could not read Username". + +// aConsumerWithABundle is the lab's shape: tools in a bundle, a requirement, its credential in a file. +func aConsumerWithABundle() Manifest { + return Manifest{ + Module: "lab", + Requires: []string{"npm-package-registry"}, + Secrets: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.secret"}, + Bundles: []Bundle{{Name: "code", Language: "typescript"}}, + } +} + +func consumerSecret(t *testing.T, m Manifest, account string) map[string]any { + t.Helper() + r := Resolution{Node: "shanks", Account: account, Modules: []Manifest{m}, + Needs: []Needed{{Name: "npm-package-registry", For: m.Module, From: "novox", Sealed: "c2VhbGVk"}}} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["type"] == "file" && res["path"] == "/var/lib/lab/package-registry.secret" { + return res + } + } + t.Fatalf("no credential file was composed: %v", out) + return nil +} + +func TestABundlesBoundCredentialBelongsToTheAccountItsRuntimeRunsAs(t *testing.T) { + if got := consumerSecret(t, aConsumerWithABundle(), "jochen")["owner"]; got != "jochen" { + t.Fatalf("the lab's credential belongs to %v; its tools run as %q, and a root-owned 0600 file is one "+ + "they cannot read", got, "jochen") + } +} + +// A module that names its secrets' owner keeps it, and one with no bundle and no owner stays root's. +func TestAConsumersDeclaredSecretsOwnerStillWins(t *testing.T) { + m := aConsumerWithABundle() + m.SecretsOwner = "65534:65534" + if got := consumerSecret(t, m, "jochen")["owner"]; got != "65534:65534" { + t.Errorf("a declared owner was replaced by %v", got) + } + m = aConsumerWithABundle() + m.Bundles = nil + if got, has := consumerSecret(t, m, "jochen")["owner"]; has { + t.Errorf("a container consumer's credential was given the owner %v", got) + } +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index f452d4a2..e9851bb7 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -730,7 +730,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // worse than none: something would read it and fail authenticating. continue } - first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + // **Read by the module's own code, so owned by whoever runs it** (novox/hq issue 624, the + // consumer's side of issue 225): a module whose code is a bundle the node's runtime runs reads + // its binding's credential as the operator's account, and a root-owned 0600 file is one it + // cannot read — the lab cloned the forge with no credential and said nothing about why. A + // module that names its secrets' owner keeps it. + owner := m.SecretsOwner + if owner == "" { + owner = r.provisionsAs(m) + } + first = append(first, ownedBy(owner, map[string]any{ "id": SecretID(SecretLocal(to, file.Local)), "type": "file", "path": file.Path, "sealed": found.Sealed, }))