diff --git a/cmd/mesh-host/filling.go b/cmd/mesh-host/filling.go index 373aef2..fa241e8 100644 --- a/cmd/mesh-host/filling.go +++ b/cmd/mesh-host/filling.go @@ -126,15 +126,33 @@ func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSe if queue == nil { return p } - p.read = func(ctx context.Context, key string) ([]byte, bool, error) { - h, err := queue.KVHistory(ctx, provisions.ProvisionsBucket, key) + // What the bus holds under prefix, read by a watch to its caught-up mark: no poll, no list of every key. + p.list = func(ctx context.Context, prefix string) (map[string][]byte, error) { + wctx, cancel := context.WithCancel(ctx) + defer cancel() + updates, err := queue.KVWatch(wctx, provisions.ProvisionsBucket, prefix+">") if err != nil { - return nil, false, err + return nil, err } - if len(h) == 0 || h[len(h)-1].Deleted { - return nil, false, nil + out := map[string][]byte{} + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case u, ok := <-updates: + if !ok { + return nil, fmt.Errorf("the watch of %s ended before it caught up", provisions.ProvisionsBucket) + } + switch { + case u.Caught: + return out, nil + case u.Deleted: + delete(out, u.Key) + default: + out[u.Key] = u.Value + } + } } - return h[len(h)-1].Value, true, nil } p.own = func(ctx context.Context, args map[string]any) (json.RawMessage, error) { return queue.AskVault(ctx, "own", args) diff --git a/cmd/mesh-host/filling_test.go b/cmd/mesh-host/filling_test.go index cc5df0e..7885eb7 100644 --- a/cmd/mesh-host/filling_test.go +++ b/cmd/mesh-host/filling_test.go @@ -7,6 +7,7 @@ import ( "os" "os/user" "path/filepath" + "strings" "testing" "time" @@ -50,11 +51,16 @@ func TestAWriteAndAnApplyOfTheSameFileSerialise(t *testing.T) { // The provision m reads, as mesh-provisioner keeps it, and the vault's value of it, sealed to this machine (faked: // the opener reads the box's "v"). provision := map[string]string{} - vaultName := "provision/postgres/db" + vaultName := "provision/n2/db" secrets := provisionSecrets{node: "n1", - read: func(_ context.Context, key string) ([]byte, bool, error) { - v, ok := provision[key] - return []byte(v), ok, nil + list: func(_ context.Context, prefix string) (map[string][]byte, error) { + out := map[string][]byte{} + for k, v := range provision { + if strings.HasPrefix(k, prefix) { + out[k] = []byte(v) + } + } + return out, nil }, own: func(_ context.Context, args map[string]any) (json.RawMessage, error) { raw, _ := json.Marshal(args) @@ -129,14 +135,14 @@ func TestAWriteAndAnApplyOfTheSameFileSerialise(t *testing.T) { // ADR 0570 clause 6: the client leaves a provision's `${secret:}` to the node-engine, which fills it at the write // from the vault's value of a provided provision, and writes the file 0600; a provision not provided refuses it. - keyed := func(state string, ref bool) string { - p := provisions.Provision{Provision: "db", Consumer: provisions.End{Module: "m", Node: "n1"}, + keyed := func(state string, ref bool, id, local string) string { + p := provisions.Provision{Provision: "db", Local: local, Consumer: provisions.End{Module: "m", Node: "n1"}, Provider: provisions.End{Module: "postgres", Node: "n2"}, State: state, At: time.Now()} if state != provisions.Provided { p.Why = "the provider has not answered" } if ref { - p.Reference = &provisions.Reference{ID: "provision/postgres/db", Generation: 2} + p.Reference = &provisions.Reference{ID: id, Generation: 2} } raw, err := provisions.Encode(p) if err != nil { @@ -151,20 +157,36 @@ func TestAWriteAndAnApplyOfTheSameFileSerialise(t *testing.T) { want string // the file after; "" keeps what was there mode os.FileMode vault string // the vault's credential name, when not the one the provision names + refID string // the reference's id, when not the provision's shared value + local string // the provision's local name (ADR 0094): the placeholder names it }{ {name: "a provision not provided is refused", state: provisions.Asked, content: "password=${secret:db}\n"}, {name: "provided with no reference is refused", state: provisions.Provided, content: "password=${secret:db}\n"}, + {name: "a reference to no credential of the provision is refused", state: provisions.Provided, ref: true, + refID: "provision/n9/db", content: "password=${secret:db}\n"}, + {name: "a name no provision goes by is refused", state: provisions.Provided, ref: true, content: "password=${secret:other}\n"}, {name: "the vault's value is another than the provision names", state: provisions.Provided, ref: true, vault: "pair/n2/db/n1/m", content: "password=${secret:db}\n"}, {name: "provided: filled at the write, 0600", state: provisions.Provided, ref: true, content: "password=${secret:db}\n", want: "password=s3cret\n", mode: 0o600}, + {name: "a local name is the provision's by its key", state: provisions.Provided, ref: true, local: "admin", + content: "admin=${secret:admin}\n", want: "admin=s3cret\n", mode: 0o600}, {name: "no placeholder is written as it came, as the declaration says", content: "plain\n", want: "plain\n", mode: 0o644}, } { before, _ := os.ReadFile(path) if tc.state != "" { - provision["n1.m.db"] = keyed(tc.state, tc.ref) + id := "provision/n2/db" + if tc.refID != "" { + id = tc.refID + } + clear(provision) + key := "n1.m.db" + if tc.local != "" { + key += "." + tc.local + } + provision[key] = keyed(tc.state, tc.ref, id, tc.local) } - vaultName = "provision/postgres/db" + vaultName = "provision/n2/db" if tc.vault != "" { vaultName = tc.vault } diff --git a/cmd/mesh-host/provision_secrets.go b/cmd/mesh-host/provision_secrets.go index 887cc4c..a99fca0 100644 --- a/cmd/mesh-host/provision_secrets.go +++ b/cmd/mesh-host/provision_secrets.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "regexp" + "sort" "strings" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/provisions" ) @@ -15,25 +17,27 @@ import ( // fills those at the write, so the client never holds the value. A whole `secrets` file of a handed consumer is the // placeholder alone. // -// For each placeholder it reads the provision's key `..` of the seat mesh-provisioner's state -// `provisions` (clause 1), which must be `provided` and carry the vault's reference; asks the vault for that pair +// A placeholder names the credential as the module calls it, `${secret:}` (catalogue.SecretLocal, ADR 0094): the +// local name where the module keeps several values of one provision, else the provision. For each, it finds the one +// provision key `..[.]` of the seat mesh-provisioner's state `provisions` (clause 1) +// going by that name, which must be `provided` and carry the vault's reference; asks the vault for that pair // credential sealed to this machine's key (the verb `own`, ADR 0349 §11 with the node-engine standing for the client, // ADR 0550 §5); and opens it with this machine's sealing key. Anything short of that refuses the write, naming the // provision and why: a file written with a placeholder left in it, or with no value, is a module that starts and cannot // connect. type provisionSecrets struct { node string - // read is the provision's key from mesh-provisioner's `provisions`: its value and whether it is there. - read func(ctx context.Context, key string) ([]byte, bool, error) + // list is every key of mesh-provisioner's `provisions` under prefix (`..`), by key: what this + // engine may read, its own node's (`.>`). + list func(ctx context.Context, prefix string) (map[string][]byte, error) // own asks the vault's verb `own` in this engine's name. own func(ctx context.Context, args map[string]any) (json.RawMessage, error) // open opens a value sealed to this machine. open func(sealed string) ([]byte, error) } -// secretPlaceholder is `${secret:}`, or `${secret:.}` where the consumer keeps several values -// of one provision (ADR 0094). -var secretPlaceholder = regexp.MustCompile(`\$\{secret:([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]+))?\}`) +// secretPlaceholder is `${secret:}`. +var secretPlaceholder = regexp.MustCompile(`\$\{secret:([A-Za-z0-9_-]+)\}`) // fill is content with each provision placeholder of module's filled, and whether any was. func (p provisionSecrets) fill(ctx context.Context, module string, content []byte) ([]byte, bool, error) { @@ -44,15 +48,27 @@ func (p provisionSecrets) fill(ctx context.Context, module string, content []byt if module == "" { return nil, false, fmt.Errorf("the file names %d provision secret(s) and belongs to no module, so whose they are is not known", len(found)) } + if p.list == nil || p.own == nil || p.open == nil { + return nil, false, fmt.Errorf("%s's provisions cannot be read on this machine: the node-engine is not linked to the mesh", module) + } + prefix := p.node + "." + module + "." + keys, err := p.list(ctx, prefix) + if err != nil { + return nil, false, fmt.Errorf("%s's provisions cannot be read: %w", module, err) + } out := string(content) done := map[string]bool{} for _, f := range found { - whole, provision, local := string(f[0]), string(f[1]), string(f[2]) + whole, name := string(f[0]), string(f[1]) if done[whole] { continue } done[whole] = true - value, err := p.value(ctx, module, provision, local) + pr, key, err := byName(keys, name) + if err != nil { + return nil, false, fmt.Errorf("%s's secret %q: %w", module, name, err) + } + value, err := p.value(ctx, key, pr) if err != nil { return nil, false, err } @@ -61,26 +77,31 @@ func (p provisionSecrets) fill(ctx context.Context, module string, content []byt return []byte(out), true, nil } -// value is one provision's value for module on this node, opened. -func (p provisionSecrets) value(ctx context.Context, module, provision, local string) ([]byte, error) { - key, err := provisions.Key(p.node, module, provision, local) - if err != nil { - return nil, err +// byName is the one provision of keys the module calls name (catalogue.SecretLocal), and its key. +func byName(keys map[string][]byte, name string) (provisions.Provision, string, error) { + var hit []string + var got provisions.Provision + for key, raw := range keys { + pr, err := provisions.Decode(raw) + if err != nil { + return provisions.Provision{}, "", fmt.Errorf("the provision %s: %w", key, err) + } + if catalogue.SecretLocal(pr.Provision, pr.Local) == name { + hit, got = append(hit, key), pr + } } - if p.read == nil || p.own == nil || p.open == nil { - return nil, fmt.Errorf("the provision %s cannot be read on this machine: the node-engine is not linked to the mesh", key) - } - raw, ok, err := p.read(ctx, key) - switch { - case err != nil: - return nil, fmt.Errorf("the provision %s cannot be read: %w", key, err) - case !ok: - return nil, fmt.Errorf("the provision %s is not in %s: nothing asks for it", key, provisions.ProvisionsRead) - } - pr, err := provisions.Decode(raw) - if err != nil { - return nil, fmt.Errorf("the provision %s: %w", key, err) + switch len(hit) { + case 0: + return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", provisions.ProvisionsRead) + case 1: + return got, hit[0], nil } + sort.Strings(hit) + return provisions.Provision{}, "", fmt.Errorf("the provisions %s all go by that name", strings.Join(hit, ", ")) +} + +// value is one provision's value on this node, opened. +func (p provisionSecrets) value(ctx context.Context, key string, pr provisions.Provision) ([]byte, error) { if pr.State != provisions.Provided || pr.Reference == nil { why := pr.State if pr.Why != "" { @@ -91,6 +112,15 @@ func (p provisionSecrets) value(ctx context.Context, module, provision, local st } return nil, fmt.Errorf("the provision %s is %s, so its value is not written yet", key, why) } + // The reference names a credential of this provision, as the vault names it (provisions.VaultName). + ours := false + for _, form := range []string{provisions.FormPair, provisions.FormShared, provisions.FormIssued} { + ours = ours || provisions.VaultName(form, pr) == pr.Reference.ID + } + if !ours { + return nil, fmt.Errorf("the provision %s names the vault's %s, which is no credential of it", key, pr.Reference.ID) + } + module, provision, local := pr.Consumer.Module, pr.Provision, pr.Local type asked struct { Role string `json:"role"` Provision string `json:"provision"` diff --git a/go.mod b/go.mod index 675bca2..6a00b6c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029213215-4a967933b368 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029213929-66ff33dbd35d github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 3c95de2..326dffd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029213215-4a967933b368 h1:LaBxsyU3HeTIPKYmvpJuw+Wm2plpf6O7qHO91O+mAi0= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029213215-4a967933b368/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029213929-66ff33dbd35d h1:J1QAvzlaIvCCyZGBQX0XGJjYWdNPjLXBF4zaAeIkUOg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029213929-66ff33dbd35d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=