A file may hold a credential where its content says one belongs
The gap that stopped keycloak and gitea from starting. A granted
credential arrives as a file whose entire content is the password, which
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 token at
an attribute inside it. A module in that position could be handed the
bare value or nothing, and both are useless.
The host has been able to do this all along: content with ${secret:name}
in it, sealed values beside it, substitution on the machine, which is
the only place both halves exist. 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.
A module reaches its own secrets and the credentials it was granted —
both things it wrote in its own manifest — and nothing else. Naming
another module's is refused: 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.
Filling runs after settings, which is the whole reason it sits where it
does. A setting is how a placeholder gets into a JSON document in the
first place — the desktop client that reads its token from an attribute,
not an environment variable. Before the merge that file's content is
"{}" and asks for nothing.
Tested through Declaration rather than through the helper. Three times
in this repository a test asserted on a helper while the code calling it
was wrong, and each time the injected fault stayed silent. Three faults
injected here — the call removed, the call moved before settings, and
the module boundary widened — each caught by the test meant for it.
This commit is contained in:
@@ -302,6 +302,13 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
resources = withMeshNames(resources, with.Names)
|
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 {
|
for _, unsettled := range resources {
|
||||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -311,6 +318,13 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
for k, v := range resource {
|
for k, v := range resource {
|
||||||
copied[k] = v
|
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"])
|
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
||||||
// A service saying what it reflects names resources within its own module, so those
|
// A service saying what it reflects names resources within its own module, so those
|
||||||
// are prefixed too or they would point at nothing.
|
// are prefixed too or they would point at nothing.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user