diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 30b101b..16a49f1 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -232,7 +232,7 @@ func usage() { licence add|list|use|key model access, under the name a person calls it licence manager the node that holds a refreshable licence's refresh token licence refresh mint a new access token and seal it to every holder - rotate [--consumer ] a new credential for every holder, both ends at once + rotate [--consumer ] [--module ] a new credential for every holder, both ends at once ask [json] call one of a module's tools over the broker, and print its answer pin which provider this one gets a provision from: the module, and its node diff --git a/cmd/mesh-controller/rotate.go b/cmd/mesh-controller/rotate.go index 0037254..06d7a88 100644 --- a/cmd/mesh-controller/rotate.go +++ b/cmd/mesh-controller/rotate.go @@ -6,6 +6,8 @@ import ( "flag" "fmt" "sort" + + "github.com/novox/mesh-controller/internal/inventory" ) // rotateCommand replaces a credential and moves both ends together. @@ -33,12 +35,16 @@ func rotateCommand(ctx context.Context, args []string) error { // One consumer rather than all of them. Ordinary: a credential is suspected on one machine, // and rotating the other nine would be a great deal of disruption for one suspicion. only := set.String("consumer", "", "only this machine's credential, rather than every holder's") + // One consuming module rather than every module on the machine. A machine runs many consumers + // of one provision, each with its own credential; one module that leaked its credential (novox/hq + // issue 268) is no reason to restart every other one on the machine. + module := set.String("module", "", "only this consuming module's credential") positionals, err := parseAround(set, args) if err != nil { return err } if len(positionals) != 1 { - return errors.New("rotate [--consumer ]") + return errors.New("rotate [--consumer ] [--module ]") } provision := positionals[0] @@ -53,6 +59,12 @@ func rotateCommand(ctx context.Context, args []string) error { if err != nil { return err } + holders = ofModule(holders, *module) + if len(holders) == 0 && *module != "" { + return fmt.Errorf( + "no module %s%s holds a credential for %q, so there is nothing to rotate. `plan ` "+ + "says what a machine holds", *module, onMachine(*only), provision) + } if len(holders) == 0 { // Said, not silent. "Nobody holds this" and "this did not run" must never look the same — // and a rotation somebody believes happened is worse than one they know did not. @@ -116,6 +128,27 @@ func rotateCommand(ctx context.Context, args []string) error { return nil } +// ofModule is the holders whose consuming module is this one; all of them when none is named. +func ofModule(holders []inventory.Holder, module string) []inventory.Holder { + if module == "" { + return holders + } + var out []inventory.Holder + for _, h := range holders { + if h.ConsumerModule == module { + out = append(out, h) + } + } + return out +} + +func onMachine(machine string) string { + if machine == "" { + return "" + } + return " on " + machine +} + // asLocal names the credential inside the consumer where it holds several (ADR 0094). func asLocal(local string) string { if local == "" { diff --git a/cmd/mesh-controller/rotate_test.go b/cmd/mesh-controller/rotate_test.go new file mode 100644 index 0000000..a072eaf --- /dev/null +++ b/cmd/mesh-controller/rotate_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// One consuming module's credential, and not its neighbours' on the same machine (novox/hq issue +// 268): a module that leaked its database password is no reason to restart every other consumer. +func TestARotationNarrowedToAModuleTouchesOnlyThatModulesCredential(t *testing.T) { + holders := []inventory.Holder{ + {Provision: "postgres-database", Consumer: "ace", ConsumerModule: "letta", Provider: "ace"}, + {Provision: "postgres-database", Consumer: "ace", ConsumerModule: "n8n", Provider: "ace"}, + {Provision: "postgres-database", Consumer: "ace", ConsumerModule: "letta", Local: "reader", Provider: "ace"}, + } + got := ofModule(holders, "letta") + if len(got) != 2 || got[0].ConsumerModule != "letta" || got[1].Local != "reader" { + t.Fatalf("narrowed to letta: %+v", got) + } + if len(ofModule(holders, "")) != 3 { + t.Fatal("no module named narrowed anyway") + } + if len(ofModule(holders, "absent")) != 0 { + t.Fatal("a module holding nothing matched") + } +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 25beacb..5c706b3 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -371,6 +371,11 @@ func (a *verbArguments) commandLine() ([]string, error) { if c := str("consumer"); c != "" { argv = append(argv, "--consumer", c) } + // With a provision, module narrows to one consuming module (novox/hq issue 268); node + // and secret stay the other shape's, and are refused as passed over. + if m := str("module"); m != "" { + argv = append(argv, "--module", m) + } return argv, nil } _, node := a.given["node"] diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 5cc0eca..fa7f899 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -43,6 +43,10 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" { t.Fatalf("a pair credential: %v", argv) } + argv, _ = argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace", "module": "letta"}) + if strings.Join(argv, " ") != "rotate postgres-database --consumer ace --module letta" { + t.Fatalf("one consuming module's 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) diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index ae71f20..6d30eb8 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -142,7 +142,7 @@ var ControllerVerbs = []Verb{ "node": "the machine's name; without it, every machine that is behind", "behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node", }, nil, "behind")}, - {Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " + + {Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine and module, " + "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.", @@ -150,7 +150,7 @@ var ControllerVerbs = []Verb{ "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", + "module": "an own secret: the module; with provision: only this consuming module's credential (optional)", "secret": "an own secret: its name in the module's definition", }, nil)}, {Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +