secrets: maps a requirement to several files under local names. Each local name is its own need, its own pair credential (the pair is keyed on it: migration 0027), its own file on the consumer, its own holder at the provider (the identity with the local name after it) and rotates apart from the others. The plain shape is unchanged and every existing row is the credential it was (novox/hq 04-ISSUES/069, ADR 0094).
216 lines
8.5 KiB
Go
216 lines
8.5 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// 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: they are separate
|
|
// repositories and this is a wire format, like the shape of the declaration itself.
|
|
//
|
|
// **Nothing here can check that they agree, and the claim that something did was wrong.** A unit
|
|
// test in this repository can only assert what this repository already believes. What proves it is
|
|
// the lab, where a real host receives a real declaration and the file arrives filled — and where
|
|
// the two expressions disagreeing shows up as a placeholder written through to a machine.
|
|
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 m.SecretRequirements() {
|
|
for _, file := range m.SecretFiles(to) {
|
|
key := SecretLocal(to, file.Local)
|
|
if _, taken := sealed[key]; 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, key, key, key)
|
|
}
|
|
for i := range needs {
|
|
// `For == m.Module`, not name alone: on a node with two modules requiring the same
|
|
// provision, both appear in `needs`, and matching by name would fill ${secret:X} with
|
|
// whichever came last — the other module's credential (novox/hq 04-ISSUES/022). And
|
|
// the local name, where the module keeps several (ADR 0094).
|
|
if needs[i].Name == to && needs[i].For == m.Module && needs[i].Local == file.Local && needs[i].Sealed != "" {
|
|
sealed[key] = needs[i].Sealed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return sealed, nil
|
|
}
|
|
|
|
// SecretsInEnvironment is the key a container carries to say, out loud, that a secret reaches
|
|
// its process through the environment — and why.
|
|
//
|
|
// **A secret reaches a process as a file** (novox/hq ADR 0086). The mesh seals a value to the
|
|
// machine and the host writes it at 0600; a container that then reads it from an env-file hands
|
|
// it to the runtime, which prints it in `docker inspect` and keeps it in the process's /proc entry
|
|
// for anything on the machine that can talk to the runtime (novox/hq 04-ISSUES/041). Some software
|
|
// reads its configuration from the environment and nothing else, and for that this key exists: a
|
|
// reason, on the container, so a reader can tell from the manifest which secrets are exposed that
|
|
// way and which are not. Without it, a container whose env-file carries a secret is refused.
|
|
//
|
|
// Catalogue-level: the host never sees this key.
|
|
const SecretsInEnvironment = "secrets-in-environment"
|
|
|
|
// refuseSecretsInEnvironment is the check. `secretFiles` is every file of this module whose
|
|
// content names a secret.
|
|
func refuseSecretsInEnvironment(resource map[string]any, secretFiles map[string]bool, module string) error {
|
|
if fmt.Sprint(resource["type"]) != "container" {
|
|
return nil
|
|
}
|
|
name := fmt.Sprint(resource["name"])
|
|
if env, ok := resource["env"].(map[string]any); ok {
|
|
for key, value := range env {
|
|
if strings.Contains(fmt.Sprint(value), "${secret:") {
|
|
return fmt.Errorf(
|
|
"%s's container %s puts ${secret:…} in its env (%s). A secret is never filled into an "+
|
|
"environment variable: put it in a file the module declares and mount that, or name the "+
|
|
"file in env-file and say %q why", module, name, key, SecretsInEnvironment)
|
|
}
|
|
}
|
|
}
|
|
reason, _ := resource[SecretsInEnvironment].(string)
|
|
if strings.TrimSpace(reason) != "" {
|
|
return nil
|
|
}
|
|
if files, ok := resource["env-file"].([]any); ok {
|
|
for _, f := range files {
|
|
if secretFiles[fmt.Sprint(f)] {
|
|
return fmt.Errorf(
|
|
"%s's container %s reads %s as an env-file, and that file carries a secret, so the secret "+
|
|
"reaches the process environment — readable in `docker inspect` and /proc (novox/hq "+
|
|
"04-ISSUES/041). Mount the secret's file and point the program at it, or, if the program "+
|
|
"reads only its environment, say so on the container: %q: \"<why>\"",
|
|
module, name, f, SecretsInEnvironment)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// secretFilesOf is the paths of a module's file resources whose content names a secret.
|
|
func secretFilesOf(resources []map[string]any) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, r := range resources {
|
|
if fmt.Sprint(r["type"]) != "file" {
|
|
continue
|
|
}
|
|
if content, ok := r["content"].(string); ok && len(secretsUsed(content)) > 0 {
|
|
out[fmt.Sprint(r["path"])] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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]
|
|
}
|