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, }))