A module's own secret rotates when its definition says the module reads it at start (hq 180, forge 231) #183
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -161,8 +161,16 @@ 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 {
|
||||||
return m, err
|
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
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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"}},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user