V-2: the vault keeps the licence bindings from binding.changed and a first view (issue 1409)
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}]
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user