Node principals read what they compose their own modules from; the composer copy is held per user to the controller's grants plus exactly these (issue 1126, ADR 0547 §2, ADR 0549 §3)
mesh/delivery-group group feat/1126-nodes-compose rejected: composed together: the checker's verdict at the members' heads (922da6d6ae76): forge
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 6df0dfe208c6 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose

This commit is contained in:
2026-10-26 18:44:06 +01:00
parent 9532b78724
commit 6df0dfe208
3 changed files with 108 additions and 29 deletions
+88 -29
View File
@@ -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>.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>.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))
})
}
+4
View File
@@ -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)
+16
View File
@@ -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 `<node>.<module>.<key>`.
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, `<node>.>`. 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+".>")...)
}