Hear a restricted stream only from a module's own account, never the tool runner's (issue 727)

The tool runner's bus account carried the heard grants, consumer and bind word of every module it runs, and every agent session on that machine reaches the bus as that account, so any session could pull a granted module's consumer on MAIL. Per ADR 0376 §1 the controller now composes a restricted stream's consumer, grants and bind word only for a module that declares runs-as; registration refuses a consumes of a restricted stream without runs-as (§2), and a grant that composes nothing is said in doctor (D-heard) and in the controller's log on every raise and send.
This commit is contained in:
jochen
2026-10-15 14:11:37 +02:00
parent f7928a2bf7
commit f75cd0239d
15 changed files with 377 additions and 21 deletions
+1 -1
View File
@@ -210,7 +210,7 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
for _, d := range p.Carries {
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
Streams: d.Streams, Hears: d.Hears})
Streams: d.Streams, Hears: ComposedHears(d)})
}
}
return out
+58 -1
View File
@@ -98,6 +98,62 @@ type Heard struct {
Stream string
Emitter string
Filters []string
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
Restricted bool
}
// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it
// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own
// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus
// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a
// consumer, its grants or its bind word composed there would hand the stream to any session. A module that
// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account
// the mesh keeps from agents.
func ComposedHears(d Declared) []Heard {
if d.RunsAs != "" {
return d.Hears
}
var out []Heard
for _, h := range d.Hears {
if !h.Restricted {
out = append(out, h)
}
}
return out
}
// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the
// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never
// silent.
type Withheld struct {
Node, Module, Stream, Emitter string
}
// Said is a withheld hearing in the operator's words: the module, the stream and the remedy.
func (w Withheld) Said() string {
return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+
"by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+
"for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)",
w.Module, w.Node, w.Emitter, w.Stream, w.Module)
}
// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module.
func WithheldOf(r Records) []Withheld {
var out []Withheld
for _, node := range sortedCopy(r.Nodes) {
for _, d := range r.Assigned[node] {
if d.RunsAs != "" {
continue
}
for _, h := range d.Hears {
if h.Restricted && len(h.Filters) > 0 {
out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter})
}
}
}
}
return out
}
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
@@ -177,7 +233,8 @@ func streamsIssuedFor(d Declared) []StreamIssued {
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
func HeardIssuedFor(node string, d Declared) []HeardIssued {
var out []HeardIssued
for _, h := range d.Hears {
// A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1).
for _, h := range ComposedHears(d) {
if len(h.Filters) == 0 {
continue
}
+106 -9
View File
@@ -14,9 +14,14 @@ var mailStream = ModuleStream{Module: "mesh-mail", Name: "MAIL",
MaxAgeDays: 7, MaxMiB: 1024, Restricted: true}
// mailHeard is what a reader granted the account `mesh` hears of it.
var mailHeard = Heard{Stream: "MAIL", Emitter: "mesh-mail", Filters: []string{
var mailHeard = Heard{Stream: "MAIL", Emitter: "mesh-mail", Restricted: true, Filters: []string{
"mesh.stream.mesh-mail.event.mail.mesh.message.added", "mesh.stream.mesh-mail.event.mail.mesh.message.changed"}}
// newsStream is a module's stream without a grant: heard by whoever asks (ADR 0363 §5), the tool runner included.
var newsStream = ModuleStream{Module: "news", Name: "NEWS", Events: []string{"story.**"}, MaxAgeDays: 7, MaxMiB: 16}
var newsHeard = Heard{Stream: "NEWS", Emitter: "news", Filters: []string{"mesh.stream.news.event.story.>"}}
// A module's stream is its own: subjects EVENTS does not claim, its age and size, kept by limits (ADR 0363 §1, §2).
func TestAModuleStreamTakesSubjectsEventsDoesNot(t *testing.T) {
s := mailStream.Stream()
@@ -91,12 +96,14 @@ func TestTheProviderPublishesItsStreamAndOnlyAGrantedConsumerHearsIt(t *testing.
}
}
// A granted consumer has a durable consumer on the stream, with its granted subjects as filters, and an EVENTS
// consumer that hears what it gives up on delivered again (ADR 0363 §5, §7); the runtime carrying it may read both.
// A granted consumer of its own account has a durable consumer on the stream, with its granted subjects as
// filters, and an EVENTS consumer that hears what it gives up on delivered again (ADR 0363 §5, §7); a module of its
// own account is no module the runtime carries, so the runtime reads neither (ADR 0376 §1).
func TestAGrantedConsumerHasAConsumerOnTheStream(t *testing.T) {
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}},
"ace": {{Module: RuntimeModule}, {Module: "reader", Hears: []Heard{mailHeard}, HearsAStream: true}, {Module: "stranger", HearsAStream: true}},
"ace": {{Module: RuntimeModule}, {Module: "reader", RunsAs: "reader", Hears: []Heard{mailHeard}, HearsAStream: true},
{Module: "stranger", HearsAStream: true}},
}}
users, err := Users(records)
if err != nil {
@@ -118,22 +125,112 @@ func TestAGrantedConsumerHasAConsumerOnTheStream(t *testing.T) {
t.Fatalf("on EVENTS: %+v", onEvents)
}
for _, u := range users {
if u.Kind != KindNodeTools || u.Node != "ace" {
if u.Node != "ace" || (u.Kind != KindNodeTools && u.Module != "reader") {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
if !MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader") || !MayPublish(perms, "$JS.ACK.MAIL.ace_reader.1") {
t.Fatal("the runtime carrying the reader may not read its consumer on MAIL")
reads := MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader") && MayPublish(perms, "$JS.ACK.MAIL.ace_reader.1")
if u.Kind == KindNodeTools && reads {
t.Fatal("the runtime may read the consumer on MAIL of a module of its own account")
}
if u.Kind == KindModule && !reads {
t.Fatal("the reader's own account may not read its consumer on MAIL")
}
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") {
t.Fatal("the runtime may read a consumer no module it carries is granted")
t.Fatal("a consumer no module is granted may be read")
}
}
}
// **The tool runner never hears a restricted stream** (ADR 0376 §1, §2): a module it carries, granted part of
// MAIL, gets no consumer on MAIL, the tool runner's account no grant to ask about, pull or acknowledge one there,
// and its membership no word to bind one; a module of its own account granted the same part gets all three, on
// its own account. The grant that composes nothing is said. A stream without a grant is heard through the tool
// runner as before.
func TestTheToolRunnerNeverHearsARestrictedStream(t *testing.T) {
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, {Module: "news", Streams: []ModuleStream{newsStream}}},
"ace": {{Module: RuntimeModule},
{Module: "carried", Hears: []Heard{mailHeard, newsHeard}, HearsAStream: true},
{Module: "mailer", RunsAs: "mailer", Hears: []Heard{mailHeard}, HearsAStream: true}},
}}
users, err := Users(records)
if err != nil {
t.Fatal(err)
}
var onMail, onNews []string
for _, c := range ConsumersOf(users) {
switch c.Consumer.Stream {
case "MAIL":
onMail = append(onMail, c.Consumer.Name)
case "NEWS":
onNews = append(onNews, c.Consumer.Name)
}
}
if !slices.Equal(onMail, []string{"ace_mailer"}) {
t.Fatalf("consumers on MAIL: %v, want only the mailer's", onMail)
}
if !slices.Equal(onNews, []string{"ace_carried"}) {
t.Fatalf("consumers on NEWS: %v, want the carried module's as before", onNews)
}
for _, u := range users {
if u.Node != "ace" {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
switch {
case u.Kind == KindNodeTools:
for _, s := range perms.Publish {
if strings.Contains(s, ".MAIL.") {
t.Errorf("the tool runner's account may publish %s", s)
}
}
for _, s := range perms.Subscribe {
if strings.HasPrefix(s, "mesh.stream.mesh-mail.") {
t.Errorf("the tool runner's account may subscribe %s", s)
}
}
if !MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.NEWS.ace_carried") {
t.Error("the tool runner may no longer read the carried module's consumer on a stream without a grant")
}
case u.Kind == KindModule && u.Module == "carried":
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_carried") {
t.Error("a module without an account of its own may pull a consumer on MAIL")
}
case u.Kind == KindModule && u.Module == "mailer":
for _, s := range []string{"$JS.API.CONSUMER.INFO.MAIL.ace_mailer", "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_mailer",
"$JS.ACK.MAIL.ace_mailer.1"} {
if !MayPublish(perms, s) {
t.Errorf("the mailer's own account may not publish %s", s)
}
}
}
}
where := Placements{Nodes: map[string][]string{}}
carried := MembershipFor("ace", records.Assigned["ace"][1], where)
if !reflect.DeepEqual(carried.Hears, []HeardIssued{{Stream: "NEWS", Consumer: "ace_carried"}}) {
t.Fatalf("the carried module is told to bind %+v", carried.Hears)
}
mailer := MembershipFor("ace", records.Assigned["ace"][2], where)
if !reflect.DeepEqual(mailer.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_mailer"}}) {
t.Fatalf("the mailer is told to bind %+v", mailer.Hears)
}
withheld := WithheldOf(records)
if len(withheld) != 1 || withheld[0] != (Withheld{Node: "ace", Module: "carried", Stream: "MAIL", Emitter: "mesh-mail"}) {
t.Fatalf("withheld %+v", withheld)
}
if said := withheld[0].Said(); !strings.Contains(said, "carried on ace") || !strings.Contains(said, "MAIL") ||
!strings.Contains(said, "runs-as") {
t.Fatalf("said %q", said)
}
}
// The membership says where a module's stream events go and which consumers on module streams to bind
// (ADR 0363 §2, §8).
func TestTheMembershipNamesTheStreamAndWhatIsHeard(t *testing.T) {
@@ -144,7 +241,7 @@ func TestTheMembershipNamesTheStreamAndWhatIsHeard(t *testing.T) {
!slices.Equal(provider.Streams[0].Events, mailStream.Events) {
t.Fatalf("the provider's membership: %+v", provider.Streams)
}
reader := MembershipFor("ace", Declared{Module: "reader", Hears: []Heard{mailHeard}}, where)
reader := MembershipFor("ace", Declared{Module: "reader", RunsAs: "reader", Hears: []Heard{mailHeard}}, where)
if !reflect.DeepEqual(reader.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}) {
t.Fatalf("the reader's membership: %+v", reader.Hears)
}
+4 -3
View File
@@ -857,9 +857,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
for _, d := range p.Carries {
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
Streams: d.Streams, Hears: d.Hears}
// And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8).
pub = append(pub, heardGrants(own, d.Hears)...)
Streams: d.Streams, Hears: ComposedHears(d)}
// And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8) — never
// a restricted one, which only a module on an account of its own hears (ADR 0376 §1).
pub = append(pub, heardGrants(own, own.Hears)...)
if _, consumes := ConsumerFor(own); !consumes {
continue
}
+3 -1
View File
@@ -139,13 +139,15 @@ func Users(r Records) ([]Principal, error) {
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
Logs: logNames(d.Logs), Streams: d.Streams, Hears: d.Hears,
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d),
})
}
if runtimeHere {
var carried []Declared
for _, d := range r.Assigned[node] {
if d.RunsAs == "" {
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
d.Hears = ComposedHears(d)
carried = append(carried, d)
}
}