A module's own secret rotates when its definition says the module reads it at start (hq 180, forge 231) #183

Merged
mesh-admin merged 1 commits from feat/231-an-own-secret-rotates into main 2026-10-01 09:42:10 +00:00
18 changed files with 409 additions and 34 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ import (
// A module that declares none is refused before the account exists, so the bus never carries an // A module that declares none is refused before the account exists, so the bus never carries an
// account nothing reads (novox/hq 04-ISSUES/078). // account nothing reads (novox/hq 04-ISSUES/078).
func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) { func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: map[string]string{"password": "/run/password"}}) err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}})
if err == nil { if err == nil {
t.Fatal("a module with no broker own secret was issued an account") t.Fatal("a module with no broker own secret was issued an account")
} }
@@ -20,7 +20,7 @@ func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
t.Errorf("the refusal does not say %q: %v", want, err) t.Errorf("the refusal does not say %q: %v", want, err)
} }
} }
if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: map[string]string{"broker": "/run/broker"}}); err != nil { if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}); err != nil {
t.Errorf("a module declaring its broker secret was refused: %v", err) t.Errorf("a module declaring its broker secret was refused: %v", err)
} }
} }
+14
View File
@@ -87,6 +87,20 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
return []string{"push", n, "--wait", "0"}, nil return []string{"push", n, "--wait", "0"}, nil
} }
return []string{"push", "--behind", "--wait", "0"}, nil return []string{"push", "--behind", "--wait", "0"}, nil
case "rotate":
if p := str("provision"); p != "" {
argv := []string{"rotate", p}
if c := str("consumer"); c != "" {
argv = append(argv, "--consumer", c)
}
return argv, nil
}
if str("node") != "" && str("module") != "" && str("secret") != "" {
return []string{"secret", "rotate", str("node"), str("module"), str("secret")}, nil
}
// Half of either shape: the command says its usage, which names both shapes, and that is
// the answer the caller needs.
return []string{"rotate"}, nil
case "build": case "build":
if err := need("repository"); err != nil { if err := need("repository"); err != nil {
return nil, err return nil, err
+17
View File
@@ -56,6 +56,23 @@ func TestTheBuildToolTellsAForgePathFromAURL(t *testing.T) {
} }
} }
// `rotate` is one verb with two shapes (ADR 0114, issue 180): a pair credential by provision, or a
// module's own secret by machine, module and name.
func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
argv, _ := argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace"})
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" {
t.Fatalf("a pair credential: %v", argv)
}
argv, _ = argvFor("rotate", map[string]any{"node": "ace", "module": "nodered", "secret": "api-token"})
if strings.Join(argv, " ") != "secret rotate ace nodered api-token" {
t.Fatalf("an own secret: %v", argv)
}
argv, _ = argvFor("rotate", map[string]any{"node": "ace"})
if strings.Join(argv, " ") != "rotate" {
t.Fatalf("half an own secret falls to the command's usage: %v", argv)
}
}
// A required argument missing is refused in the verb's own words, before anything runs. // A required argument missing is refused in the verb's own words, before anything runs.
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) { func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) { if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
+48 -1
View File
@@ -10,6 +10,7 @@ import (
"io" "io"
"os" "os"
"strings" "strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
@@ -38,6 +39,8 @@ func secretCommand(ctx context.Context, args []string) error {
} }
switch args[0] { switch args[0] {
case "accept": case "accept":
case "rotate":
return secretRotate(ctx, args[1:])
case "recover": case "recover":
return secretRecover(ctx, args[1:]) return secretRecover(ctx, args[1:])
case "export": case "export":
@@ -101,7 +104,8 @@ func secretCommand(ctx context.Context, args []string) error {
return nil return nil
} }
const secretUsage = "secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" + const secretUsage = "secret rotate <node> <module> <name>\n" +
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" + "secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]" "secret export [--out <file>]"
@@ -359,3 +363,46 @@ func valueFor(node, module, name, from string) (string, error) {
return line, nil return line, nil
} }
} }
// secretRotate makes a module's own secret anew and sends the machine, so the module starts again on
// the new value (novox/hq ADR 0114, issue 180). A pair credential rotates with `rotate <provision>`;
// this is the secret with one party. Said in the log with who asked and when, never the value.
func secretRotate(ctx context.Context, args []string) error {
rest, _ := split(args)
if len(rest) != 3 {
return errors.New(secretUsage)
}
node, module, name := rest[0], rest[1], rest[2]
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
if err := open.inventory.RotateModuleSecret(ctx, node, module, name); err != nil {
var refused inventory.ErrNotRotatable
if errors.As(err, &refused) {
return fmt.Errorf("not rotated: %s", refused.Why)
}
return err
}
fmt.Printf("rotated %q of %s on %s at %s, asked by %s; the value is sealed and not shown\n",
name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked())
fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module)
if err := sendTo(ctx, open, []string{node}); err != nil {
return fmt.Errorf("%w\n\nThe new value is sealed and not yet delivered; what runs keeps the old "+
"one until the machine next applies. Fix the cause and run `push %s`", err, node)
}
return nil
}
// whoAsked names the caller for the log: the account the command runs as, which for a tool call
// through the console is the mesh's own.
func whoAsked() string {
if u := os.Getenv("SUDO_USER"); u != "" {
return u
}
if u := os.Getenv("USER"); u != "" {
return u
}
return "the mesh"
}
+1 -1
View File
@@ -465,7 +465,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
"%s needs a secret called %q and none was made for it", m.Module, name) "%s needs a secret called %q and none was made for it", m.Module, name)
} }
first = append(first, ownedBy(m.SecretsOwner, map[string]any{ first = append(first, ownedBy(m.SecretsOwner, map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name], "sealed": sealed, "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
})) }))
} }
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
+10 -2
View File
@@ -161,9 +161,17 @@ func placedManifest(m Manifest, with Rendering) (Manifest, error) {
if m.Secrets, err = fillMap(m.Secrets); err != nil { if m.Secrets, err = fillMap(m.Secrets); err != nil {
return m, err return m, err
} }
if m.OwnSecrets, err = fillMap(m.OwnSecrets); err != nil { if len(m.OwnSecrets) > 0 {
own := make(OwnSecrets, len(m.OwnSecrets))
for name, s := range m.OwnSecrets {
filled, err := dirFill(s.Path, dirs, m.Module)
if err != nil {
return m, err return m, err
} }
own[name] = OwnSecret{Path: filled, Taken: s.Taken}
}
m.OwnSecrets = own
}
if m.Grants, err = fillMap(m.Grants); err != nil { if m.Grants, err = fillMap(m.Grants); err != nil {
return m, err return m, err
} }
@@ -335,7 +343,7 @@ func (m Manifest) unknownDirRefs() []string {
} }
maps := map[string]map[string]string{ maps := map[string]map[string]string{
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets, "receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
"own-secrets": m.OwnSecrets, "grants": m.Grants, "own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
} }
for field, entries := range maps { for field, entries := range maps {
for _, value := range entries { for _, value := range entries {
+4 -4
View File
@@ -164,7 +164,7 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
}, },
Binds: map[string]string{"route": "${dir:state}/route.json"}, Binds: map[string]string{"route": "${dir:state}/route.json"},
Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"}, Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"},
OwnSecrets: map[string]string{"admin-key": "${dir:state}/admin-key.secret"}, OwnSecrets: OwnSecrets{"admin-key": {Path: "${dir:state}/admin-key.secret"}},
Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"}, Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"},
} }
placed, err := placedManifest(m, Rendering{}) placed, err := placedManifest(m, Rendering{})
@@ -177,7 +177,7 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" { if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" {
t.Fatalf("secrets are placed; got %v", placed.Secrets) t.Fatalf("secrets are placed; got %v", placed.Secrets)
} }
if placed.OwnSecrets["admin-key"] != "/var/lib/photos/admin-key.secret" { if placed.OwnSecrets["admin-key"].Path != "/var/lib/photos/admin-key.secret" {
t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets) t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets)
} }
if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" { if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" {
@@ -231,7 +231,7 @@ func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
{"id": "server", "type": "container", "image": "x@sha256:aa", {"id": "server", "type": "container", "image": "x@sha256:aa",
"volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}}, "volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}},
}, },
OwnSecrets: map[string]string{"broker": "${dir:mesh-state}/broker"}, OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
Binds: map[string]string{"route": "${dir:state}/route.json"}, Binds: map[string]string{"route": "${dir:state}/route.json"},
} }
if got := m.unknownDirRefs(); len(got) != 0 { if got := m.unknownDirRefs(); len(got) != 0 {
@@ -249,7 +249,7 @@ func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if placed.OwnSecrets["broker"] != "/var/lib/mesh/umami/broker" { if placed.OwnSecrets["broker"].Path != "/var/lib/mesh/umami/broker" {
t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets) t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets)
} }
container := shallowCopy(m.Resources[2]) container := shallowCopy(m.Resources[2])
+1 -1
View File
@@ -419,7 +419,7 @@ func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) {
func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) { func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) {
r := Resolution{Modules: []Manifest{{ r := Resolution{Modules: []Manifest{{
Module: "networking", Computed: "mesh-network", Module: "networking", Computed: "mesh-network",
OwnSecrets: map[string]string{"key": "/var/lib/mesh/key"}, OwnSecrets: OwnSecrets{"key": {Path: "/var/lib/mesh/key"}},
}}} }}}
out, err := r.Declaration(Rendering{ out, err := r.Declaration(Rendering{
Needed: map[string]map[string]string{"networking": {"key": "sealed"}}, Needed: map[string]map[string]string{"networking": {"key": "sealed"}},
+87 -6
View File
@@ -390,7 +390,16 @@ type Manifest struct {
// module running on three machines has three passwords and the mesh can read none of them. A // module running on three machines has three passwords and the mesh can read none of them. A
// manifest carrying one instead would put the same secret on every machine that ever runs the // manifest carrying one instead would put the same secret on every machine that ever runs the
// module, in a file anybody can read, for ever. // module, in a file anybody can read, for ever.
OwnSecrets map[string]string `json:"own-secrets,omitempty"` //
// **And how the module takes it** (novox/hq ADR 0114, issue 180): `"admin": "<path>"` says
// where and nothing else; `"admin": {"path": "<path>", "taken": "at-start"}` says the module
// reads the file when it starts, so the mesh may rotate it by making a new value and starting
// the module again; `"taken": "applied"` says the module's own code applies it to a backend
// that takes it only once, so a rotation must be staged beside the current value — the form the
// mesh does not build yet, and refuses by name. A secret that says neither is not rotated by
// the mesh: the one fault worse than an unrotated credential is a rotated one the software
// never saw.
OwnSecrets OwnSecrets `json:"own-secrets,omitempty"`
// SecretsOwner is who the files holding this module's secrets belong to on the machine — // SecretsOwner is who the files holding this module's secrets belong to on the machine —
// `uid:gid`, or a name — when its process is not root. // `uid:gid`, or a name — when its process is not root.
@@ -1419,14 +1428,20 @@ func ParseManifest(raw []byte) (Manifest, error) {
} }
} }
} }
for name, where := range m.OwnSecrets { for name, own := range m.OwnSecrets {
if !placedOrAbsolute(where) { if !placedOrAbsolute(own.Path) {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, where)) "%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, own.Path))
} }
if name == "" { if name == "" {
problems = append(problems, m.Module+" needs a secret with no name") problems = append(problems, m.Module+" needs a secret with no name")
} }
if own.Taken != "" && own.Taken != TakenAtStart && own.Taken != TakenApplied {
problems = append(problems, fmt.Sprintf(
"%s says its secret %q is taken %q; a secret is taken %q (read when the module starts) "+
"or %q (applied by the module's own code to a backend that takes it once)",
m.Module, name, own.Taken, TakenAtStart, TakenApplied))
}
} }
localOf := map[string]string{} localOf := map[string]string{}
for _, to := range m.SecretRequirements() { for _, to := range m.SecretRequirements() {
@@ -1690,8 +1705,8 @@ func (m Manifest) undeclaredMounts() []string {
claim(fmt.Sprint(r["path"])) claim(fmt.Sprint(r["path"]))
} }
} }
for _, where := range m.OwnSecrets { for _, own := range m.OwnSecrets {
claim(where) claim(own.Path)
} }
for _, to := range m.SecretRequirements() { for _, to := range m.SecretRequirements() {
for _, f := range m.SecretFiles(to) { for _, f := range m.SecretFiles(to) {
@@ -1826,3 +1841,69 @@ func invokeProblems(m Manifest) []string {
} }
return problems return problems
} }
// How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or
// applied by its own code to a backend that takes it once.
const (
TakenAtStart = "at-start"
TakenApplied = "applied"
)
// OwnSecret is where one of a module's own secrets lands, and how the module takes it.
type OwnSecret struct {
Path string
Taken string
}
// OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming
// the path and how it is taken; written back the way it was read, so a manifest the mesh holds
// keeps its bytes.
type OwnSecrets map[string]OwnSecret
func (o *OwnSecrets) UnmarshalJSON(raw []byte) error {
var entries map[string]json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return err
}
out := make(OwnSecrets, len(entries))
for name, body := range entries {
var path string
if err := json.Unmarshal(body, &path); err == nil {
out[name] = OwnSecret{Path: path}
continue
}
var long struct {
Path string `json:"path"`
Taken string `json:"taken,omitempty"`
}
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&long); err != nil {
return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\"}: %w", name, err)
}
out[name] = OwnSecret{Path: long.Path, Taken: long.Taken}
}
*o = out
return nil
}
func (o OwnSecrets) MarshalJSON() ([]byte, error) {
entries := make(map[string]any, len(o))
for name, s := range o {
if s.Taken == "" {
entries[name] = s.Path
continue
}
entries[name] = map[string]string{"path": s.Path, "taken": s.Taken}
}
return json.Marshal(entries)
}
// Paths is each own secret's path by name — the shape every placement and file walk reads.
func (o OwnSecrets) Paths() map[string]string {
out := make(map[string]string, len(o))
for name, s := range o {
out[name] = s.Path
}
return out
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
func needy() Manifest { func needy() Manifest {
return Manifest{ return Manifest{
Module: "postgres", Version: "1", Module: "postgres", Version: "1",
OwnSecrets: map[string]string{"superuser": "/var/lib/mesh/postgres/superuser"}, OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/mesh/postgres/superuser"}},
Resources: []map[string]any{ Resources: []map[string]any{
{"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"}, {"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"},
}, },
@@ -74,7 +74,7 @@ func TestANeedIsAnAbsolutePath(t *testing.T) {
func TestAModuleMayNeedSeveralThings(t *testing.T) { func TestAModuleMayNeedSeveralThings(t *testing.T) {
// A password and a token, say. Telling them apart is the module's business, not the mesh's. // A password and a token, say. Telling them apart is the module's business, not the mesh's.
m := needy() m := needy()
m.OwnSecrets["replication"] = "/var/lib/mesh/postgres/replication" m.OwnSecrets["replication"] = OwnSecret{Path: "/var/lib/mesh/postgres/replication"}
got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{}) got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{})
out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{ out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{
"postgres": {"superuser": "b25l", "replication": "dHdv"}, "postgres": {"superuser": "b25l", "replication": "dHdv"},
@@ -0,0 +1,54 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// An own secret says how the module takes it (novox/hq ADR 0114, issue 180): a path alone says
// nothing of it, an object says `at-start` or `applied`, and the bytes the mesh holds are the bytes
// it was given either way.
func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
"broker":"/var/lib/mesh/idp/broker",
"admin":{"path":"/var/lib/idp/admin.secret","taken":"applied"},
"session":{"path":"/var/lib/idp/session.secret","taken":"at-start"}}}`))
if err != nil {
t.Fatal(err)
}
if m.OwnSecrets["broker"] != (OwnSecret{Path: "/var/lib/mesh/idp/broker"}) {
t.Fatalf("a path alone is a path and nothing more: %+v", m.OwnSecrets["broker"])
}
if m.OwnSecrets["admin"].Taken != TakenApplied || m.OwnSecrets["session"].Taken != TakenAtStart {
t.Fatalf("the word was not kept: %+v", m.OwnSecrets)
}
// Written back the way it was read, so a registered manifest keeps its bytes.
out, err := json.Marshal(m.OwnSecrets)
if err != nil {
t.Fatal(err)
}
var again OwnSecrets
if err := json.Unmarshal(out, &again); err != nil {
t.Fatal(err)
}
if len(again) != 3 || again["admin"].Taken != TakenApplied || again["broker"].Taken != "" {
t.Fatalf("the round trip changed the secrets: %s", out)
}
if !strings.Contains(string(out), `"broker":"/var/lib/mesh/idp/broker"`) {
t.Fatalf("a path alone is written back as a path: %s", out)
}
}
func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
"admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`))
if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) {
t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err)
}
_, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
"admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`))
if err == nil {
t.Fatal("an unknown field on an own secret was accepted")
}
}
@@ -15,7 +15,7 @@ func aModuleWithAnEnvFileSecret(exception string) Manifest {
} }
return Manifest{ return Manifest{
Module: "app", Version: "1", Module: "app", Version: "1",
OwnSecrets: map[string]string{"token": "/var/lib/app/token.secret"}, OwnSecrets: OwnSecrets{"token": {Path: "/var/lib/app/token.secret"}},
Resources: []map[string]any{ Resources: []map[string]any{
{"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600", {"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600",
"content": "APP_TOKEN=${secret:token}\n"}, "content": "APP_TOKEN=${secret:token}\n"},
@@ -25,7 +25,7 @@ func fileNamed(out []map[string]any, id string) map[string]any {
func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) { func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{{ r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea", Module: "gitea",
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "[security]\nSECRET_KEY = ${secret:admin}\n", "content": "[security]\nSECRET_KEY = ${secret:admin}\n",
@@ -130,7 +130,7 @@ func TestTwoConsumersOfOneProvisionEachGetTheirOwnInAPlaceholderFile(t *testing.
func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) { func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{{ r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea", Module: "gitea",
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "SECRET_KEY = ${secret:adnim}\n", "content": "SECRET_KEY = ${secret:adnim}\n",
@@ -151,7 +151,7 @@ func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
// One module may not read another's credential by guessing its name. // One module may not read another's credential by guessing its name.
func TestAFileCannotNameAnotherModulesSecret(t *testing.T) { func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{ r := Resolution{Node: "anchor", Modules: []Manifest{
{Module: "postgres", OwnSecrets: map[string]string{"superuser": "/var/lib/postgres/su.env"}}, {Module: "postgres", OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/postgres/su.env"}}},
{Module: "gitea", Resources: []map[string]any{{ {Module: "gitea", Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "PASSWORD=${secret:superuser}\n", "content": "PASSWORD=${secret:superuser}\n",
@@ -174,7 +174,7 @@ func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) { func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) {
r := Resolution{Node: "workstation", Modules: []Manifest{{ r := Resolution{Node: "workstation", Modules: []Manifest{{
Module: "chat", Module: "chat",
OwnSecrets: map[string]string{"api-token": "/home/operator/.config/chat/token"}, OwnSecrets: OwnSecrets{"api-token": {Path: "/home/operator/.config/chat/token"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "settings", "type": "file", "merge": "json", "id": "settings", "type": "file", "merge": "json",
"path": "/home/operator/.config/chat/settings.json", "content": "{}", "path": "/home/operator/.config/chat/settings.json", "content": "{}",
@@ -207,7 +207,7 @@ func TestANameMeaningTwoThingsIsRefused(t *testing.T) {
Node: "anchor", Node: "anchor",
Modules: []Manifest{{ Modules: []Manifest{{
Module: "thing", Module: "thing",
OwnSecrets: map[string]string{"store": "/var/lib/thing/own.env"}, OwnSecrets: OwnSecrets{"store": {Path: "/var/lib/thing/own.env"}},
Secrets: map[string]string{"store": "/var/lib/thing/granted.env"}, Secrets: map[string]string{"store": "/var/lib/thing/granted.env"},
}}, }},
Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}}, Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}},
@@ -225,7 +225,7 @@ func TestANameMeaningTwoThingsIsRefused(t *testing.T) {
func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) { func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{{ r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea", Module: "gitea",
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"}, OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{ Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n", "id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n",
}}, }},
+11
View File
@@ -99,6 +99,17 @@ var ControllerVerbs = []Verb{
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})}, Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.", {Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)}, Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
"name: made anew and the machine sent, so the module starts again on it — only for a secret its " +
"definition says it reads at start; a value given to the mesh, or one the module applies to a backend, is refused with the reason.",
Input: schema(map[string]string{
"provision": "a pair credential: the provision whose credential to replace",
"consumer": "with provision: only the holder on this machine (optional)",
"node": "an own secret: the machine",
"module": "an own secret: the module",
"secret": "an own secret: its name in the module's definition",
}, nil)},
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " + {Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.", "`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
Input: schema(map[string]string{ Input: schema(map[string]string{
+2 -2
View File
@@ -32,7 +32,7 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
m := catalogue.Manifest{Module: "step-ca", Version: "1", m := catalogue.Manifest{Module: "step-ca", Version: "1",
Provides: catalogue.Offers("acme-ca"), OwnSecrets: map[string]string{"password": "/run/password"}} Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil { if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -228,6 +228,6 @@ func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T)
// withOwnSecret gives a fixture manifest an own secret, so a delivery to it is one the module // withOwnSecret gives a fixture manifest an own secret, so a delivery to it is one the module
// declares (novox/hq 04-ISSUES/078). // declares (novox/hq 04-ISSUES/078).
func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest { func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest {
m.OwnSecrets = map[string]string{name: "/run/" + name} m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}}
return m return m
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) { func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1",
OwnSecrets: map[string]string{"superuser": "/run/superuser", "replication": "/run/replication"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"superuser": {Path: "/run/superuser"}, "replication": {Path: "/run/replication"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+86 -1
View File
@@ -505,7 +505,7 @@ func declaresOwn(m catalogue.Manifest) string {
if len(m.OwnSecrets) == 0 { if len(m.OwnSecrets) == 0 {
return "it declares no own secrets" return "it declares no own secrets"
} }
return "it declares: " + strings.Join(sortedNames(m.OwnSecrets), ", ") return "it declares: " + strings.Join(sortedNames(m.OwnSecrets.Paths()), ", ")
} }
func sortedNames(of map[string]string) []string { func sortedNames(of map[string]string) []string {
@@ -523,3 +523,88 @@ func orNone(names []string) string {
} }
return strings.Join(names, ", ") return strings.Join(names, ", ")
} }
// ErrNotRotatable says why the mesh will not rotate a module's own secret; the words are the caller's
// to print, and the remedy is in them.
type ErrNotRotatable struct{ Why string }
func (e ErrNotRotatable) Error() string { return e.Why }
// RotateModuleSecret makes a module's own secret anew, the way the first mint did (novox/hq
// ADR 0114, issue 180). The caller sends the node, so the module is started again on the new value.
//
// **Only a secret the module reads when it starts.** A secret the module's code applies to a
// backend that takes it once would, rotated this way, leave the backend on the old value and the
// module reading the new one — the fault issue 179 was. That form is staged, which the mesh does
// not build yet, and is refused by name. A secret whose manifest says neither is refused with the
// word to write; a secret given to the mesh rather than made by it is refused as 0113 says: the
// mesh will not replace what it cannot read.
func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name string) error {
m, err := i.declared(ctx, module)
if err != nil {
return err
}
own, declared := m.OwnSecrets[name]
if !declared {
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
}
switch own.Taken {
case catalogue.TakenAtStart:
case catalogue.TakenApplied:
return ErrNotRotatable{Why: fmt.Sprintf(
"%s applies %q to a backend that takes it once, so a rotation must be staged beside the "+
"current value until the module confirms it — the mesh does not do that yet (ADR 0114). "+
"Changing it is a person's work: change it in %s, then `secret accept %s %s %s`",
module, name, module, node, module, name)}
default:
return ErrNotRotatable{Why: fmt.Sprintf(
"%s does not say how it takes %q, so the mesh will not rotate it: a secret rotated under "+
"software that never reads it again is worse than one left alone. Its definition says "+
"\"own-secrets\": {%q: {\"path\": …, \"taken\": \"at-start\"}} when the module reads it as it "+
"starts, or \"applied\" when its own code applies it",
module, name, name)}
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
key, err := i.SealingKeyOf(ctx, node)
if err != nil {
return err
}
if key == "" {
return fmt.Errorf("%s has no sealing key, so nothing can be sealed to it", node)
}
var origin string
err = i.store.Pool().QueryRow(ctx,
`select origin from module_secret where node = $1 and module = $2 and name = $3`,
record.ID, module, name).Scan(&origin)
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("%s on %s holds no %q yet; the first push makes it", module, node, name)
}
if err != nil {
return err
}
if origin == OriginAccepted {
return ErrNotRotatable{Why: fmt.Sprintf(
"%s on %s holds %q as a value given to the mesh, not made by it, and the mesh will not "+
"replace what it cannot read (ADR 0113). Change it where it lives, then `secret accept "+
"%s %s %s` with the new value",
module, node, name, node, module, name)}
}
operator, err := i.OperatorKey(ctx)
if err != nil {
return err
}
made, blob, err := secrets.MakeWithOperator(key, key, operator)
if err != nil {
return err
}
forOperator, operatorKey := operatorColumns(operator, blob)
_, err = i.store.Pool().Exec(ctx,
`update module_secret set sealed = $4, node_key = $5, origin = 'made', made_at = now(),
operator_sealed = $6, operator_key = $7
where node = $1 and module = $2 and name = $3`,
record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey)
return err
}
+61 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/ecdh" "crypto/ecdh"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"errors"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets" "github.com/novox/mesh-controller/internal/secrets"
"strings" "strings"
@@ -408,7 +409,7 @@ func TestACredentialGoesWhenEitherMachineDoes(t *testing.T) {
func TestASecretTheMeshWasGivenIsNotReinventedWhenTheMachineRejoins(t *testing.T) { func TestASecretTheMeshWasGivenIsNotReinventedWhenTheMachineRejoins(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1",
OwnSecrets: map[string]string{"broker": "/run/broker"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
const url = "amqps://builder:the-password-the-broker-was-told@broker/" const url = "amqps://builder:the-password-the-broker-was-told@broker/"
@@ -440,7 +441,7 @@ func TestASecretTheMeshWasGivenIsNotReinventedWhenTheMachineRejoins(t *testing.T
func TestASecretTheMeshWasGivenSurvivesAnOrdinaryPush(t *testing.T) { func TestASecretTheMeshWasGivenSurvivesAnOrdinaryPush(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "builder", Version: "1",
OwnSecrets: map[string]string{"broker": "/run/broker"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := inv.AcceptSecretForModule(ctx, "consumer", "builder", "broker", if err := inv.AcceptSecretForModule(ctx, "consumer", "builder", "broker",
@@ -725,7 +726,7 @@ func TestTheOperatorRecoversEachLocalNameApart(t *testing.T) {
func TestADeliveredSecretIsRefusedUnderANameTheModuleDoesNotDeclare(t *testing.T) { func TestADeliveredSecretIsRefusedUnderANameTheModuleDoesNotDeclare(t *testing.T) {
inv, ctx := twoNodesWithKeys(t) inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "step-ca", Version: "1", if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "step-ca", Version: "1",
OwnSecrets: map[string]string{"password": "/run/password"}}, Source{}); err != nil { OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}}, Source{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
err := inv.AcceptSecretForModule(ctx, "consumer", "step-ca", "root-key", "not-a-key") err := inv.AcceptSecretForModule(ctx, "consumer", "step-ca", "root-key", "not-a-key")
@@ -785,3 +786,60 @@ func TestADeliveredPairCredentialIsRefusedForARequirementTheModuleDoesNotHave(t
t.Errorf("a delivery under a kept local was refused: %v", err) t.Errorf("a delivery under a kept local was refused: %v", err)
} }
} }
// A module's own secret rotates when its definition says the module reads it at start: made anew,
// sealed to the machine and the operator, origin made. Refused with the reason when the definition
// says nothing, says the module applies it, or when the value was given to the mesh (novox/hq
// ADR 0114, issue 180).
func TestAnOwnSecretRotatesOnlyWhenTheModuleReadsItAtStart(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
m := catalogue.Manifest{Module: "idp", Version: "1", OwnSecrets: catalogue.OwnSecrets{
"session": {Path: "/var/lib/idp/session.secret", Taken: catalogue.TakenAtStart},
"admin": {Path: "/var/lib/idp/admin.secret", Taken: catalogue.TakenApplied},
"broker": {Path: "/var/lib/mesh/idp/broker"},
}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
before, err := inv.SecretForModule(ctx, "consumer", "idp", "session")
if err != nil {
t.Fatal(err)
}
if err := inv.RotateModuleSecret(ctx, "consumer", "idp", "session"); err != nil {
t.Fatal(err)
}
after, err := inv.SecretForModule(ctx, "consumer", "idp", "session")
if err != nil {
t.Fatal(err)
}
if after == before {
t.Fatal("rotating made no new value")
}
if _, err := inv.SecretForModule(ctx, "consumer", "idp", "admin"); err != nil {
t.Fatal(err)
}
var refused ErrNotRotatable
err = inv.RotateModuleSecret(ctx, "consumer", "idp", "admin")
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "staged") {
t.Fatalf("an applied secret must be refused as not yet stageable: %v", err)
}
if _, err := inv.SecretForModule(ctx, "consumer", "idp", "broker"); err != nil {
t.Fatal(err)
}
err = inv.RotateModuleSecret(ctx, "consumer", "idp", "broker")
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "does not say how it takes") {
t.Fatalf("a secret that says nothing of how it is taken must be refused: %v", err)
}
if err := inv.AcceptSecretForModule(ctx, "consumer", "idp", "session", "the-real-one"); err != nil {
t.Fatal(err)
}
err = inv.RotateModuleSecret(ctx, "consumer", "idp", "session")
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "given to the mesh") {
t.Fatalf("an accepted value must be refused: %v", err)
}
if err := inv.RotateModuleSecret(ctx, "consumer", "idp", "nothing"); err == nil || !strings.Contains(err.Error(), "does not declare") {
t.Fatalf("an undeclared secret: %v", err)
}
}