Test that the forge bridge's, indexer's and teller's credentials grant exactly their manifests (issue 1243)
ADR 0437 §4's check: the three manifests, copied from mesh-delivery's main, are composed through Users and PermissionsFor; each may do what it declares and is refused another module's bucket, another module's name and a seat's verb subject.
This commit is contained in:
@@ -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>/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 `<module>@<node>` and `runner@<node>`.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user