diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index b81b1c2..e76cdd3 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -302,6 +302,13 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { resources = withMeshNames(resources, with.Names) } + // What this module may name from inside one of its own files. Gathered once per module + // rather than per file, because it is a fact about the module. + sealed, err := sealedFor(m, r.Needs, with) + if err != nil { + return nil, err + } + for _, unsettled := range resources { resource, err := ApplySettings(unsettled, with.Settings[m.Module]) if err != nil { @@ -311,6 +318,13 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { for k, v := range resource { copied[k] = v } + // **After settings, and that is the whole reason it is here.** A module's file + // content is where a setting lands, so a placeholder may only exist once the setting + // has been put in — filling secrets first would look at content that is not yet what + // the machine receives. + if err := intoFile(copied, sealed, m.Module); err != nil { + return nil, err + } copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) // A service saying what it reflects names resources within its own module, so those // are prefixed too or they would point at nothing. diff --git a/internal/catalogue/secrets_into_files.go b/internal/catalogue/secrets_into_files.go new file mode 100644 index 0000000..49e3c3f --- /dev/null +++ b/internal/catalogue/secrets_into_files.go @@ -0,0 +1,139 @@ +package catalogue + +import ( + "fmt" + "regexp" + "sort" +) + +// 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. The two +// repositories agree on a wire format, and a format read on both sides is exactly the thing that +// must not be quietly changed on one of them; a test asserts they still agree. +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 { + if needs[i].Name == to && needs[i].Sealed != "" { + sealed[to] = needs[i].Sealed + } + } + } + return sealed, nil +} + +// 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] +} diff --git a/internal/catalogue/secrets_into_files_test.go b/internal/catalogue/secrets_into_files_test.go new file mode 100644 index 0000000..a16ac0a --- /dev/null +++ b/internal/catalogue/secrets_into_files_test.go @@ -0,0 +1,196 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Driven through Declaration, never through the helper. +// +// Three times in this repository a test asserted on a helper while the code that called it was +// wrong, and each time an injected fault stayed silent because nothing on the path was under test. +// What has to be true is that a declaration leaving the control plane carries the values, so that +// is what these ask. + +func fileNamed(out []map[string]any, id string) map[string]any { + for _, r := range out { + if r["id"] == id { + return r + } + } + return nil +} + +// A module's own credential, put into a file it wrote. +func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{{ + Module: "gitea", + OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/gitea/app.ini", + "content": "[security]\nSECRET_KEY = ${secret:admin}\n", + }}, + }}} + out, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}}, + }) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "gitea.conf") + if file == nil { + t.Fatalf("no file in %v", out) + } + got, _ := file["secrets"].(map[string]any) + if got["admin"] != "sealed-admin" { + t.Errorf("the file was sent without its secret: %v", file) + } + // The hole is still a hole on the wire. The mesh does not fill it in; the host does, on the + // machine, which is the only place both halves exist. + if !strings.Contains(file["content"].(string), "${secret:admin}") { + t.Errorf("the mesh substituted it itself: %v", file["content"]) + } +} + +// The gap this exists for: a granted credential arrives as a bare password, and the program that +// needs it reads KEY=value. +func TestAGrantedCredentialCanBeShapedIntoAnEnvFile(t *testing.T) { + r := Resolution{ + Node: "anchor", + Modules: []Manifest{{ + Module: "keycloak", + Requires: []string{"postgres-database"}, + Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", + "content": "KC_DB_USERNAME=keycloak\nKC_DB_PASSWORD=${secret:postgres-database}\n", + "mode": "0600", + }}, + }}, + Needs: []Needed{{Name: "postgres-database", From: "anchor", Sealed: "sealed-db"}}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "keycloak.dbenv") + got, _ := file["secrets"].(map[string]any) + if got["postgres-database"] != "sealed-db" { + t.Errorf("the credential never reached the file it was needed in: %v", file) + } +} + +// A name nothing declared is a typo, and it is refused here rather than on the machine. +func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{{ + Module: "gitea", + OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/gitea/app.ini", + "content": "SECRET_KEY = ${secret:adnim}\n", + }}, + }}} + _, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}}, + }) + if err == nil { + t.Fatal("a file asked for a secret that does not exist and the mesh sent it anyway") + } + // It says what could have been meant, because the answer is nearly always one of two names. + if !strings.Contains(err.Error(), `"admin"`) { + t.Errorf("the refusal does not say what it could have named: %v", err) + } +} + +// One module may not read another's credential by guessing its name. +func TestAFileCannotNameAnotherModulesSecret(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{ + {Module: "postgres", OwnSecrets: map[string]string{"superuser": "/var/lib/postgres/su.env"}}, + {Module: "gitea", Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/gitea/app.ini", + "content": "PASSWORD=${secret:superuser}\n", + }}}, + }} + _, err := r.Declaration(Rendering{Needed: map[string]map[string]string{ + "postgres": {"superuser": "sealed-su"}, + }}) + if err == nil { + t.Fatal("gitea read postgres's credential by naming it") + } +} + +// A token inside a JSON document, where a setting put the placeholder there. +// +// **The case this whole shape was argued from**: a desktop client that reads its token from an +// attribute inside a JSON file in a home directory, not from an environment variable. The module +// ships an empty document, a setting says which attribute, and the credential never passes +// through the mesh in the open. +func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{{ + Module: "chat", + OwnSecrets: map[string]string{"api-token": "/home/operator/.config/chat/token"}, + Resources: []map[string]any{{ + "id": "settings", "type": "file", "merge": "json", + "path": "/home/operator/.config/chat/settings.json", "content": "{}", + }}, + }}} + out, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"chat": {"api-token": "sealed-token"}}, + Settings: SettingsBy{"chat": {{From: "node", Values: map[string]any{ + "chat.atlassian.token": "${secret:api-token}", + }}}}, + }) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "chat.settings") + got, _ := file["secrets"].(map[string]any) + if got["api-token"] != "sealed-token" { + t.Errorf("a placeholder a setting put there was never filled: %v", file) + } + // Filling secrets runs after settings for exactly this reason: before the merge, the content + // is "{}" and asks for nothing at all. + if !strings.Contains(file["content"].(string), "${secret:api-token}") { + t.Errorf("the hole did not survive to the machine: %v", file["content"]) + } +} + +// An own secret and a requirement of the same name would make ${secret:x} mean either. +func TestANameMeaningTwoThingsIsRefused(t *testing.T) { + r := Resolution{ + Node: "anchor", + Modules: []Manifest{{ + Module: "thing", + OwnSecrets: map[string]string{"store": "/var/lib/thing/own.env"}, + Secrets: map[string]string{"store": "/var/lib/thing/granted.env"}, + }}, + Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}}, + } + _, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"thing": {"store": "sealed-own"}}, + }) + if err == nil || !strings.Contains(err.Error(), "rename") { + t.Errorf("one name meant two credentials and nothing said so: %v", err) + } +} + +// A file with no placeholder is not given a secrets map. The host refuses a secret its content +// never asks for, so an empty map added helpfully would break every file that has none. +func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{{ + Module: "gitea", + OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n", + }}, + }}} + out, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}}, + }) + if err != nil { + t.Fatal(err) + } + if _, given := fileNamed(out, "gitea.conf")["secrets"]; given { + t.Error("a file that asks for nothing was given a secrets map, which the host refuses") + } +}