From 02214cf633532e8935c991b8818e660981524e48 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:22:23 +0100 Subject: [PATCH] mesh-enrolment: revoke, the expiry marker and the drop of the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revoke ends an enrolment at any stage without an Approve and records it as an act done by hand (the vault retires an account minted for a redeem under way); the bus's expiry marker of a key at live says enrolment-expired with the issued event; the durable consumer of the seat's own ending events asks the vault to drop the token and its hash, acknowledging only once the vault answers, never before an end. No event or key holds a token (ADR 0587 §5, §8). Run starts the watch, the consumer and the operator's answers. Tests added: two table-style tests. (issue 774, 774/S58) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../mesh-enrolment/cmd/mesh-enrolment/end.go | 119 ++++++++++++++++++ .../cmd/mesh-enrolment/end_test.go | 91 ++++++++++++++ .../cmd/mesh-enrolment/kit_test.go | 2 + .../mesh-enrolment/cmd/mesh-enrolment/main.go | 2 + .../mesh-enrolment/cmd/mesh-enrolment/run.go | 39 ++++++ .../cmd/mesh-enrolment/tokens_test.go | 9 ++ .../cmd/mesh-enrolment/tools.go | 11 ++ 7 files changed, 273 insertions(+) create mode 100644 modules/mesh-enrolment/cmd/mesh-enrolment/end.go create mode 100644 modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go create mode 100644 modules/mesh-enrolment/cmd/mesh-enrolment/run.go diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go new file mode 100644 index 0000000..b4d2f2b --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Revoke ends an enrolment at any stage (ADR 0587 §5), by a person's call: at asked it withdraws the ask; at redeeming it also +// undoes what redeem had not finished, the vault retiring a node account it already minted (a node already written into +// placement stays there and is removed, if wanted, as any node is removed). It only takes access away, so it needs no Approve; +// it is recorded in the hand-act log. enrolment-revoked carries the key's content and the why; the token is then dropped by +// the durable consumer of that event, never before. A revoke that stopped is finished by calling it again. +func (h *Holder) Revoke(caller, node, why string) (map[string]any, error) { + if caller == "" || caller == stdio.Unproven { + return nil, fmt.Errorf("the caller is not proven by the runtime, and a revoke is recorded with who made it") + } + if strings.TrimSpace(why) == "" { + return nil, fmt.Errorf("a revoke says why: it is recorded as an act done by hand") + } + row, found, err := h.Store.Get(NodeKey(node)) + if err != nil { + return nil, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err) + } + if !found { + return nil, fmt.Errorf("no enrolment of %s is open: mesh-enrolment.tokens lists those that are", node) + } + e, err := decodeEnrolment(row.Value) + if err != nil { + return nil, err + } + if e.Stage == StageAsked && e.Ask != "" && h.Asker != nil { + if err := h.Asker.Cancel(e.Ask); err != nil { + h.logf("the ask %s of %s was not taken back at the router: %v", e.Ask, node, err) + } + } + if e.Stage == StageRedeeming { + if err := h.Vault.Retire(node); err != nil { + return nil, fmt.Errorf("the vault did not retire the account minted for %s, so nothing is ended (call revoke again): %w", node, err) + } + } + out := map[string]any{"node": node, "was": e.Stage} + if h.Act != nil { + if err := h.Act("mesh-enrolment revoke of "+node+" at "+string(e.Stage), why+" (by "+caller+")", "enrolment-revoke"); err != nil { + out["recorded"] = "NOT recorded in the hand-act log: " + err.Error() + h.logf("the revoke of %s is not recorded in the hand-act log: %v", node, err) + } + } + if err := h.sayEvent(EventRevoked, e, why); err != nil { + return nil, fmt.Errorf("enrolment-revoked of %s cannot be said, so its keys stay (call revoke again): %w", node, err) + } + if err := h.release(e); err != nil { + return nil, fmt.Errorf("the enrolment of %s is revoked and its keys are not all deleted (call revoke again): %w", node, err) + } + out["says"] = "the enrolment is ended; the vault is asked to drop the token and, for a redeem under way, the account was retired" + return out, nil +} + +// Heard is the holder's watch of its own bucket (stdio.State.Follow): it keeps each node key's last content, and the bus's +// expiry marker of a key at stage live says enrolment-expired, naming the node and the enrolment-issued event that carried its +// content (ADR 0587 §5). redeem checks the expiry itself, so a marker heard late admits no machine. +func (h *Holder) Heard(op, key string, value []byte) error { + node, ok := strings.CutPrefix(key, "node.") + if !ok { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + if h.seen == nil { + h.seen = map[string]Enrolment{} + } + switch op { + case stdio.OpPut: + if e, err := decodeEnrolment(value); err == nil { + h.seen[node] = e + } + case stdio.OpDelete: + delete(h.seen, node) + case stdio.OpExpire: + e, known := h.seen[node] + delete(h.seen, node) + body := Event{Node: node, Enrolment: e} + id := "enrolment-expired." + node + if known { + body.Issued = eventID(EventIssued, e) + id = eventID(EventExpired, e) + } + body.Reason = "the token's expiry passed unredeemed" + if err := h.say(EventExpired, body, id); err != nil { + return fmt.Errorf("enrolment-expired of %s cannot be said (offered again): %w", node, err) + } + } + return nil +} + +// Ended is the durable consumer's act on one of the holder's own ending events (ADR 0587 §5): the vault is asked to drop the +// node's token and its hash, and the event is acknowledged only when the vault answers that both are gone, so a drop that +// fails is delivered again by the bus. It never runs before an end: the consumer hears only the end events. +func (h *Holder) Ended(event string, body []byte) error { + var e struct { + Node string `json:"node"` + } + if err := json.Unmarshal(body, &e); err != nil || CheckNode(e.Node) != nil { + h.logf("the event %s names no node and is not acted on: %v", event, err) + return nil + } + if err := h.Vault.Drop(e.Node); err != nil { + return fmt.Errorf("the vault did not drop the token of %s (the bus delivers %s again): %w", e.Node, event, err) + } + h.mu.Lock() + h.ended = append(h.ended, json.RawMessage(`{"event":`+fmt.Sprintf("%q", event)+`,"content":`+string(body)+`}`)) + if len(h.ended) > 200 { + h.ended = h.ended[len(h.ended)-200:] + } + h.mu.Unlock() + return nil +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go new file mode 100644 index 0000000..92cd90f --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "slices" + "strings" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// 774/S58/AC1: a revoke at redeeming, whether the vault minted the account yet or not, takes both keys away, the vault retires +// the account, the revoke is recorded and enrolment-revoked is said; one the vault failed to retire ends nothing and is +// finished by calling it again. At asked it withdraws the ask; an expiry marker says enrolment-expired with the issued event. +func TestRevokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) { + r := newRig(t) + r.seed("ace", "10.9.0.2", StageRedeeming) + r.vault.fail["retire ace"] = true + if _, err := r.h.Revoke("person.operator", "ace", "never confirmed"); err == nil || len(r.keys()) != 2 || len(r.said) != 0 { + t.Fatalf("a failed retire ended it: %v, keys %v, events %v", err, r.keys(), r.events()) + } + if _, err := r.h.Revoke("person.operator", "ace", "never confirmed"); err != nil { + t.Fatal(err) + } + if len(r.keys()) != 0 || !slices.Equal(r.events(), []string{EventRevoked}) || len(r.acts) != 1 || !slices.Contains(r.vault.calls, "retire ace") { + t.Errorf("keys %v events %v acts %v vault %v", r.keys(), r.events(), r.acts, r.vault.calls) + } + mustContain(t, string(r.said[0].Body), "never confirmed") + // At asked it withdraws the ask and the vault is not asked to retire anything. + r.seed("g14", "10.9.0.3", StageAsked) + if _, err := r.h.Revoke("person.operator", "g14", "changed our mind"); err != nil || !slices.Equal(r.asker.cancelled, []string{"ask-g14"}) || slices.Contains(r.vault.calls, "retire g14") { + t.Errorf("%v, cancelled %v, vault %v", err, r.asker.cancelled, r.vault.calls) + } + if _, err := r.h.Revoke(stdio.Unproven, "ace", "x"); err == nil { + t.Error("revoked for no one") + } + // The bus's marker of a key at live: the content the watch handed over is said with the issued event. + r.seed("novox", "10.9.0.4", StageLive) + row, _, _ := r.st.Get(NodeKey("novox")) + _ = r.h.Heard(stdio.OpPut, NodeKey("novox"), row.Value) + if err := r.h.Heard(stdio.OpExpire, NodeKey("novox"), nil); err != nil { + t.Fatal(err) + } + last := r.said[len(r.said)-1] + mustContain(t, string(last.Body), `"issued-event":"enrolment-issued.novox.`) + if last.Event != EventExpired { + t.Errorf("said %s", last.Event) + } +} + +// 774/S58/AC2 and AC3: the drop is asked of the vault only from an ending event, acknowledged only once the vault answers, and +// asked again when its first answer fails; and neither an event nor the bucket ever holds the token, sealed or not. +func TestTheDropFollowsOnlyAnEndAndNothingHoldsTheToken(t *testing.T) { + r := newRig(t) + a, _ := r.h.Issue("person.operator", "ace", "a new machine", 0) + _ = r.h.Warranted(yes, askWarrant(a.Ask)) + if _, err := r.h.Tokens("ace", false); err != nil { // relays the sealed token + t.Fatal(err) + } + if slices.ContainsFunc(r.vault.calls, func(c string) bool { return strings.HasPrefix(c, "drop") }) { + t.Fatalf("the vault was asked to drop a token before an end: %v", r.vault.calls) + } + if _, err := r.h.Revoke("person.operator", "ace", "lost the machine"); err != nil { + t.Fatal(err) + } + end := r.said[len(r.said)-1] + r.vault.fail["drop ace"] = true + if err := r.h.Ended(end.Event, end.Body); err == nil { + t.Fatal("an event was acknowledged though the vault did not answer") + } + if err := r.h.Ended(end.Event, end.Body); err != nil { + t.Fatal(err) + } + if n := strings.Count(strings.Join(r.vault.calls, ","), "drop ace"); n != 2 { + t.Errorf("the drop was asked %d times", n) + } + for _, s := range r.said { + if strings.Contains(string(s.Body), "sealed") || strings.Contains(string(s.Body), "token") { + t.Errorf("the event %s holds a token: %s", s.Event, s.Body) + } + } + for k, row := range r.st.rows { + if strings.Contains(string(row.Value), "sealed") { + t.Errorf("the key %s holds a token", k) + } + } + tk, _ := r.h.Tokens("", true) + if raw, _ := json.Marshal(tk); !strings.Contains(string(raw), "enrolment-revoked") { + t.Errorf("history %s", raw) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index 40bd2cc..d42413b 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -255,3 +255,5 @@ func errText(err error) string { } return fmt.Sprint(err) } + +func askWarrant(ask string) asks.Warrant { return asks.Warrant{Ask: ask} } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go index 189f172..3ff7ccd 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go @@ -139,6 +139,8 @@ func loadedVia(rd Reader) func(string) (bool, error) { func main() { h := newHolder() + client := h.Asker.(*asker.Client) + h.Run(func() error { return client.Listen(h.Warranted, h.Said) }) if err := stdio.Serve("", Tools(h)); err != nil { logf("%v", err) os.Exit(1) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/run.go b/modules/mesh-enrolment/cmd/mesh-enrolment/run.go new file mode 100644 index 0000000..07cd094 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/run.go @@ -0,0 +1,39 @@ +package main + +import ( + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Run starts what the holder does beside its tools, none of it on a timer: the watch of its own bucket (the bus's expiry +// markers), the durable consumer of its own ending events (the drop), and the operator's answers. Each is asked of the +// runtime as soon as it serves, and again with a growing wait until the runtime takes it. +func (h *Holder) Run(listen func() error) { + go stdio.State(State).Follow("", func(c stdio.StateChange) error { return h.Heard(c.Op, c.Key, c.Value) }) + for _, event := range EndEvents { + go func(key string) { + <-stdio.Served() + retry("hear "+key, h.logf, func() error { + return stdio.Subscribe(key, func(e stdio.Envelope) error { return h.Ended(key, e.Body) }) + }) + }(Seat + "." + event) + } + if listen != nil { + go func() { + <-stdio.Served() + retry("hear the operator's answers", h.logf, listen) + }() + } +} + +func retry(what string, logf func(string, ...any), do func() error) { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := do() + if err == nil { + return + } + logf("does not %s yet (%v); asking again in %s", what, err, wait) + time.Sleep(wait) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go index a200701..0ca284c 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -94,6 +94,15 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { if !slices.Equal(m.Consumes, heard) { t.Errorf("consumes %v, the end events are %v", m.Consumes, heard) } + served := map[string]bool{} + for _, tool := range Tools(&Holder{}) { + served[tool.Name] = true + } + for _, v := range Verbs { + if !served[Seat+"."+v] { + t.Errorf("the verb %s has no tool", v) + } + } if len(m.State) != 2 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool { var d struct { Name string `json:"name"` diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go index e6f1ac3..1c1ca47 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -15,6 +15,7 @@ const ( AgainTool = Seat + ".again" RedeemTool = Seat + ".redeem" ConfirmTool = Seat + ".confirm" + RevokeTool = Seat + ".revoke" TokensTool = Seat + ".tokens" ) @@ -94,6 +95,16 @@ func Tools(h *Holder) []stdio.Tool { "the enrolment; the vault is then asked to drop the token.", Input: obj(map[string]any{"node": str("the machine's own name (optional)")}), Run: func(a map[string]any) (any, error) { return h.Confirm(stdio.CallerOf(a), arg(a, "node")) }}, + {Name: RevokeTool, + Description: "End an enrolment at any stage (novox/hq ADR 0587 §5): at asked it withdraws the ask; at live or redeeming it deletes " + + "the key and its address key, and at redeeming the vault retires an account it already minted (a node already written into " + + "placement stays there). It only takes access away, so it needs no Approve; it is recorded as an act done by hand. Says " + + "enrolment-revoked; the vault then drops the token. A machine stuck at redeeming is ended by this. A revoke that stopped is " + + "finished by calling it again.", + Input: obj(map[string]any{"node": str("the machine"), "why": str("why, kept with the act")}, "node", "why"), + Run: func(a map[string]any) (any, error) { + return h.Revoke(stdio.CallerOf(a), arg(a, "node"), arg(a, "why")) + }}, {Name: AgainTool, Description: "Ask the operator again about an enrolment still at stage asked whose ask the router no longer holds open " + "(novox/hq ADR 0587 §8): a new ask for the same node, address and expiry. Nothing more: a redeem stopped midway is finished " +