Resolve ${secret:<name>} through this node's provision keys, and check the reference by the vault's own name (issue 1954 L6-E1, E2; ADR 0570 clauses 1, 6; ADR 0094)
- A placeholder is `${secret:<name>}`, the name the module calls the credential (catalogue.SecretLocal): the local
name where it keeps several values of one provision, else the provision. The `<provision>.<local>` form goes.
- The engine finds the one key under `<node>.<module>.` of mesh-provisioner's `provisions` going by that name. That is
what node.<node> may read, by a watch to its caught-up mark; plans are not read.
- The reference's ID must be a credential name of that provision (provisions.VaultName). The vault's answer must carry
exactly that name and generation. Anything else refuses the write, naming both.
Pins mesh-sdk 66ff33d. Tests: rows in TestAWriteAndAnApplyOfTheSameFileSerialise (a local name, a name no provision
goes by, a reference to no credential of the provision, a mismatched answer).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 `<node>.<consumer>.<provision>` 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:<name>}` (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 `<node>.<consumer>.<provision>[.<local>]` 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 (`<node>.<consumer>.`), by key: what this
|
||||
// engine may read, its own node's (`<node>.>`).
|
||||
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:<provision>}`, or `${secret:<provision>.<local>}` 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:<name>}`.
|
||||
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"`
|
||||
|
||||
Reference in New Issue
Block a user