diff --git a/internal/broker/delivery_parts_grant_test.go b/internal/broker/delivery_parts_grant_test.go new file mode 100644 index 00000000..836a9f9a --- /dev/null +++ b/internal/broker/delivery_parts_grant_test.go @@ -0,0 +1,490 @@ +package broker + +import ( + "encoding/json" + "slices" + "sort" + "strings" + "testing" +) + +// The delivery's forge bridge, indexer and teller are processes on their module's own broker credential, granted from +// the manifest alone (novox/hq ADR 0437 ยง4, issue 1243): `state` grants writing and watching the module's own buckets +// and streams, `reads` watching another module's bucket and never writing it, `consumes` hearing the named events, +// `invokes` calling the named tools, `uses` the seat operator-channel's by-caller subjects under the module's own name. +// +// The three manifests are copied from mesh-delivery's main at 6a23627426c6 (modules//module.json, read +// 2026-10-19), whole, so a change to one of them there that changes its grant is a change to read here. +const bridgeManifest = `{ + "module": "mesh-delivery-forge-bridge", + "version": "1", + "slug": "forge-bridge", + "consumes": [ + "gitea.pull.updated", + "gitea.pull.merged", + "gitea.pull.closed" + ], + "streams": [ + { "name": "forge", "events": ["forge.**"] } + ], + "state": [ + { "name": "heads", "history": 1 } + ], + "reads": [ + "mesh-delivery-gate.merges", + "mesh-delivery-gate.owed" + ], + "invokes": [ + "gitea.gitea_merge_pull_request", + "gitea.gitea_commit_status", + "gitea.gitea_delivery_view", + "gitea.gitea_note_append", + "gitea.gitea_add_comment", + "gitea.gitea_open_pulls", + "gitea.gitea_commit_trees" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-forge-bridge", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-forge-bridge", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-forge-bridge" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-forge-bridge", + "place": ".", + "unknown": "program" + }, + { + "id": "bridge", + "type": "process", + "name": "mesh-delivery-forge-bridge", + "artifact": "program", + "run": [ + "./mesh-delivery-forge-bridge" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-forge-bridge", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-forge-bridge", + "binary": "mesh-delivery-forge-bridge" + } + ] + } +}` + +const indexerManifest = `{ + "module": "mesh-delivery-indexer", + "version": "1", + "slug": "indexer", + "consumes": [ + "mesh-delivery-forge-bridge.forge.**" + ], + "state": [ + { "name": "candidates", "history": 10 }, + { "name": "modules", "history": 20 } + ], + "reads": [ + "mesh-controller.builds", + "mesh-delivery-gate.source" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-indexer", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-indexer", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-indexer" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-indexer", + "place": ".", + "unknown": "program" + }, + { + "id": "indexer", + "type": "process", + "name": "mesh-delivery-indexer", + "artifact": "program", + "run": [ + "./mesh-delivery-indexer" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-indexer", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-indexer", + "binary": "mesh-delivery-indexer" + } + ] + } +}` + +const tellerManifest = `{ + "module": "mesh-delivery-teller", + "version": "1", + "slug": "teller", + "state": [ + { "name": "answers", "history": 5 } + ], + "reads": [ + "mesh-delivery-gate.asks" + ], + "uses": [ + "operator-channel" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-teller", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-teller", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-teller" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-teller", + "place": ".", + "unknown": "program" + }, + { + "id": "teller", + "type": "process", + "name": "mesh-delivery-teller", + "artifact": "program", + "run": [ + "./mesh-delivery-teller" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-teller", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-teller", + "binary": "mesh-delivery-teller" + } + ] + } +}` + +// manifestOf is the part of a module's manifest that composes a bus account, as the controller reads it. +type manifestOf struct { + Module string `json:"module"` + Consumes []string `json:"consumes"` + Streams []struct { + Name string `json:"name"` + Events []string `json:"events"` + } `json:"streams"` + State []struct { + Name string `json:"name"` + History int `json:"history"` + } `json:"state"` + Reads []string `json:"reads"` + Invokes []string `json:"invokes"` + Uses []string `json:"uses"` + OwnSecrets map[string]json.RawMessage `json:"own-secrets"` + Claims []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + } `json:"claims"` +} + +// declaredOf is what the controller declares of a manifest to the broker (inventory's busrecords): its state as +// buckets, its streams under the module's name in the bus's spelling, its claims as held seats, the module streams +// its consumes name as heard, and no account when it declares no own secret `broker`. streamsOf are the module +// streams the other manifests of the test own, by module. +func declaredOf(t *testing.T, raw string, streamsOf map[string]ModuleStream) Declared { + t.Helper() + var m manifestOf + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatal(err) + } + d := Declared{Module: m.Module, Consumes: m.Consumes, Reads: m.Reads, Invokes: m.Invokes} + if _, reads := m.OwnSecrets["broker"]; !reads { + d.NoAccount = true + } + for _, s := range m.State { + d.State = append(d.State, Bucket{Module: m.Module, Name: s.Name, History: s.History}) + } + for _, s := range m.Streams { + var events []string + for _, e := range s.Events { + events = append(events, WirePattern(e)) + } + d.Streams = append(d.Streams, ModuleStream{Module: m.Module, Name: strings.ToUpper(s.Name), Events: events, + MaxAgeDays: 7, MaxMiB: 64}) + } + for _, c := range m.Claims { + d.Holds = append(d.Holds, Seat{Name: c.Name, Scope: c.Scope, Serves: c.Serves}) + } + for _, name := range m.Uses { + if name == "operator-channel" { + d.Uses = append(d.Uses, operatorChannelWithCondition()) + } + } + for _, c := range m.Consumes { + emitter, event, _ := strings.Cut(c, ".") + if s, owned := streamsOf[emitter]; owned && strings.HasPrefix(event, strings.ToLower(s.Name)+".") { + d.Hears = append(d.Hears, Heard{Stream: s.Name, Emitter: emitter, + Filters: []string{ModuleStreamSubject(emitter, WirePattern(event))}}) + d.HearsAStream = true + } + } + return d +} + +// canWrite says a principal may publish to a bucket's key; canRead says it may get one directly. +func canWrite(p Permissions, bucket, key string) bool { + return allowed(p.Publish, "$KV."+bucket+"."+key) +} +func canRead(p Permissions, bucket, key string) bool { + return allowed(p.Publish, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+key) +} + +// composed is every module's and every machine's tool runner's grants the broker composes for these records, +// as `@` and `runner@`. +func composed(t *testing.T, r Records) map[string]Permissions { + t.Helper() + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + out := map[string]Permissions{} + for _, u := range users { + switch u.Kind { + case KindNodeTools: + out["runner@"+u.Node] = perms(t, u) + case KindModule: + out[u.Module+"@"+u.Node] = perms(t, u) + } + } + return out +} + +// withPrefix is the patterns that start with any of the prefixes, sorted. +func withPrefix(patterns []string, prefixes ...string) []string { + var out []string + for _, p := range patterns { + for _, prefix := range prefixes { + if strings.HasPrefix(p, prefix) { + out = append(out, p) + break + } + } + } + sort.Strings(out) + return out +} + +// eventsSaid is the events a principal may publish, under a module's name or on a module's stream. +func eventsSaid(p Permissions) []string { + var out []string + for _, s := range withPrefix(p.Publish, "mesh.mod.", "mesh.stream.") { + if strings.Contains(s, ".event.") { + out = append(out, s) + } + } + return out +} + +// toolsCalled is the tools of one module a principal may call, by name. +func toolsCalled(p Permissions, module string) []string { + var out []string + for _, s := range withPrefix(p.Publish, "mesh.mod."+module+".tool.") { + if name := strings.TrimPrefix(s, "mesh.mod."+module+".tool."); !strings.Contains(name, ".") { + out = append(out, name) + } + } + return out +} + +// partsRecords is the three parts on one machine, beside the machine's tool runner. +func partsRecords(t *testing.T) Records { + t.Helper() + streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}} + return Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, + declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), declaredOf(t, tellerManifest, streams), + }}} +} + +// 1243/AC1: each part's credential, composed from its manifest, may do exactly what its declaration names: it writes +// its own buckets and stream, reads the buckets it reads (and cannot write them), hears what it consumes, calls what it +// invokes, and says what the seat it uses lets it say under its own name. +func TestEachPartsCredentialGrantsExactlyItsDeclaration(t *testing.T) { + all := composed(t, partsRecords(t)) + bridge, indexer, teller := all["mesh-delivery-forge-bridge@novox"], all["mesh-delivery-indexer@novox"], all["mesh-delivery-teller@novox"] + + // The forge bridge: state heads, the stream FORGE, reads merges and owed, invokes seven tools of the forge. + if got, want := withPrefix(bridge.Publish, "$KV."), []string{"$KV.mesh-delivery-forge-bridge_heads.>"}; !slices.Equal(got, want) { + t.Errorf("the bridge writes %v; want exactly %v", got, want) + } + if got, want := eventsSaid(bridge), []string{"mesh.stream.mesh-delivery-forge-bridge.event.forge.>"}; !slices.Equal(got, want) { + t.Errorf("the bridge says %v; want exactly %v", got, want) + } + for _, b := range []string{"merges", "owed"} { + if !canRead(bridge, "mesh-delivery-gate_"+b, "g.1") || canWrite(bridge, "mesh-delivery-gate_"+b, "g.1") { + t.Errorf("the bridge must read and not write the gate's %s", b) + } + } + for _, event := range []string{"pull.updated", "pull.merged", "pull.closed"} { + if !allowed(bridge.Subscribe, "mesh.mod.gitea.event."+event) { + t.Errorf("the bridge does not hear gitea's %s", event) + } + } + if allowed(bridge.Subscribe, "mesh.mod.gitea.event.pull.opened") { + t.Error("the bridge hears an event of gitea's it does not consume") + } + called := []string{"gitea_add_comment", "gitea_commit_status", "gitea_commit_trees", "gitea_delivery_view", + "gitea_merge_pull_request", "gitea_note_append", "gitea_open_pulls"} + if got := toolsCalled(bridge, "gitea"); !slices.Equal(got, called) { + t.Errorf("the bridge calls %v; want exactly %v", got, called) + } + if allowed(bridge.Publish, "mesh.mod.gitea.tool.gitea_delete_repo") { + t.Error("the bridge may call a tool of gitea's it does not invoke") + } + + // The indexer: state candidates and modules, reads the controller's builds and the gate's source, hears FORGE. + if got, want := withPrefix(indexer.Publish, "$KV."), []string{"$KV.mesh-delivery-indexer_candidates.>", "$KV.mesh-delivery-indexer_modules.>"}; !slices.Equal(got, want) { + t.Errorf("the indexer writes %v; want exactly %v", got, want) + } + if got := eventsSaid(indexer); len(got) != 0 { + t.Errorf("the indexer says %v; it declares no event", got) + } + for _, read := range [][2]string{{"mesh-controller_builds", "b.1"}, {"mesh-delivery-gate_source", "main"}} { + if !canRead(indexer, read[0], read[1]) || canWrite(indexer, read[0], read[1]) { + t.Errorf("the indexer must read and not write %s", read[0]) + } + } + if !allowed(indexer.Subscribe, "mesh.mod.mesh-delivery-forge-bridge.event.forge.merged") || + !allowed(indexer.Publish, "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-indexer") { + t.Errorf("the indexer does not hear FORGE through its own consumer: %v / %v", indexer.Subscribe, indexer.Publish) + } + if got := withPrefix(indexer.Publish, "mesh.seat."); len(got) != 0 { + t.Errorf("the indexer reaches the seats %v; it uses none", got) + } + + // The teller: state answers, reads the gate's asks, uses operator-channel under its own name. + if got, want := withPrefix(teller.Publish, "$KV."), []string{"$KV.mesh-delivery-teller_answers.>"}; !slices.Equal(got, want) { + t.Errorf("the teller writes %v; want exactly %v", got, want) + } + if !canRead(teller, "mesh-delivery-gate_asks", "merge.g.h") || canWrite(teller, "mesh-delivery-gate_asks", "merge.g.h") { + t.Error("the teller must read and not write the gate's asks") + } + for _, verb := range []string{"ask", "cancel", "condition"} { + if !allowed(teller.Publish, "mesh.seat.operator-channel.accept."+verb+".mesh-delivery-teller") { + t.Errorf("the teller may not say %s as itself", verb) + } + } + if !allowed(teller.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery-teller") { + t.Errorf("the teller does not hear its own decided events: %v", teller.Subscribe) + } +} + +// 1243/AC2: each part's credential is refused writing another module's bucket, publishing under another module's name +// and answering a verb subject of a seat. +func TestNoPartsCredentialReachesBeyondItsDeclaration(t *testing.T) { + all := composed(t, partsRecords(t)) + parts := []string{"mesh-delivery-forge-bridge", "mesh-delivery-indexer", "mesh-delivery-teller"} + buckets := map[string][]string{ + "mesh-delivery-forge-bridge": {"heads"}, "mesh-delivery-indexer": {"candidates", "modules"}, + "mesh-delivery-teller": {"answers"}, "mesh-delivery-gate": {"merges", "owed", "source", "asks", "deliveries"}, + "mesh-controller": {"builds", "plans"}, + } + for _, part := range parts { + p := all[part+"@novox"] + for owner, names := range buckets { + if owner == part { + continue + } + for _, name := range names { + if canWrite(p, owner+"_"+name, "some.key") { + t.Errorf("%s may write %s_%s", part, owner, name) + } + } + } + for _, other := range append(append([]string(nil), parts...), "mesh-delivery-gate", "mesh-controller", "gitea", "mesh-delivery") { + if other == part { + continue + } + for _, subject := range []string{"mesh.mod." + other + ".event.merged", "mesh.stream." + other + ".event.forge.merged"} { + if allowed(p.Publish, subject) { + t.Errorf("%s may publish %s, under %s's name", part, subject, other) + } + } + } + for _, verb := range []string{"mesh.seat.mesh-delivery.tool.deliveries", "mesh.seat.mesh-delivery.tool.release", + "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.operator-channel.tool.tell"} { + if allowed(p.Subscribe, verb) { + t.Errorf("%s answers the verb subject %s", part, verb) + } + } + } + // The teller says what it says as itself alone. + teller := all["mesh-delivery-teller@novox"] + for _, subject := range []string{"mesh.seat.operator-channel.accept.ask.mesh-delivery-gate", + "mesh.seat.operator-channel.accept.condition.mesh-delivery-indexer", "mesh.seat.operator-channel.accept.ask.*", + "mesh.seat.operator-channel.accept.ask.>"} { + if allowed(teller.Publish, subject) { + t.Errorf("the teller may publish %s", subject) + } + } +}