Watch the have bucket only where the node's grant allows, so a node composing itself is not refused by the bus (issue 1126)

The fold's watch of every have key ('>') was refused (permissions violation on
CONSUMER.CREATE KV_node-engine_have), and every self-composing agent stopped
after the seed. The composer now watches '*.node' (records) and '<node>.>' (its
own haves), as buscompose grants a node; the shadow watches '<node>.>' too. The
selfcompose tests check every watch and get against PermissionsFor(KindNode),
and the agent's integration tests run the agent as the node under that grant.
This commit is contained in:
2026-10-27 09:27:54 +01:00
parent 50c229f905
commit 0184e2a5b8
4 changed files with 144 additions and 40 deletions
+97 -18
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -18,41 +19,95 @@ import (
const lemurs = `{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`
// fakeBus is the buckets the composer watches, each a channel a test writes updates to, and the keys Get reads.
// fakeBus is the buckets the composer watches, each watch a channel that gets the updates a test writes to its bucket
// whose key its pattern matches, as the bus's own watches do, and the keys Get reads. It keeps every watch and get
// made, so a test checks each against the node's grant.
type fakeBus struct {
mu sync.Mutex
chans map[string]chan agent.Update
kv map[string][]byte // bucket/key
said []string
mu sync.Mutex
watches []patternWatch
kv map[string][]byte // bucket/key
said []string
reads [][2]string // bucket, pattern or key: every watch and get
}
func newFakeBus() *fakeBus {
return &fakeBus{chans: map[string]chan agent.Update{}, kv: map[string][]byte{}}
}
func (b *fakeBus) ch(bucket string) chan agent.Update {
b.mu.Lock()
defer b.mu.Unlock()
if b.chans[bucket] == nil {
b.chans[bucket] = make(chan agent.Update, 16)
}
return b.chans[bucket]
return &fakeBus{kv: map[string][]byte{}}
}
func (b *fakeBus) env() Env {
return Env{Node: "n1",
Watch: func(_ context.Context, bucket, _ string) (<-chan agent.Update, error) { return b.ch(bucket), nil },
Watch: func(_ context.Context, bucket, pattern string) (<-chan agent.Update, error) {
b.mu.Lock()
defer b.mu.Unlock()
w := patternWatch{bucket: bucket, pattern: pattern, ch: make(chan agent.Update, 64)}
b.watches = append(b.watches, w)
b.reads = append(b.reads, [2]string{bucket, pattern})
return w.ch, nil
},
Get: func(_ context.Context, bucket, key string) ([]byte, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.reads = append(b.reads, [2]string{bucket, key})
return b.kv[bucket+"/"+key], nil
},
Say: func(s string) { b.mu.Lock(); b.said = append(b.said, s); b.mu.Unlock() }}
}
// send gives u to every watch of bucket whose pattern matches its key; Caught to every watch of bucket.
func (b *fakeBus) send(bucket string, u agent.Update) {
b.mu.Lock()
ws := append([]patternWatch(nil), b.watches...)
b.mu.Unlock()
for _, w := range ws {
if w.bucket == bucket && (u.Caught || matches(w.pattern, u.Key)) {
w.ch <- u
}
}
}
func (b *fakeBus) put(t *testing.T, bucket, key string, value []byte) {
t.Helper()
b.ch(bucket) <- agent.Update{Key: key, Value: value}
b.send(bucket, agent.Update{Key: key, Value: value})
}
// grantAllows says whether the node's grant (buscompose.PermissionsFor, KindNode) lets it watch or read pattern of
// bucket: the consumer a watch or a get makes (`$JS.API.CONSUMER.CREATE.KV_<bucket>.<name>.$KV.<bucket>.<pattern>`)
// and the stream's information, matched as the bus matches a permission against a subject.
func grantAllows(t *testing.T, node, bucket, pattern string) bool {
t.Helper()
perms, err := buscompose.PermissionsFor(buscompose.Principal{Kind: buscompose.KindNode, Node: node})
if err != nil {
t.Fatal(err)
}
allowed := func(subject string) bool {
for _, deny := range perms.PublishDeny {
if subjectMatches(deny, subject) {
return false
}
}
for _, p := range perms.Publish {
if subjectMatches(p, subject) {
return true
}
}
return false
}
stream := "KV_" + bucket
return allowed("$JS.API.STREAM.INFO."+stream) && allowed("$JS.API.CONSUMER.CREATE."+stream+".c1.$KV."+bucket+"."+pattern)
}
// subjectMatches is the bus's match of a permission against a subject: `*` any one token, `>` one or more to the end.
func subjectMatches(permission, subject string) bool {
p, s := strings.Split(permission, "."), strings.Split(subject, ".")
for i, tok := range p {
if tok == ">" {
return len(s) > i
}
if i >= len(s) || (tok != "*" && tok != s[i]) {
return false
}
}
return len(p) == len(s)
}
func next(t *testing.T, out <-chan agent.Update) agent.Update {
@@ -135,7 +190,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant)
for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket} {
b.ch(bucket) <- agent.Update{Caught: true}
b.send(bucket, agent.Update{Caught: true})
}
got := map[string]bool{}
var own delivery.Composed
@@ -163,6 +218,15 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
if !strings.Contains(strings.Join(ownText, " "), `"id":"oldie.service"`) {
t.Errorf("the node's own composition, composed before anything was applied, lacks oldie: %v", ownText)
}
// Issue 1126, 2026-10-27 09:21: every watch and get the composer makes is one the node's grant allows; a watch of
// every have key (`>`) was refused by the bus and stopped every node agent.
b.mu.Lock()
for _, r := range b.reads {
if !grantAllows(t, "n1", r[0], r[1]) {
t.Errorf("the composer reads %s of %s, which the node's grant does not allow", r[1], r[0])
}
}
b.mu.Unlock()
lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fp)
nodeKey, _ := delivery.ComposedNodeKey("n1")
if !got[lemursKey] || !got[nodeKey] || len(got) != 2 {
@@ -424,6 +488,21 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
}
}
waitFor("every group answered", func(a map[string]delivery.CandidateAnswer) bool { return len(a) == 4 })
// Every watch and get the shadow makes is one the node's grant allows (issue 1126, 2026-10-27 09:21).
sb.mu.Lock()
for _, w := range sb.watches {
if !grantAllows(t, "n1", w.bucket, w.pattern) {
t.Errorf("the shadow watches %s of %s, which the node's grant does not allow", w.pattern, w.bucket)
}
}
sb.mu.Unlock()
readOnly.mu.Lock()
for _, r := range readOnly.reads {
if !grantAllows(t, "n1", r[0], r[1]) {
t.Errorf("the shadow reads %s of %s, which the node's grant does not allow", r[1], r[0])
}
}
readOnly.mu.Unlock()
for group, want := range map[string]func(delivery.CandidateAnswer) bool{
"same": func(a delivery.CandidateAnswer) bool {
return a.Verdict == delivery.CandidateSame && a.Head == "h-same" && a.Revision == 3 && len(a.Same) == 2