diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 1b70ab8..beefe2b 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "strings" stdio "git.novox.be/novox/mesh-sdk/go" @@ -135,6 +136,25 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.MatchConsumer(grants.Answer(a, "stream"), grants.Answer(a, "consumer")) }}, + {Name: grants.ToolAcceptHashes, Description: "Release the hold of a user list that changes several loaded users' hashes at " + + "once (novox/hq ADR 0561 §7; the condition mesh-grants.user-list-held names them), by a person's act with why: names " + + "exactly the users held, bound to the held list's digest, so the part's process writes that list once at its next pass " + + "and says hashes-accepted. It never releases another hold (a failed read, a user dropped for want of a hash, a lost " + + "right), and a list composed otherwise since is held again for a new acceptance.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "users": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the users held, as the condition names them"}, + "why": map[string]any{"type": "string", "description": "why their hashes change"}}, "required": []string{"users", "why"}}, + Run: func(a map[string]any) (any, error) { + var users []string + if list, ok := a["users"].([]any); ok { + for _, u := range list { + if s, ok := u.(string); ok { + users = append(users, strings.TrimSpace(s)) + } + } + } + return p.AcceptHashes(stdio.CallerOf(a), users, grants.Answer(a, "why")) + }}, {Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " + "every act with what became of it (asked, switched, declined, expired, refused and why).", Input: schema(nil), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 6060327..eaf5da0 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2096,9 +2096,40 @@ func TestGrants(t *testing.T) { if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "a.mod-x, a.nats") { t.Errorf("the hold was said as %+v", said) } + // The hold is its own guard, beside take-over's refusals (ADR 0562): the switch stays taken over. + if p.SwitchState() != grants.TookOver { + t.Errorf("the hold moved the switch to %s", p.SwitchState()) + } + // A person accepts exactly the named users' changes of the held list, with why: refused for another set, with no + // why, or once the list changed since; accepted, the next pass writes it once, said as a hand act. + if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x"}, "rotated by hand"); err == nil || !strings.Contains(err.Error(), "a.mod-x, a.nats") { + t.Errorf("an acceptance of another set than the held one: %v", err) + } + if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, ""); err == nil { + t.Error("an acceptance with no why") + } + if _, err := p.AcceptHashes("person.jochen", []string{"a.nats", "a.mod-x"}, "the vault's store was restored"); err != nil { + t.Fatal(err) + } + // The list changed since the acceptance (a.mod-x's hash again): the acceptance is bound to the held list, refused. + hash("a.mod-x", "$2a$10$fourth", 4, false) + res = p.Compose() + if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "held") { + t.Fatalf("an acceptance of a list that changed since released it: %+v", res.Sealed) + } + if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, "the vault's store was restored"); err != nil { + t.Fatal(err) + } + b.events = nil + p.Compose() + if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared || + !slices.Contains(b.events, grants.EventHashesAccepted) { + t.Fatalf("an accepted hold was not released once and said: %+v, events %v", said, b.events) + } + inForce, _, _ = p.Sealed() // The vault's rotation names each: written, the warning cleared. - hash("a.mod-x", "$2a$10$third", 3, true) - hash("a.nats", "$2a$10$third", 2, true) + hash("a.mod-x", "$2a$10$fifth", 5, true) + hash("a.nats", "$2a$10$fifth", 3, true) p.Compose() if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared { t.Errorf("changes the vault's rotation names were held: %+v", said) diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 1756cfd..51e336a 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -42,7 +42,7 @@ const ( ) // Emits is every event the part says. -var Emits = []string{EventAsserted, EventMembership, EventUserList} +var Emits = []string{EventAsserted, EventHashesAccepted, EventMembership, EventUserList} // Store is the part's own state, by local name: the only thing the part writes. type Store interface { @@ -166,6 +166,8 @@ type Part struct { // differSaid is the account consumers' differences last said as a condition, once differKnown (consumers.go). differSaid string differKnown bool + // hashHold are the users whose hash changes held the pass's list, or nil (sealed.go). + hashHold []string } // Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 20f1be6..cb5bc46 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -2,10 +2,12 @@ package grants import ( "encoding/json" + "errors" "fmt" "regexp" "slices" "strings" + "time" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/buscompose" @@ -91,11 +93,12 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string why = "no node runs the bus's module, so the list is sealed to nobody" case key == "": why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" - case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost) != "": + case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost, digest) != "": // After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user // of it for want of a hash: that user's module would be locked out of the bus (review of the switch). - why = p.holdsBack(in, leftOut, bus, lost) + why = p.holdsBack(in, leftOut, bus, lost, digest) held = why + p.keepHashHold(res, digest) case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: object, err := delivery.SealUserList(list, key) if err == nil { @@ -134,6 +137,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string } if next.Digest == digest && cur.Digest != digest { p.keepWrittenUsers(res, list, in.Hashes) + p.acceptedWritten(res, digest) } // The object the key named before is taken away once the key names the new one: the bucket keeps the list in force. if had && cur.Object != "" && cur.Object != next.Object { @@ -342,7 +346,8 @@ func listUsers(list []byte) []string { // loaded list not read holds it too, since that cannot be ruled out). The list in force is the one the part wrote, by the users // it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when // they could not be read). Empty when it may. -func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string { +func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string, digest string) string { + p.hashHold = nil if f := in.ReadFailures(); len(f) > 0 { return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" } @@ -378,9 +383,121 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string { if err != nil { return "held: the credentials of the list in force cannot be read: " + err.Error() } - if len(changed) > 1 { + if len(changed) > 1 && !p.accepted(digest, changed) { + p.hashHold = changed return "held: the list in force is kept, since the hashes of " + fmt.Sprint(len(changed)) + " loaded users would change at " + "once with no rotation by the vault naming them: " + strings.Join(changed, ", ") } return "" } + +// **A person accepts a held list's hash changes** (ADR 0561 §7, §9; tools-are-how-a-module-is-spoken-to): the hold of +// several loaded users' hashes changing at once is released only by grants_accept_hashes, naming exactly the users held, +// with why, bound to the held list's digest. The acceptance releases this guard alone, once: the other holds (a failed +// read, a user dropped for want of a hash, a lost right) stand whatever it says; and a list composed otherwise since is +// held again, for a new acceptance. + +// KeyHashHold names the list held for its hash changes (its digest and the users), and KeyHashAccept a person's acceptance. +const ( + KeyHashHold = "hash-hold" + KeyHashAccept = "hash-accept" +) + +// EventHashesAccepted is said when a list held for its hash changes is written on a person's acceptance: a hand act. +const EventHashesAccepted = "hashes-accepted" + +// HashHold is the value of KeyHashHold, and HashAccept of KeyHashAccept. +type HashHold struct { + Digest string `json:"digest"` + Users []string `json:"users"` + At time.Time `json:"at"` +} + +type HashAccept struct { + Digest string `json:"digest"` + Users []string `json:"users"` + By string `json:"by"` + Why string `json:"why"` + At time.Time `json:"at"` +} + +// keepHashHold writes which list is held for its hash changes, when that is the hold. +func (p *Part) keepHashHold(res *Result, digest string) { + if p.hashHold == nil { + return + } + data, _ := json.Marshal(HashHold{Digest: digest, Users: p.hashHold, At: p.now()}) + if raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold); err == nil && found { + var was HashHold + if json.Unmarshal(raw, &was) == nil && was.Digest == digest && slices.Equal(was.Users, p.hashHold) { + return + } + } + if _, err := p.Store.Put(StateUserList, KeyHashHold, data); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyHashHold, err)) + } +} + +// accepted says a person accepted exactly these users' hash changes of the list of this digest. +func (p *Part) accepted(digest string, users []string) bool { + raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept) + if err != nil || !found { + return false + } + var a HashAccept + return json.Unmarshal(raw, &a) == nil && a.Digest == digest && slices.Equal(a.Users, users) +} + +// acceptedWritten says, once, that the list a person accepted is written, and forgets the acceptance and the hold. +func (p *Part) acceptedWritten(res *Result, digest string) { + raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept) + if err != nil || !found { + return + } + var a HashAccept + if json.Unmarshal(raw, &a) != nil || a.Digest != digest { + return + } + p.say(res, EventHashesAccepted, map[string]any{"digest": digest, "users": a.Users, "by": a.By, "why": a.Why}) + for _, k := range []string{KeyHashAccept, KeyHashHold} { + if err := p.Store.Delete(StateUserList, k); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, k, err)) + } + } +} + +// AcceptHashes is the tool accept-hashes: a person accepts the hash changes of the list held for them, naming exactly its +// users, with why. Refused when no list is held for its hashes, or for another set of users. Bound to the held list's +// digest: a list composed otherwise since is held again. +func (p *Part) AcceptHashes(caller string, users []string, why string) (any, error) { + if strings.TrimSpace(why) == "" { + return nil, errors.New("accept-hashes says why (`why`)") + } + raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold) + if err != nil { + return nil, fmt.Errorf("the held list cannot be read: %w", err) + } + var h HashHold + if !found || json.Unmarshal(raw, &h) != nil || h.Digest == "" { + return nil, errors.New("no list is held for its users' hashes changing: there is nothing to accept") + } + named := slices.Sorted(slices.Values(users)) + if !slices.Equal(named, h.Users) { + return nil, fmt.Errorf("the list held (%s) changes the hashes of %s: an acceptance names exactly those, and %s is not that", + short(h.Digest), strings.Join(h.Users, ", "), strings.Join(named, ", ")) + } + data, _ := json.Marshal(HashAccept{Digest: h.Digest, Users: h.Users, By: someoneOr(caller), Why: strings.TrimSpace(why), At: p.now()}) + rev, err := p.Store.Put(StateUserList, KeyHashAccept, data) + if err != nil { + return nil, fmt.Errorf("the acceptance could not be written: %w", err) + } + return map[string]any{"accepted": rev, "digest": h.Digest, "users": h.Users, "says": "the part's process writes the held " + + "list at its next pass, once, unless another hold stands or the list composed then is another; it says hashes-accepted"}, nil +} + +func someoneOr(caller string) string { + if caller == "" { + return "a caller the runtime did not prove" + } + return caller +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 344bec3..955adf9 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -23,11 +23,12 @@ const ( ToolHandBack = "grants_hand_back" ToolAccounts = "grants_accounts" ToolConsumerMatch = "grants_consumer_match" + ToolAcceptHashes = "grants_accept_hashes" ) // Tools is every tool, in the manifest's order. var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState, - ToolAccounts, ToolConsumerMatch} + ToolAccounts, ToolConsumerMatch, ToolAcceptHashes} // WritesNoGrant is what grants says of the part's own grants in this milestone. const WritesNoGrant = "none: the grants part writes no grants.. before ADR 0547's third milestone (clause 5); the controller's are beside" diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index c2db166..4d06fbf 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -31,6 +31,7 @@ ], "emits": [ "asserted", + "hashes-accepted", "membership-changed", "user-list-changed" ], @@ -44,7 +45,8 @@ "grants_take_over_state", "grants_hand_back", "grants_accounts", - "grants_consumer_match" + "grants_consumer_match", + "grants_accept_hashes" ], "state": [ {