Review of L2: the vault's own fails closed: a pair credential's consumer is read standing, the shared value goes only to a holder its record names, and unread assignments refuse, never hand (C1, C2); the placement check pins mesh-sdk 3968849 (issue 1126, ADR 0550 §5)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 1faf31706919 on 5b75233406fe | novox/mesh-catalog#505 in group feat/1126-nodes-compose

This commit is contained in:
2026-10-26 20:12:21 +01:00
parent 851e8782d5
commit 1faf317069
5 changed files with 121 additions and 16 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ module checks/placement
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36
+2
View File
@@ -14,6 +14,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520 h1:twiziR88
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18 h1:HWNPfnT9YT9O6xHPLy2y6Q+LYT8XRjvLnZw9O61NETk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36 h1:aBkmFUq/zAVdR3PzfgjjC9AxSymNl3eVC7UEZv9tZ0Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
@@ -140,6 +140,7 @@ func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) {
t.Cleanup(s.Shutdown)
v := newVault(t, nil)
v.Askers = Declared{Read: assignments{"ace.web": `{"node":"ace","module":"web"}`}.read, Store: v.Store}
tools := map[string]toolDef{"node-key": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key"),
"own": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".own")}
vaultConn := connectAs(t, s.ClientURL(), "novox.mesh-vault")
+88 -15
View File
@@ -9,6 +9,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
@@ -83,10 +84,17 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
if c.Consumer != node {
return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer)
}
if d, ok := v.Askers.(Declared); ok && d.Read != nil {
if raw, err := standing(d.Read)(node + "." + c.ConsumerModule); err == nil && raw == nil {
return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node)
}
// The consuming module stands on the machine: read, never assumed (an assignment that cannot be read refuses).
read, why := v.assignmentReader(node)
if why != "" {
return why
}
raw, err := read(node + "." + c.ConsumerModule)
if err != nil {
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
}
if raw == nil {
return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node)
}
return ""
case "provider":
@@ -98,6 +106,47 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
return fmt.Sprintf("the role %q is not consumer or provider", c.Role)
}
// assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them,
// and without them it refuses rather than hands (a rule that cannot be read is no rule).
func (v *Vault) assignmentReader(node string) (AssignmentReader, string) {
d, ok := v.Askers.(Declared)
if !ok || d.Read == nil {
return nil, "the vault cannot read the assignments, so it cannot tell what " + node + "'s modules are; nothing is handed"
}
return standing(d.Read), ""
}
// sharedRefusal says why node may not have the provision's one shared value `provision/<provider>/<provision>` for c,
// or "": the vault's record of who holds it names the consumer's module among its consumers, or the machine as its
// provider. Without that record nothing is handed: the shared value is not one any machine may have by naming a
// provider (ADR 0550 §5).
func (v *Vault) sharedRefusal(node string, c CredentialAsked) (string, error) {
name := "provision/" + c.Provider + "/" + c.Provision
sh, ok, err := v.Store.ReadShared(name)
if err != nil {
return "", err
}
if !ok {
return fmt.Sprintf("the vault keeps no record of who holds %s", name), nil
}
switch c.Role {
case "consumer":
want := consumerOf(c.Consumer, c.ConsumerModule, c.Local)
for _, held := range sh.Consumers {
if held == want {
return "", nil
}
}
return fmt.Sprintf("%s is held by %s, not by %s", name, strings.Join(sh.Consumers, ", "), want), nil
case "provider":
if sh.Provider == node {
return "", nil
}
return fmt.Sprintf("%s is provided from %s, not %s", name, sh.Provider, node), nil
}
return fmt.Sprintf("the role %q is not consumer or provider", c.Role), nil
}
// composeOrigin is the vault's origin of a value as the composition reads it: made by the mesh, or given (accepted at a
// terminal, typed at a desk, issued by a provider's software).
func composeOrigin(origin string) string {
@@ -138,7 +187,11 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if len(parts) != 4 || parts[0] != "own" || parts[1] != node {
continue
}
if v.handed(node, parts[2]) {
handed, err := v.handed(node, parts[2])
if err != nil {
return OwnAnswer{}, err
}
if handed {
continue
}
value, err := seal.Open(r.AtRest, v.Own.PrivateKey)
@@ -167,7 +220,8 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why)
}
found := false
for _, name := range credentialNames(c) {
names := credentialNames(c)
for i, name := range names {
r, held, err := v.Store.Read(name)
if err != nil {
return OwnAnswer{}, err
@@ -175,6 +229,17 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if !held {
continue
}
if i == len(names)-1 {
// The provision's one shared value: handed only to a machine the record of its holders names.
why, err := v.sharedRefusal(node, c)
if err != nil {
return OwnAnswer{}, err
}
if why != "" {
v.log("%s is not handed %s: %s", node, name, why)
continue
}
}
value, err := seal.Open(r.AtRest, v.Own.PrivateKey)
if err != nil {
return OwnAnswer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
@@ -200,16 +265,24 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
}
// handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed.
// Where the plans cannot be read, nothing counts as handed: the composer leaves out a handed module's secrets itself.
func (v *Vault) handed(node, module string) bool {
d, ok := v.Askers.(Declared)
if !ok || d.Read == nil {
return false
// A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner
// holds.
func (v *Vault) handed(node, module string) (bool, error) {
read, why := v.assignmentReader(node)
if why != "" {
return false, errors.New(why)
}
raw, err := standing(d.Read)(node + "." + module)
if err != nil || raw == nil {
return false
raw, err := read(node + "." + module)
if err != nil {
return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+
"provisioner is not known: %w", node, module, err)
}
if raw == nil {
return false, nil
}
var a plannedSecrets
return json.Unmarshal(raw, &a) == nil && a.Provisioning != nil
if err := json.Unmarshal(raw, &a); err != nil {
return false, fmt.Errorf("the assignment %s.%s is not the controller's: %w", node, module, err)
}
return a.Provisioning != nil, nil
}
@@ -1,6 +1,7 @@
package main
import (
"errors"
"strings"
"testing"
@@ -11,7 +12,9 @@ import (
// the key that machine's engine stated, about that machine alone; another caller, another machine's secrets and a
// machine with no stated key get nothing.
func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) {
plans := assignments{"anchor.web": `{"node":"anchor","module":"web"}`, "laptop.web": `{"node":"laptop","module":"web"}`}
v := newVault(t, nil)
v.Askers = Declared{Read: plans.read, Store: v.Store}
anchorPub, anchorPriv := aSealingKey(t)
laptopPub, _ := aSealingKey(t)
for _, m := range []struct {
@@ -89,4 +92,30 @@ func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) {
t.Errorf("%s asking %+v: %v", who, c, err)
}
}
// Review of L2 (C1, C2): `own` fails closed. A provision's one shared value is handed only to a consumer the record
// of its holders names, never to a machine that names its provider; assignments that cannot be read, or a vault
// that reads none, refuse rather than hand.
if _, err := v.keep("provision/anchor/redis", "shared-redis", "test", "", OriginMade); err != nil {
t.Fatal(err)
}
if err := v.Store.WriteShared(Shared{Name: "provision/anchor/redis", Provider: "anchor", Provision: "redis",
Consumers: []string{"anchor/web"}}); err != nil {
t.Fatal(err)
}
if got, err := v.OwnOfNode("node.laptop", none); err != nil || len(got.Credentials) != 0 || len(got.Missing) != 1 {
t.Errorf("laptop, whose web the shared value's record does not name, got %+v, %v", got, err)
}
anchorRedis := CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"}
if got, err := v.OwnOfNode("node.anchor", anchorRedis); err != nil || len(got.Credentials) != 1 {
t.Errorf("anchor's web, a holder the record names, got %+v, %v", got, err)
}
v.Askers = Declared{Read: func(string) ([]byte, error) { return nil, errors.New("the bucket did not answer") }, Store: v.Store}
if _, err := v.OwnOfNode("node.anchor"); err == nil || !strings.Contains(err.Error(), "could not be read") {
t.Errorf("own with the assignments unread: %v", err)
}
v.Askers = nil
if _, err := v.OwnOfNode("node.anchor", anchorRedis); err == nil || !strings.Contains(err.Error(), "cannot read the assignments") {
t.Errorf("own with no reader of the assignments: %v", err)
}
}