diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go index bb85f20a5..f852d9e63 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go @@ -45,6 +45,12 @@ type LicenceBindings struct { Delete func(consumer string) error Keys func() ([]string, error) Log func(string, ...any) + // WrittenAt is when the bus wrote a consumer's key, so a first view keeps a key an event wrote after it was asked + // (B43); nil writes no time, and every key a first view does not name goes. + WrittenAt func(consumer string) (time.Time, error) + // Answered is whether the first view of the bindings has been answered since the vault started; until it has, an + // access token is refused, saying why (B31). Nil is a vault that keeps no first view (its tests). + Answered func() bool } // busLicenceBindings is the state on the bus. @@ -63,6 +69,14 @@ func busLicenceBindings() LicenceBindings { Delete: func(consumer string) error { return st.Delete(consumer) }, Keys: st.Keys, Log: say, + WrittenAt: func(consumer string) (time.Time, error) { + e, err := st.Get(consumer) + if err != nil || e == nil { + return time.Time{}, err + } + return e.At, nil + }, + Answered: func() bool { return licenceBindingsView != nil && !licenceBindingsView.Status().Answered.IsZero() }, } } @@ -111,9 +125,10 @@ func (lb LicenceBindings) Heard(body []byte) error { 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 { +// FirstView takes the manager's answer to `bindings`, asked at asked, 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, unless an +// event wrote that key after the view was asked (B43): the view does not know that move yet. +func (lb LicenceBindings) FirstView(values map[string]json.RawMessage, asked time.Time) error { var errs []error for consumer, raw := range values { var b LicenceBinding @@ -128,9 +143,20 @@ func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error { return errors.Join(append(errs, err)...) } for _, consumer := range held { - if _, named := values[consumer]; !named { - errs = append(errs, lb.Delete(consumer)) + if _, named := values[consumer]; named { + continue } + if lb.WrittenAt != nil && !asked.IsZero() { + at, err := lb.WrittenAt(consumer) + if err != nil { + errs = append(errs, err) + continue + } + if at.After(asked) { + continue // written by an event after the view was asked (B43) + } + } + errs = append(errs, lb.Delete(consumer)) } return errors.Join(errs...) } @@ -192,7 +218,7 @@ func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, return } values := f.Values() - if err := lb.FirstView(values); err != nil { + if err := lb.FirstView(values, s.Asked); err != nil { say("the licence bindings of the first view are not all kept: %v", err) return } @@ -256,6 +282,9 @@ func (v *Vault) licenceRefusal(caller, name string) (bool, string) { if v.LicenceBindings == nil { return true, "the vault keeps no licence bindings, so no binding names the licence " + key } + if v.LicenceBindings.Answered != nil && !v.LicenceBindings.Answered() { + return true, "the vault's first view of the licence bindings has not been answered since it started, so it does not yet know which machine a binding names; it asks the licence manager until it answers (B31)" + } consumers, err := v.LicenceBindings.Keys() if err != nil { return true, "the licence bindings cannot be read: " + err.Error() diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go index a9858b11b..1297538ce 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go @@ -8,6 +8,7 @@ import ( "slices" "sort" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -85,7 +86,7 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { if err != nil { t.Fatal(err) } - if err := lb.FirstView(a.Values); err != nil { + if err := lb.FirstView(a.Values, time.Time{}); err != nil { t.Fatal(err) } want := map[string]LicenceBinding{"laptop": {Licence: "acct-1@example.org", Key: "acct-1_at_example.org", Generation: 1013}, @@ -98,10 +99,28 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { } 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 { + if err := lb.FirstView(a.Values, time.Time{}); err == nil { t.Fatal("a first view not kept was not said") } + // B43: a key an event wrote after the view was asked is kept though the view does not name it; one written before + // the ask and not named goes. + lb, held = memLicenceBindings() + asked := time.Date(2026, 11, 12, 1, 0, 0, 0, time.UTC) + written := map[string]time.Time{"late": asked.Add(time.Second), "gone": asked.Add(-time.Second)} + lb.WrittenAt = func(c string) (time.Time, error) { return written[c], nil } + held["late"] = LicenceBinding{Licence: "api", Key: "api", Generation: 2000} + held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9} + if err := lb.FirstView(a.Values, asked); err != nil { + t.Fatal(err) + } + if _, ok := held["late"]; !ok { + t.Error("a key an event wrote after the first view was asked was removed by it") + } + if _, ok := held["gone"]; ok { + t.Error("a key written before the first view was asked, which it does not name, was kept") + } + manifest, _ := os.ReadFile("../../module.json") var m struct { Invokes, Consumes []string diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go index 373ad630a..02f8c3357 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licences_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -73,6 +73,22 @@ func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) { } } access := "licence.jane_at_example.org.access" + // B31: until the first view of the bindings is answered, an access token is refused, saying why. + answered := false + lb.Answered = func() bool { return answered } + if err := lb.Heard([]byte(`{"consumer":"g14","licence":"jane","key":"jane_at_example.org","generation":1}`)); err != nil { + t.Fatal(err) + } + if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "first view of the licence bindings has not been answered") { + t.Fatalf("a token was handed before the first view was answered: %v", err) + } + answered = true + if _, _, err := asks(v, "g14.node-tools", access); err != nil { + t.Fatalf("once the first view is answered the bound machine is refused: %v", err) + } + if err := lb.Heard([]byte(`{"consumer":"g14","licence":"","key":"","generation":2}`)); err != nil { + t.Fatal(err) + } if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "no consumer on g14") { t.Fatalf("a machine no binding names was handed the token: %v", err) }