From 83a298e7e0309169e88474ffdfd7fa5604218b91 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 28 Sep 2026 04:14:47 +0200 Subject: [PATCH] A module serves every tool under its own name, and may answer Every module that served a tool was refused the subscription on the new bus: the grant listed tools from a manifest field no module fills, because the tools a module serves are what its code answers and a second copy of that list would be a second source of truth. The grant is now the module's own tool namespace; nothing else may subscribe it, a caller is still granted per tool by name, and a module may answer what it was asked. --- internal/broker/nats.go | 17 ++++++++----- internal/broker/nats_test.go | 34 ++++++++++++++++++-------- internal/broker/testdata/composed.conf | 8 +++--- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 492a111..90d8c54 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -266,9 +266,13 @@ func PermissionsFor(p Principal) (Permissions, error) { for _, e := range p.Emits { pub = append(pub, own+".event."+e) } - for _, t := range p.Serves { - sub = append(sub, own+".tool."+t) - } + // Every tool under its own name, not a list: the tools a module serves are what its code + // answers, and a second copy of that list in the manifest would be a second source of + // truth for the mesh to keep in step (2026-09-28: every module that served a tool was + // refused the subscription, because none had written the list twice). Nothing is given + // away — no other principal may subscribe this namespace, and a caller's authority is + // still granted per tool, by name, on the publish side. + sub = append(sub, own+".tool.>") // 2. What it consumes, by the emitter's own subject — an event is addressed to its // emitter, because the emitter's identity is the meaning (ADR 0118). @@ -348,9 +352,10 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{ Publish: pub, Subscribe: sub, - // Only something that serves is ever answering. A pure consumer is granted nothing here. - AllowResponses: p.Kind == KindModule && (len(p.Serves) > 0 || len(p.Holds) > 0) || - p.Kind == KindController, + // A module answers what it was asked — a tool call reaches it on its own namespace, so the + // authority is bounded by having been asked — and so does the controller. A node and a + // person are never asked anything, and are granted nothing here. + AllowResponses: p.Kind == KindModule || p.Kind == KindController, }, nil } diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index 6ba56b1..007292d 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -1,6 +1,7 @@ package broker import ( + "slices" "strings" "testing" ) @@ -89,17 +90,30 @@ func TestAnInboxIsScopedToItsOwner(t *testing.T) { } // A responder answers on the caller's inbox, which it has no permission for. allow_responses is -// what makes a scoped inbox workable at all — the authority is bounded by having been asked. -func TestOnlySomethingThatServesMayAnswer(t *testing.T) { - serving, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing", - Serves: []string{"status"}, PasswordHash: "x"}) - if !serving.AllowResponses { - t.Fatal("a module serving a tool cannot answer the caller's inbox") - } - consumer, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit", +// what makes a scoped inbox workable at all — the authority is bounded by having been asked. A +// module is asked on its own namespace and may answer; a node and a person are never asked. +func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) { + module, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit", Consumes: []string{"shop.order.placed"}, PasswordHash: "x"}) - if consumer.AllowResponses { - t.Fatal("a pure consumer was granted the right to answer, which nothing asked it to do") + if !module.AllowResponses { + t.Fatal("a module cannot answer a tool call on its own namespace") + } + node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"}) + if node.AllowResponses { + t.Fatal("a node was granted the right to answer, and nothing asks a node anything") + } +} + +// A module serves every tool under its own name, and no other module's. +func TestAModuleServesItsOwnNamespaceAndNoOthers(t *testing.T) { + p, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "gitea", PasswordHash: "x"}) + if !slices.Contains(p.Subscribe, "mesh.mod.gitea.tool.>") { + t.Fatalf("a module may not serve its own tools: %v", p.Subscribe) + } + for _, s := range p.Subscribe { + if strings.HasPrefix(s, "mesh.mod.") && !strings.HasPrefix(s, "mesh.mod.gitea.") { + t.Fatalf("a module may subscribe another's namespace: %s", s) + } } } diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 1def85c..22fea23 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -38,16 +38,18 @@ accounts { } } { user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: { publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] } - subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.one_telegram", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.status", "mesh.seat.telegram-sender.accept.send"] } + subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.one_telegram", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: { publish: { allow: ["$JS.ACK.EVENTS.two_audit.>"] } - subscribe: { allow: ["_DELIVER.two_audit", "_INBOX.two.audit.>", "mesh.mod.shop.event.order.placed"] } + subscribe: { allow: ["_DELIVER.two_audit", "_INBOX.two.audit.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] } + allow_responses: { max: 1, ttl: "1m" } } } { user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: { publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] } - subscribe: { allow: ["_DELIVER.two_shop", "_INBOX.two.shop.>"] } + subscribe: { allow: ["_DELIVER.two_shop", "_INBOX.two.shop.>", "mesh.mod.shop.tool.>"] } + allow_responses: { max: 1, ttl: "1m" } } } ] } -- 2.54.0