From 256a4125901dbcce51b46b12af6f5140c1147723 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:39:15 +0100 Subject: [PATCH] V-2: the vault keeps the licence bindings from binding.changed and a first view (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault consumes the licence manager's binding.changed and, at its start, asks the seat's bindings until it answers; it keeps each consumer's licence, the licence's key and the generation in its own state licence-bindings, never an older generation, deleting a released consumer's key. The state is in the vault's old state section, and holds no secret. Builds: design 39 bus-and-switch §17, B2, B9, B10, B22, B25 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/mesh-vault/licencebindings.go | 231 ++++++++++++++++++ .../cmd/mesh-vault/licencebindings_test.go | 115 +++++++++ modules/mesh-vault/cmd/mesh-vault/main.go | 5 + .../mesh-vault/testdata/binding-changed.json | 1 + .../mesh-vault/testdata/bindings-answer.json | 1 + .../mesh-vault/cmd/mesh-vault/vault_test.go | 11 +- modules/mesh-vault/module.json | 9 +- 7 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-vault/cmd/mesh-vault/licencebindings.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json create mode 100644 modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go new file mode 100644 index 000000000..d1d09422a --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go @@ -0,0 +1,231 @@ +package main + +// The licence bindings the vault keeps (design 39 bus-and-switch §17, B2, B10, B25): which licence each consumer is +// bound to, so the vault hands a licence's access token only to a machine a binding names. Learned from the licence +// manager's `binding.changed`, which the vault consumes, and at its start from the seat's verb `bindings`, its first +// view, asked until it answers; kept in the vault's own state `licence-bindings`, one key per consumer with the +// licence, its key and the generation, deleted on a release. The vault never reads the manager's state (ADR 0596 §2). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + stdio "git.novox.be/novox/mesh-sdk/go" + + "git.novox.be/novox/mesh-sdk/go/views" +) + +const ( + // LicenceBindingsState is the vault's own state of the licence bindings (§17; B2: in the old section `state`). + LicenceBindingsState = "licence-bindings" + // BindingChangedEvent is the licence manager's event of a binding written or deleted (§21), which the vault consumes. + BindingChangedEvent = "claude-licence-manager.binding.changed" + // LicenceBindingsVerb is the seat's verb the vault asks for its first view (B10), declared in its invokes (B9). + LicenceBindingsVerb = "seat:anthropic-licence-manager.bindings" +) + +// LicenceBinding is a consumer's key of `licence-bindings` (B25): the licence, its key and the binding's generation. +type LicenceBinding struct { + Licence string `json:"licence"` + Key string `json:"key"` + Generation int64 `json:"generation"` +} + +// LicenceBindings is the state `licence-bindings`. +type LicenceBindings struct { + Get func(consumer string) (*LicenceBinding, error) + Put func(consumer string, b LicenceBinding) error + Delete func(consumer string) error + Keys func() ([]string, error) + Log func(string, ...any) +} + +// busLicenceBindings is the state on the bus. +func busLicenceBindings() LicenceBindings { + st := stdio.State(LicenceBindingsState) + return LicenceBindings{ + Get: func(consumer string) (*LicenceBinding, error) { + e, err := st.Get(consumer) + if err != nil || e == nil || len(e.Value) == 0 { + return nil, err + } + var b LicenceBinding + return &b, json.Unmarshal(e.Value, &b) + }, + Put: func(consumer string, b LicenceBinding) error { _, err := st.Put(consumer, b); return err }, + Delete: func(consumer string) error { return st.Delete(consumer) }, + Keys: st.Keys, + Log: say, + } +} + +// bindingChanged is the body of `binding.changed` as the manager says it (§21, B7; B22: decoded from a copy of the +// writer's form, testdata/binding-changed.json). +type bindingChanged struct { + Consumer string `json:"consumer"` + Licence string `json:"licence"` + Key string `json:"key"` + Generation int64 `json:"generation"` +} + +// keep writes a consumer's binding unless the state holds a newer generation; an empty licence is a release. +func (lb LicenceBindings) keep(consumer string, b LicenceBinding) error { + if consumer == "" { + return errors.New("a binding names no consumer") + } + held, err := lb.Get(consumer) + if err != nil { + return err + } + if held != nil && held.Generation > b.Generation { + return nil // the state holds a newer move already + } + if b.Licence == "" { + if held == nil { + return nil + } + return lb.Delete(consumer) + } + if held != nil && *held == b { + return nil + } + return lb.Put(consumer, b) +} + +// Heard takes one `binding.changed`. +func (lb LicenceBindings) Heard(body []byte) error { + var e bindingChanged + if err := json.Unmarshal(body, &e); err != nil { + return fmt.Errorf("%s is not a binding's change: %w", BindingChangedEvent, err) + } + if e.Licence != "" && e.Key == "" { + return fmt.Errorf("%s for %s names no licence key", BindingChangedEvent, e.Consumer) + } + return lb.keep(e.Consumer, LicenceBinding{Licence: e.Licence, Key: e.Key, Generation: e.Generation}) +} + +// FirstView takes the manager's answer to `bindings`, by consumer: every binding it names is kept, and every key the +// state holds for a consumer it does not name is deleted, since that consumer was released. +func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error { + var errs []error + for consumer, raw := range values { + var b LicenceBinding + if err := json.Unmarshal(raw, &b); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", consumer, err)) + continue + } + errs = append(errs, lb.keep(consumer, b)) + } + held, err := lb.Keys() + if err != nil { + return errors.Join(append(errs, err)...) + } + for _, consumer := range held { + if _, named := values[consumer]; !named { + errs = append(errs, lb.Delete(consumer)) + } + } + return errors.Join(errs...) +} + +// licenceBindingsAnswer is the seat's answer to `bindings` (B10, B25) as a first view, by consumer. +func licenceBindingsAnswer(raw []byte) (views.Answer, error) { + var listed []struct { + Consumer string `json:"consumer"` + LicenceBinding + } + if err := json.Unmarshal(unwrapToolAnswer(raw), &listed); err != nil { + return views.Answer{}, fmt.Errorf("%s answered what is not a list of bindings: %w", LicenceBindingsVerb, err) + } + values := map[string]json.RawMessage{} + for _, b := range listed { + if b.Consumer == "" || b.Licence == "" || b.Key == "" { + return views.Answer{}, fmt.Errorf("%s answered a binding without its consumer, licence or key", LicenceBindingsVerb) + } + raw, _ := json.Marshal(b.LicenceBinding) + values[b.Consumer] = raw + } + return views.Answer{Owner: "claude-licence-manager", State: "bindings", Values: values, At: time.Now()}, nil +} + +// licenceBindingsView is the first view's follower; nil until main begins it. +var licenceBindingsView *views.Follower + +// newLicenceBindingsView makes the follower of the first view: asked until it answers, and each answer, at the start or +// asked again by hand, written into the state. +func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, error), lb LicenceBindings, + findings views.FindingWriter) *views.Follower { + var mu sync.Mutex + var taken time.Time + var f *views.Follower + f = &views.Follower{Owner: "claude-licence-manager", Tool: LicenceBindingsVerb, State: "bindings", Logf: say, + Ask: func(context.Context, string) (views.Answer, error) { + raw, err := ask(LicenceBindingsVerb, map[string]any{}) + if err != nil { + return views.Answer{}, err + } + return licenceBindingsAnswer(raw) + }, + Raise: func(s views.Status) { + fd := views.UnansweredFinding(Seat, s, time.Now()) + if err := findings.Put(fd); err != nil { + say("the finding %s is not written: %v", fd.ID, err) + } + }, + Clear: func(s views.Status) { _ = findings.Delete(views.UnansweredFinding(Seat, s, time.Now())) }, + // Each answer, once: its picture is the manager's bindings when it answered. + Said: func(s views.Status) { + mu.Lock() + fresh := !s.Answered.IsZero() && s.Answered.After(taken) + if fresh { + taken = s.Answered + } + mu.Unlock() + if !fresh { + return + } + values := f.Values() + if err := lb.FirstView(values); err != nil { + say("the licence bindings of the first view are not all kept: %v", err) + return + } + consumers := make([]string, 0, len(values)) + for c := range values { + consumers = append(consumers, c) + } + sort.Strings(consumers) + say("the licence bindings of %d consumer(s) are kept from the first view: %v", len(consumers), consumers) + }} + return f +} + +// watchLicenceBindings begins the first view and hears `binding.changed`. It answers whether it began; one that did not +// is begun again at the next minute pass. +func watchLicenceBindings(lb LicenceBindings) bool { + err := stdio.Subscribe(BindingChangedEvent, func(e stdio.Envelope) error { + if err := lb.Heard(e.Body); err != nil { + say("%v", err) + } + return nil + }) + if err != nil { + say("does not hear %s yet (%v); asked again at the next pass", BindingChangedEvent, err) + return false + } + if licenceBindingsView == nil { + licenceBindingsView = newLicenceBindingsView(stdio.Ask, lb, + meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}) + go func() { + if err := licenceBindingsView.Start(context.Background()); err != nil { + say("the first view of the licence bindings ended unanswered: %v", err) + } + }() + } + return true +} diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go new file mode 100644 index 000000000..3f7d23a9f --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "slices" + "sort" + "testing" +) + +// memLicenceBindings is the state licence-bindings in memory. +func memLicenceBindings() (LicenceBindings, map[string]LicenceBinding) { + held := map[string]LicenceBinding{} + return LicenceBindings{ + Get: func(c string) (*LicenceBinding, error) { + if b, ok := held[c]; ok { + return &b, nil + } + return nil, nil + }, + Put: func(c string, b LicenceBinding) error { held[c] = b; return nil }, + Delete: func(c string) error { delete(held, c); return nil }, + Keys: func() ([]string, error) { + var out []string + for c := range held { + out = append(out, c) + } + sort.Strings(out) + return out, nil + }, + Log: func(string, ...any) {}, + }, held +} + +// design 39 bus-and-switch §17, B22, B25: the vault keeps a consumer's binding from binding.changed, decoded from a copy +// of the manager's form, with the licence's key; an older generation is passed over and a release deletes the key. +func TestALicenceBindingIsKeptFromBindingChanged(t *testing.T) { + lb, held := memLicenceBindings() + said, err := os.ReadFile("testdata/binding-changed.json") + if err != nil { + t.Fatal(err) + } + if err := lb.Heard(said); err != nil { + t.Fatal(err) + } + if b := held["laptop"]; b != (LicenceBinding{Licence: "api", Key: "api", Generation: 3}) { + t.Fatalf("laptop is kept as %+v", b) + } + for _, c := range []struct { + body string + want *LicenceBinding + }{ + {`{"consumer":"laptop","licence":"other","key":"other","generation":2}`, &LicenceBinding{Licence: "api", Key: "api", Generation: 3}}, + {`{"consumer":"laptop","licence":"other","key":"other","generation":4}`, &LicenceBinding{Licence: "other", Key: "other", Generation: 4}}, + {`{"consumer":"laptop","licence":"","key":"","generation":4,"from":"other"}`, nil}, + } { + if err := lb.Heard([]byte(c.body)); err != nil { + t.Fatal(err) + } + got, ok := held["laptop"] + if (c.want == nil) != !ok || (c.want != nil && got != *c.want) { + t.Fatalf("after %s laptop is kept as %+v (held %v), want %+v", c.body, got, ok, c.want) + } + } + if err := lb.Heard([]byte(`{"consumer":"server","licence":"api","generation":5}`)); err == nil { + t.Fatal("a binding without its licence's key was kept") + } +} + +// design 39 bus-and-switch §17, B10, B25: the first view, the seat's answer to bindings, keeps every consumer's binding +// with its key and deletes a consumer it does not name; the vault invokes the verb and consumes the event its manifest +// names. +func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { + lb, held := memLicenceBindings() + held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9} + raw, err := os.ReadFile("testdata/bindings-answer.json") + if err != nil { + t.Fatal(err) + } + a, err := licenceBindingsAnswer(raw) + if err != nil { + t.Fatal(err) + } + if err := lb.FirstView(a.Values); err != nil { + t.Fatal(err) + } + want := map[string]LicenceBinding{"laptop": {Licence: "acct-1@example.org", Key: "acct-1_at_example.org", Generation: 1013}, + "server": {Licence: "api", Key: "api", Generation: 1016}} + if len(held) != 2 || held["laptop"] != want["laptop"] || held["server"] != want["server"] { + t.Fatalf("the first view kept %v", held) + } + if _, err := licenceBindingsAnswer([]byte(`[{"consumer":"laptop","licence":"api","generation":1}]`)); err == nil { + t.Fatal("an answer without a licence's key was taken") + } + lb.Put = func(string, LicenceBinding) error { return errors.New("the bucket is full") } + held["laptop"] = LicenceBinding{Licence: "api", Key: "api", Generation: 1} + if err := lb.FirstView(a.Values); err == nil { + t.Fatal("a first view not kept was not said") + } + + manifest, _ := os.ReadFile("../../module.json") + var m struct { + Invokes, Consumes []string + State []json.RawMessage + } + _ = json.Unmarshal(manifest, &m) + kept := false + for _, st := range m.State { + kept = kept || string(st) == `"`+LicenceBindingsState+`"` + } + if !slices.Contains(m.Invokes, LicenceBindingsVerb) || !slices.Contains(m.Consumes, BindingChangedEvent) || !kept { + t.Fatalf("the manifest invokes %v, consumes %v, keeps %s", m.Invokes, m.Consumes, m.State) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index c6dc697f9..2d2a1126b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -323,6 +323,8 @@ func main() { watched, watching := watchHandings(env), watchAssignments() wants := watchWants(env) leavers := watchLeavers(env) + bindings := busLicenceBindings() + licences := watchLicenceBindings(bindings) kick := make(chan struct{}, 1) go func() { for range kick { @@ -337,6 +339,9 @@ func main() { if !leavers { leavers = watchLeavers(env) } + if !licences { + licences = watchLicenceBindings(bindings) + } if !watched { watched = watchHandings(env) } diff --git a/modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json b/modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json new file mode 100644 index 000000000..47485b980 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/testdata/binding-changed.json @@ -0,0 +1 @@ +{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"} diff --git a/modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json b/modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json new file mode 100644 index 000000000..8e3466591 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/testdata/bindings-answer.json @@ -0,0 +1 @@ +[{"consumer":"laptop","licence":"acct-1@example.org","key":"acct-1_at_example.org","kind":"subscription","generation":1013,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"},{"consumer":"server","licence":"api","key":"api","kind":"api-key","generation":1016,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"}] diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 68def3bd5..3252e1b99 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -148,10 +148,11 @@ func TestNoBucketHoldsASecret(t *testing.T) { if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings"]` { + if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings","licence-bindings"]` { t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+ "buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+ - "credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6) and each node's login in use (ADR 0583)", s) + "credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6), each node's login in use (ADR 0583) "+ + "and the licence bindings, a licence's name, key and generation per consumer (design 39 bus-and-switch §17)", s) } if r, _ := json.Marshal(manifest["reads"]); string(r) != `["node-engine.have","mesh-grants.accounts","mesh-provisioner.plans","mesh-provisioner.provisions"]` { t.Errorf("the manifest reads %s: the vault reads the seat mesh-provisioner's plans and provisions, the haves, the "+ @@ -182,6 +183,12 @@ func TestNoBucketHoldsASecret(t *testing.T) { code = []byte(strings.Replace(string(code), "stdio.State(NodeLoginsState)."+call, "", -1)) } } + // The licence bindings, in licencebindings.go alone: the state opened once, a licence's name, key and generation per + // consumer, never a secret (design 39 bus-and-switch §17, B25). + if f == "licencebindings.go" { + code = []byte(strings.Replace(string(code), "st := stdio.State(LicenceBindingsState)", "", 1)) + code = []byte(strings.Replace(string(code), "meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}", "", 1)) + } // The signing keys' public halves, in signing.go alone: one read and one write of its own bucket (ADR 0550 §2). if f == "signing.go" { code = []byte(strings.Replace(string(code), "stdio.State(meshvault.StateSigners).Get(", "", 1)) diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 385095d3c..696131aab 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -32,7 +32,8 @@ ], "invokes": [ "seat:mesh-hand-acts.hand-acts", - "mesh-delivery-assigner.assigner_read_assignments" + "mesh-delivery-assigner.assigner_read_assignments", + "seat:anthropic-licence-manager.bindings" ], "reads": [ "node-engine.have", @@ -48,7 +49,8 @@ "signers", "bound", "node-logins", - "findings" + "findings", + "licence-bindings" ], "receives": { "secret": "${dir:grants}/mesh.json" @@ -254,6 +256,7 @@ } }, "consumes": [ - "mesh-delivery-assigner.views.assignments" + "mesh-delivery-assigner.views.assignments", + "claude-licence-manager.binding.changed" ] }