diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 28a541204..6262c4bf5 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -79,27 +79,48 @@ func CauseOf(ctx context.Context) Cause { return Cause{Why: "a rule of this module's own", By: Itself} } -// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line -// in the journal, a row in the audit table, and the event `licence.` on the bus. Never a token. +// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in +// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and +// `rotated` are said no more (B16). Never a token. func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error { if err := m.Store.Audit(ctx, what, detail); err != nil { m.Log("%s, but %v", line, err) return err } m.Log("%s", line) - _ = m.Emit("licence."+what, detail) return nil } -// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom. -func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) error { - what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence) - if from != "" { - what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence) +// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a +// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token. +func (m *Manager) changed(b Binding, from string) { + key := "" + if b.Licence != "" { + key = KeyFor(b.Licence) } - return m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By), - map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation, - "why": c.Why, "by": c.By}) + _ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key, + "kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By, + "at": m.Now().UTC().Format(time.RFC3339Nano)}) +} + +// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27). +func switchedFields(from, to string, consumers []string, c Cause) map[string]any { + return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why} +} + +// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with +// §27's fields), and as binding.changed. +func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error { + what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence) + detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation, + "why": c.Why, "by": c.By} + if from != "" && from != b.Licence { + what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence) + detail = switchedFields(from, b.Licence, []string{b.Consumer}, c) + } + err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail) + m.changed(b, from) + return err } // Settings are the manager's own, declared with defaults (design 39 §8). @@ -305,7 +326,7 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f if err != nil { return err } - if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil { + if err := m.moved(ctx, b, "", c); err != nil { return err } } @@ -418,6 +439,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, // Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625). cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself} ctx = WithCause(ctx, cause) + // The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once + // (§21). + if err := m.publishAdvance(ctx, l, floor); err != nil { + return "", err + } if b, err := m.Store.Binding(ctx, c.Node); err != nil { return "", err } else if b == nil || b.Licence != l.Name { @@ -429,7 +455,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, if b != nil { from = b.Licence } - if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil { + if err := m.moved(ctx, moved, from, cause); err != nil { return "", err } } @@ -442,14 +468,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, } bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself} if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil { - if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil { + if err := m.moved(ctx, b, "", bc); err != nil { return "", err } } } - if err := m.publishAdvance(ctx, l, floor); err != nil { - return "", err - } _ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil}) m.Log("adopted %s from %s (%s)", l.Name, c.Node, why) return l.Name, nil @@ -506,7 +529,10 @@ func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) { // it fetched again (novox/hq issue 625). func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error { c := CauseOf(ctx) - _, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) + advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) + for _, b := range advanced { + m.changed(b, b.Licence) + } return err } @@ -761,9 +787,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if before != nil { from = before.Licence } - if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil { + if err := m.moved(ctx, b, from, c); err != nil { return nil, err } + if from != "" && from != licence { + _ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36) + } return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil } @@ -781,6 +810,7 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil { return nil, err } + m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence) return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index d31471ac6..31156bd76 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -186,12 +186,11 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 { t.Fatalf("bindings %v, state %v", bs, mm.bindings.values) } - // The adoption is said, and so is each binding it made (novox/hq issue 625). + // The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21). events := strings.Join(mm.events, "|") - for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`, - `licence.bound {"by":"claude-licence-manager","consumer":"server"`} { - if !strings.Contains(events, want) { - t.Fatalf("events lack %s: %v", want, mm.events) + for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} { + if strings.Count(events, want) != 1 { + t.Fatalf("events say %s other than once: %v", want, mm.events) } } } @@ -522,27 +521,62 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) { } } +// changedSince is the binding.changed events said from mark on, decoded. +func (mm *miniMesh) changedSince(mark int) []map[string]any { + var out []map[string]any + for _, e := range mm.events[mark:] { + if body, ok := strings.CutPrefix(e, "binding.changed "); ok { + var v map[string]any + _ = json.Unmarshal([]byte(body), &v) + out = append(out, v) + } + } + return out +} + // A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq -// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. -func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { +// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39 +// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key, +// after its key is written or deleted; a switch says one licence.switched with §27's fields. +func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) { mm := newMesh(t) var lines []string mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) } ctx := context.Background() + once := func(mark int, consumer, licence, by string) map[string]any { + t.Helper() + got := mm.changedSince(mark) + if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by { + t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by) + } + if licence != "" && got[0]["key"] != KeyFor(licence) { + t.Fatalf("binding.changed names the key %v", got[0]["key"]) + } + if mm.bound(consumer).Licence != licence { + t.Fatalf("said before the key was written: %+v", mm.bound(consumer)) + } + return got[0] + } + mark := len(mm.events) _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself { t.Fatalf("a login's binding said %+v", s) } + once(mark, "laptop", licence1, Itself) file := filepath.Join(t.TempDir(), "key") _ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600) if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil { t.Fatal(err) } + mark = len(mm.events) asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"}) if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil { t.Fatal(err) } + if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil { + t.Fatalf("the switch said %v", e) + } if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" { t.Fatalf("the node is told %+v", s) } @@ -551,35 +585,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { !strings.Contains(said, "the switch verb; asked by g14.node-tools") { t.Fatalf("the journal said:\n%s", said) } - var switched string + var switched []string for _, e := range mm.events { if strings.HasPrefix(e, "licence.switched ") { - switched = e + switched = append(switched, e) } } - for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} { - if !strings.Contains(switched, want) { - t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events) + if len(switched) != 1 { + t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events) + } + for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} { + if !strings.Contains(switched[0], want) { + t.Fatalf("the event %q lacks %s", switched[0], want) } } - all := mm.audit.under("") - last := all[len(all)-1] - if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") { + all := mm.audit.under("switched") + if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) { t.Fatalf("the audit kept %s", raw) } + _, _ = mm.m.Bind(ctx, "server", licence1) + mark = len(mm.events) _, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true) - if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) { - t.Fatalf("a rotation asked for said no caller: %v", mm.events) - } + once(mark, "server", licence1, "novox.node-tools") + mark = len(mm.events) if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil { t.Fatal(err) } + if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" { + t.Fatalf("the release said %v", e) + } if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") { t.Fatalf("the journal said:\n%s", said) } for _, e := range mm.events { + for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} { + if strings.HasPrefix(e, gone) { + t.Fatalf("an event no longer said was said: %s", e) + } + } if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") { t.Fatalf("an event carries a secret: %s", e) }