diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 60297ec..554c6a1 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -14,24 +14,96 @@ import ( // byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own // internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the // controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller, -// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the rows the copy -// grants beyond it on purpose: the copy must grant everything the controller's grants, and only those rows more. +// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy +// gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and +// only those more. // createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it. var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `) -// nodeRecordReads are the two rows the copy grants a node principal beyond the controller's on purpose (novox/hq ADR 0555 -// §3, issue 1126): a read of every node's own facts, `.node` of the have bucket, where the node's record rides, so a -// node agent composing its own modules reads the other nodes' public records. After the take-over (ADR 0547 §8) the grants -// part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and keeps working -// without the second milestone's composing. -var nodeRecordReads = regexp.MustCompile(`"\$JS\.API\.(DIRECT\.GET|CONSUMER\.CREATE)\.KV_node-engine_have\.(\*\.)?\$KV\.node-engine_have\.\*\.node",? ?`) +// beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3, +// issue 1126), by user: a node's account reads every node's own facts, `.node` of the have bucket where the node's +// record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the +// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes +// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation +// (issue 1790) is the other row beyond, matched by createdAdvisory. +func beyondTheControllers(user string) map[string]bool { + out := map[string]bool{} + node, ok := strings.CutPrefix(user, "node.") + if !ok { + return out + } + for _, s := range append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...) { + out[s] = true + } + return out +} -// beyondTheControllers is a list the copy composed with the rows it grants beyond the controller's on purpose taken -// out, and how many node-record rows it held: what is left must be the controller's list exactly, so the copy grants -// everything the controller's grants and nothing more but those rows. -func beyondTheControllers(list string) (string, int) { - n := len(nodeRecordReads.FindAllString(list, -1)) - return nodeRecordReads.ReplaceAllString(createdAdvisory.ReplaceAllString(list, ""), ""), n +// quotedToken is a quoted token of a composed list. +var quotedToken = regexp.MustCompile(`"[^"]*"`) + +// grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its +// password's hash, every subject it may publish or subscribe to), by user. +func grantsByUser(list string) (string, map[string]map[string]bool) { + var outside strings.Builder + users := map[string]map[string]bool{} + user := "" + for _, line := range strings.SplitAfter(list, "\n") { + trimmed := strings.TrimSpace(line) + if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok { + user, _, _ = strings.Cut(name, `"`) + users[user] = map[string]bool{} + } else if user != "" && trimmed == "]" { + user = "" + } + if user == "" { + outside.WriteString(line) + continue + } + for _, q := range quotedToken.FindAllString(line, -1) { + users[user][strings.Trim(q, `"`)] = true + } + } + return outside.String(), users +} + +// grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it +// names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and +// the copy's beyond them are exactly those, every one of them there. +func grantsAsTheControllers(t *testing.T, got, want string) { + t.Helper() + gotOutside, gotUsers := grantsByUser(got) + wantOutside, wantUsers := grantsByUser(want) + if gotOutside != wantOutside { + t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside) + } + for user, was := range wantUsers { + now, ok := gotUsers[user] + if !ok { + t.Errorf("the copy has no user %s", user) + continue + } + intended := beyondTheControllers(user) + for g := range was { + if !now[g] { + t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g) + } + } + for g := range now { + if !was[g] && !intended[g] && !createdAdvisory.MatchString(`"`+g+`", `) { + t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) + } + } + for g := range intended { + if !now[g] { + t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g) + } + } + } + for user := range gotUsers { + if _, ok := wantUsers[user]; !ok { + t.Errorf("the copy has a user %s the controller's has not", user) + } + } } func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { @@ -72,14 +144,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { // the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state // missing at its start is bound when it appears. The controller's composer is frozen and its list no longer // loaded; while it is not, this row is left out of the comparison and nothing else. - list, records := beyondTheControllers(list) - if list != want[i].List { - t.Errorf("the list differs from the controller's beyond the intended rows:\n%s", list) - } - // And the intended rows are there: two for each node principal of the fixture. - if nodes := strings.Count(want[i].List, `"$KV.node-engine_have.`); nodes > 0 && records == 0 { - t.Errorf("no node principal reads the other nodes' records") - } + grantsAsTheControllers(t, list, want[i].List) // And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the // grants part publishes them. raw, err := json.Marshal(MembershipsFor(f)) @@ -155,12 +220,6 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { if err != nil { t.Fatal(err) } - got, records := beyondTheControllers(got) - if records != 2 { - t.Errorf("node one is granted %d of the two reads of the other nodes' records", records) - } - if got != string(want) { - t.Errorf("the configuration differs from the controller's golden beyond the intended rows:\n%s", got) - } + grantsAsTheControllers(t, got, string(want)) }) } diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index cdf7337..8f5379b 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -886,6 +886,10 @@ func PermissionsFor(p Principal) (Permissions, error) { // its own modules reads the other nodes' public records, which its firewall and resolver need (ADR 0547 §2). Read // only: each node still writes its own keys alone. pub = append(pub, readGrants(delivery.HaveBucket, "*."+delivery.HaveNodeWord)...) + // And what it composes its own modules from (ADR 0547 §2, ADR 0549 §3): the indexer's placement, modules and + // manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of + // its own node alone. Read only. + pub = append(pub, NodeComposeReads(p.Node)...) // And an object of the declaration bucket by its name, and nothing of the bucket's list or writing // (novox/hq ADR 0486 §2, issue 1697). fileReads, err := DeclarationFilesReads(p.Node) diff --git a/go/buscompose/state.go b/go/buscompose/state.go index f772cae..a0d6feb 100644 --- a/go/buscompose/state.go +++ b/go/buscompose/state.go @@ -3,6 +3,7 @@ package buscompose import ( "fmt" "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/delivery" "sort" "strings" "time" @@ -306,3 +307,18 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err sort.Strings(undeclared) return undeclared, nil } + +// ResolvedBucket is mesh-config's state of resolved values, keyed `..`. +const ResolvedBucket = "mesh-config_resolved" + +// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0549 §3, issue +// 1126): every key of the indexer's placement, modules and manifests and of the builder's builds, and of mesh-config's +// resolved values only its own node's, `.>`. Reads only; the node is checked as every subject token is by the +// caller's principal. +func NodeComposeReads(node string) []string { + var out []string + for _, bucket := range []string{delivery.PlacementBucket, delivery.ModulesBucket, delivery.ManifestsBucket, delivery.BuilderBuildsBucket} { + out = append(out, readGrants(bucket, "")...) + } + return append(out, readGrants(ResolvedBucket, node+".>")...) +}