From 527840af9bc985056db69981a964c7f218ee2038 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 08:25:46 +0000 Subject: [PATCH] Give go/facts each machine's grants and bindings, from facts format 2 (issue 1380) Machine gains Grants (provision, consumer node, consumer module, local name; no sealed copy) and Bindings (consumer module, provision, provider node, provider module), both omitted when empty. Format moves 1 to 2, because a reader of 1 must refuse a snapshot that says what it cannot read (GrantsFrom names the number); MediaType stays, a reader of 2 reads a snapshot of 1 as having none. Scrubber.Grant and Scrubber.Binding put the machine names under pseudonyms as Pins' are. go/compose's Input.Issued and Node.Bound docs name them as where an assigner fills those from; no logic changes there. Tests: all new ones failed first (the types did not exist); the copied ones are exempt. Unit level 1.19 s (bound 2 s). --- go/compose/input.go | 5 +- go/facts/facts.go | 48 ++++++++++++++- go/facts/grants_test.go | 130 ++++++++++++++++++++++++++++++++++++++++ go/facts/scrub.go | 11 ++++ 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 go/facts/grants_test.go diff --git a/go/compose/input.go b/go/compose/input.go index e98a7fe..853eab7 100644 --- a/go/compose/input.go +++ b/go/compose/input.go @@ -70,7 +70,8 @@ type Input struct { // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not - // made yet has no grant until the send that makes it. + // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each + // machine (go/facts, from format 2), which carry who each credential is for and never the credential. Issued []Issued // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to. // Their sealed values are optional. @@ -147,7 +148,7 @@ type Node struct { // Pins is the provider each provision was pinned to for it (inv.PinsFor). Pins map[string]catalogue.Chosen // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer - // module and provision. + // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2). Bound map[string]map[string]catalogue.Chosen // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent. Settings map[string][]catalogue.Layer diff --git a/go/facts/facts.go b/go/facts/facts.go index 6526db6..270471d 100644 --- a/go/facts/facts.go +++ b/go/facts/facts.go @@ -29,7 +29,17 @@ import ( // Format is the snapshot's version. A reader refuses one newer than it knows: a field it cannot read // is a fact it would judge without. -const Format = 1 +// +// Format 2 is the first to carry a machine's Grants and Bindings (GrantsFrom): a reader of format 1 refuses a +// snapshot of 2 rather than judge a change to a credential or a binding without them, and a reader of 2 reads a +// snapshot of 1 as one whose machines have none. MediaType stays as it is: it names the kind of document, and the +// format inside it says which fields it carries. +const Format = 2 + +// GrantsFrom is the first format that carries each machine's Grants and Bindings (novox/hq ADR 0446 section 2): the +// credentials made for the consumers of what the machine provides, and where each of its consumers of a provision is +// bound. A snapshot of an earlier format says nothing of them, which is not the same as saying there are none. +const GrantsFrom = 2 // Repository and Tag are where the controller keeps the newest snapshot in the artifact store, and // MediaType what it is kept as. @@ -150,6 +160,14 @@ type Machine struct { // Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot // make them, so a check composing this machine gives each a stand-in instead of refusing it. Accepted []Accepted `json:"accepted,omitempty"` + // Grants are the pair credentials made for the consumers of what this machine provides (mesh-controller's + // inv.SecretsFrom for it): who each is for, never the credential, sealed or not. Written from format GrantsFrom; an + // assigner reads them as compose.Input.Issued. + Grants []Grant `json:"grants,omitempty"` + // Bindings are where each consumer on this machine was last bound to a provision that keeps data (mesh-controller's + // inv.BindingsFor for it), by consumer module and provision. Written from format GrantsFrom; an assigner reads them + // as compose.Node.Bound. + Bindings []Binding `json:"bindings,omitempty"` // Declaration is how its declaration composes today, as the controller that took this saw it. Declaration Declaration `json:"declaration"` } @@ -177,6 +195,25 @@ type Accepted struct { Local string `json:"local,omitempty"` } +// Grant is one pair credential made for a consumer of a provision this machine provides: the provision, the machine and +// module that consume it, and the local name the consumer takes it under. The machine is a pseudonym. Nothing of the +// credential is here, not even sealed (compose.Issued without its ForProvider). +type Grant struct { + Provision string `json:"provision"` + ConsumerNode string `json:"consumer-node"` + ConsumerModule string `json:"consumer-module"` + Local string `json:"local,omitempty"` +} + +// Binding is where one consumer module on this machine was last bound for a provision: the provider's machine (a +// pseudonym) and module (compose.Node.Bound). +type Binding struct { + ConsumerModule string `json:"consumer-module"` + Provision string `json:"provision"` + ProviderNode string `json:"provider-node"` + ProviderModule string `json:"provider-module"` +} + // Settings is one layer of one module's settings. type Settings struct { Module string `json:"module"` @@ -263,6 +300,15 @@ func (f *Facts) Sorted() { sort.Slice(m.Capabilities, func(a, b int) bool { return m.Capabilities[a].Name < m.Capabilities[b].Name }) sort.Slice(m.Pins, func(a, b int) bool { return m.Pins[a].Provision < m.Pins[b].Provision }) sort.Slice(m.Settings, func(a, b int) bool { return m.Settings[a].Module < m.Settings[b].Module }) + sort.Slice(m.Grants, func(a, b int) bool { + x, y := m.Grants[a], m.Grants[b] + return x.Provision+"\x00"+x.ConsumerNode+"\x00"+x.ConsumerModule+"\x00"+x.Local < + y.Provision+"\x00"+y.ConsumerNode+"\x00"+y.ConsumerModule+"\x00"+y.Local + }) + sort.Slice(m.Bindings, func(a, b int) bool { + x, y := m.Bindings[a], m.Bindings[b] + return x.ConsumerModule+"\x00"+x.Provision < y.ConsumerModule+"\x00"+y.Provision + }) sort.Slice(m.Accepted, func(a, b int) bool { x, y := m.Accepted[a], m.Accepted[b] return x.Module+"\x00"+x.Name+"\x00"+x.Provider+"\x00"+x.Local < y.Module+"\x00"+y.Name+"\x00"+y.Provider+"\x00"+y.Local diff --git a/go/facts/grants_test.go b/go/facts/grants_test.go new file mode 100644 index 0000000..f9df78a --- /dev/null +++ b/go/facts/grants_test.go @@ -0,0 +1,130 @@ +package facts + +import ( + "reflect" + "strings" + "testing" + "time" +) + +// A snapshot the controller wrote before grants and bindings existed is read as one that has none. The body +// is the format 1 document facts_test.go's TestANewerSnapshotIsRefusedNotHalfRead reads (mesh-controller +// 6c2a8d62, internal/facts/facts_test.go), with the fields of a machine that format 1 wrote. +func TestASnapshotWithoutGrantsOrBindingsReadsAsHavingNone(t *testing.T) { + f, err := Decode([]byte(`{"facts": 1, "machines": [{"name": "abc", "length": 3}, {"name": "defgh", "length": 5, + "pins": [{"provision": "store", "machine": "xyz", "module": "postgres"}]}]}`)) + if err != nil { + t.Fatal(err) + } + for _, m := range f.Machines { + if len(m.Grants) != 0 || len(m.Bindings) != 0 { + t.Errorf("%s reads grants %v and bindings %v from a snapshot that carries none", m.Name, m.Grants, m.Bindings) + } + } + body, err := f.Encode() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "grants") || strings.Contains(string(body), "bindings") { + t.Errorf("a machine with none wrote them anyway:\n%s", body) + } +} + +// A snapshot that carries them reads back what it carried, and writes the same bytes again. +func TestGrantsAndBindingsRoundTripByteForByte(t *testing.T) { + f := Facts{Format: Format, Taken: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), Machines: []Machine{{ + Name: "abcdef", Length: 6, + Grants: []Grant{ + {Provision: "store", ConsumerNode: "ghijkl", ConsumerModule: "mesh-issues", Local: "database"}, + {Provision: "store", ConsumerNode: "abcdef", ConsumerModule: "records"}, + }, + Bindings: []Binding{ + {ConsumerModule: "records", Provision: "store", ProviderNode: "abcdef", ProviderModule: "postgres"}, + }, + }}} + first, err := f.Encode() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"grants"`, `"bindings"`, `"consumer-node": "ghijkl"`, `"consumer-module": "mesh-issues"`, + `"provider-node": "abcdef"`, `"provider-module": "postgres"`, `"local": "database"`} { + if !strings.Contains(string(first), want) { + t.Errorf("the snapshot does not say %s:\n%s", want, first) + } + } + back, err := Decode(first) + if err != nil { + t.Fatal(err) + } + second, err := back.Encode() + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Errorf("the snapshot did not round-trip:\nfirst %s\nsecond %s", first, second) + } + m, _ := back.Machine("abcdef") + // Sorted, so two snapshots of one mesh are the same bytes: the grant for the lower node name comes first. + if len(m.Grants) != 2 || m.Grants[0].ConsumerNode != "abcdef" || len(m.Bindings) != 1 { + t.Errorf("read back grants %+v and bindings %+v", m.Grants, m.Bindings) + } +} + +// A machine name in a grant or a binding is scrubbed as a pin's is: to the machine's pseudonym, never kept. +func TestTheScrubberScrubsTheMachineNamesOfGrantsAndBindings(t *testing.T) { + s := NewScrubber() + pin := Pin{Provision: "store", Machine: s.Machine("homeserver"), Module: "postgres"} + grant := s.Grant("store", "laptop2", "mesh-issues", "database") + binding := s.Binding("records", "store", "homeserver", "postgres") + if grant.ConsumerNode != s.Machine("laptop2") || grant.ConsumerNode == "laptop2" { + t.Errorf("the grant's consumer node is %q", grant.ConsumerNode) + } + if binding.ProviderNode != pin.Machine || binding.ProviderNode == "homeserver" { + t.Errorf("the binding's provider node is %q, not the pin's pseudonym %q", binding.ProviderNode, pin.Machine) + } + if grant.Provision != "store" || grant.ConsumerModule != "mesh-issues" || grant.Local != "database" || + binding.ConsumerModule != "records" || binding.Provision != "store" || binding.ProviderModule != "postgres" { + t.Errorf("the scrubber changed what is not a machine's name: %+v %+v", grant, binding) + } + if len(grant.ConsumerNode) != len("laptop2") { + t.Errorf("the pseudonym %q does not keep the length of the name", grant.ConsumerNode) + } +} + +// A grant says that a credential was made and for whom, never the credential: no field of either type is +// a sealed value, a secret or a hash. +func TestNoFieldOfAGrantOrABindingHoldsASecret(t *testing.T) { + for _, typ := range []reflect.Type{reflect.TypeOf(Grant{}), reflect.TypeOf(Binding{})} { + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + for _, banned := range []string{"Sealed", "Secret", "Hash", "Password"} { + if strings.Contains(strings.ToLower(name), strings.ToLower(banned)) { + t.Errorf("%s.%s may hold a secret", typ.Name(), name) + } + } + if typ.Field(i).Type.Kind() != reflect.String { + t.Errorf("%s.%s is a %s, not a name", typ.Name(), name, typ.Field(i).Type) + } + } + } +} + +// The format number says from which number a snapshot carries them: a reader of 1 refuses 2 rather than +// judge without them, and a reader of 2 reads 1. +func TestTheFormatSaysFromWhichNumberGrantsAndBindingsAreWritten(t *testing.T) { + if GrantsFrom != 2 { + t.Errorf("grants and bindings are written from format %d, not 2", GrantsFrom) + } + if Format < GrantsFrom { + t.Errorf("the format is %d, below %d, where grants and bindings begin", Format, GrantsFrom) + } + if _, err := Decode([]byte(`{"facts": 2, "machines": []}`)); err != nil { + t.Errorf("format 2 is not read: %v", err) + } + if _, err := Decode([]byte(`{"facts": 1, "machines": []}`)); err != nil { + t.Errorf("format 1 is not read: %v", err) + } + if _, err := Decode([]byte(`{"facts": 3, "machines": []}`)); err == nil { + t.Error("format 3 is read by a reader of format 2") + } +} diff --git a/go/facts/scrub.go b/go/facts/scrub.go index 4615bb3..febc1f5 100644 --- a/go/facts/scrub.go +++ b/go/facts/scrub.go @@ -54,6 +54,17 @@ func NewScrubber() *Scrubber { return &Scrubber{seen: map[string]bool{}} } // Machine registers a machine's name and answers its pseudonym. func (s *Scrubber) Machine(name string) string { return s.word("machine", name) } +// Grant is a grant with the consumer's machine under its pseudonym, as a pin's machine is: the one place a grant is +// built from a real name. +func (s *Scrubber) Grant(provision, consumerNode, consumerModule, local string) Grant { + return Grant{Provision: provision, ConsumerNode: s.Machine(consumerNode), ConsumerModule: consumerModule, Local: local} +} + +// Binding is a binding with the provider's machine under its pseudonym. +func (s *Scrubber) Binding(consumerModule, provision, providerNode, providerModule string) Binding { + return Binding{ConsumerModule: consumerModule, Provision: provision, ProviderNode: s.Machine(providerNode), ProviderModule: providerModule} +} + // Site registers a site's name and answers its pseudonym. func (s *Scrubber) Site(name string) string { return s.word("site", name) }