Let a module declare a stream of its own and restrict who hears it by a trusted config value (hq issue 718, ADR 0363)
mesh-mail's events must live on a stream of their own and reach only the modules the operator granted an account (ADR 0359 §2, §4). The controller now creates a module's declared stream, publishes its events on mesh.stream.<module>.event.<event>, and composes a consumer's durable consumer, filters and bus grants on a restricted stream only where its consumes asks and the provider's trusted value names it; a consumer that loses its grant has its consumer removed on the next assertion. Nothing changes while no manifest declares a stream.
This commit is contained in:
@@ -128,7 +128,13 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||
hearsAStream := false
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) > 0 {
|
||||
hearsAStream = true
|
||||
}
|
||||
}
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
|
||||
return Consumer{}, false
|
||||
}
|
||||
perms, err := PermissionsFor(p)
|
||||
@@ -140,11 +146,14 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// business, which is why this is a filter and not the whole list.
|
||||
var filters []string
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") {
|
||||
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
|
||||
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
|
||||
filters = append(filters, s)
|
||||
}
|
||||
}
|
||||
if len(filters) == 0 {
|
||||
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
|
||||
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
|
||||
if len(filters) == 0 && !hearsAStream {
|
||||
return Consumer{}, false
|
||||
}
|
||||
sort.Strings(filters)
|
||||
@@ -181,6 +190,13 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||
}
|
||||
seen[p.Node+"/"+p.Module] = true
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
|
||||
// And one on each module stream it hears (novox/hq ADR 0363 §5).
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
if p.Kind == KindModule {
|
||||
@@ -193,7 +209,8 @@ 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})
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||
Streams: d.Streams, Hears: d.Hears})
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -486,3 +486,25 @@ func (j *JetStream) LogStreams() ([]string, error) {
|
||||
}
|
||||
return out, lister.Err()
|
||||
}
|
||||
|
||||
// ConsumerNames is every durable consumer on a stream; none for a stream that does not exist yet.
|
||||
func (j *JetStream) ConsumerNames(stream string) ([]string, error) {
|
||||
if _, err := j.js.StreamInfo(stream); errors.Is(err, nats.ErrStreamNotFound) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for name := range j.js.ConsumerNames(stream) {
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoveConsumer deletes one durable consumer (novox/hq ADR 0363 §6): one the composition no longer asks for.
|
||||
func (j *JetStream) RemoveConsumer(stream, name string) error {
|
||||
if err := j.js.DeleteConsumer(stream, name); err != nil && !errors.Is(err, nats.ErrConsumerNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,6 +61,11 @@ type Membership struct {
|
||||
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||
// the runtime is the runtime's to refuse.
|
||||
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||
// Streams are this module's own streams (novox/hq ADR 0363 §2): an event one of them carries is published
|
||||
// on that stream's subject, never on Emits. Hears are the module streams it hears, each through a durable
|
||||
// consumer on that stream the runtime binds beside the one on EVENTS (§8).
|
||||
Streams []StreamIssued `json:"streams,omitempty"`
|
||||
Hears []HeardIssued `json:"hears,omitempty"`
|
||||
}
|
||||
|
||||
// Served is one address a tool is answered on.
|
||||
@@ -126,6 +131,8 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||
if !s.Kinded {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own stream on the bus (novox/hq ADR 0363).
|
||||
//
|
||||
// A module declares a stream as it declares a log: named locally, created by the controller from the
|
||||
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
|
||||
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
|
||||
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
|
||||
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
|
||||
// EVENTS. Pure; the controller's raise asserts what is composed here.
|
||||
|
||||
// moduleStreamPrefix starts every subject a module's own stream takes.
|
||||
const moduleStreamPrefix = "mesh.stream."
|
||||
|
||||
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
|
||||
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
|
||||
func ModuleStreamSubject(module, event string) string {
|
||||
return moduleStreamPrefix + module + ".event." + event
|
||||
}
|
||||
|
||||
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
|
||||
func WirePattern(local string) string {
|
||||
parts := strings.Split(local, ".")
|
||||
for i, p := range parts {
|
||||
if p == "**" {
|
||||
parts[i] = ">"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// A ModuleStream is one module's declared stream as the bus holds it.
|
||||
type ModuleStream struct {
|
||||
Module string
|
||||
// Name is its name on the bus, `MAIL`.
|
||||
Name string
|
||||
// Events are the events it carries, as the bus spells them (WirePattern).
|
||||
Events []string
|
||||
MaxAgeDays int
|
||||
MaxMiB int
|
||||
// Restricted says a trusted setting of the module grants who hears it (ADR 0363 §4).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// Subjects are the subjects the stream takes.
|
||||
func (s ModuleStream) Subjects() []string {
|
||||
var out []string
|
||||
for _, e := range s.Events {
|
||||
out = append(out, ModuleStreamSubject(s.Module, e))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
|
||||
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
|
||||
// answers for anything older.
|
||||
func (s ModuleStream) Stream() Stream {
|
||||
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
|
||||
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
|
||||
if s.Restricted {
|
||||
why += "; heard only by the consumers its trusted setting grants"
|
||||
}
|
||||
return Stream{
|
||||
Name: s.Name,
|
||||
Subjects: s.Subjects(),
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
|
||||
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
|
||||
Why: why,
|
||||
}
|
||||
}
|
||||
|
||||
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
|
||||
func ModuleStreams(declared []ModuleStream) []Stream {
|
||||
sorted := append([]ModuleStream(nil), declared...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
out := make([]Stream, 0, len(sorted))
|
||||
for _, s := range sorted {
|
||||
out = append(out, s.Stream())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
|
||||
// within the stream and, for a restricted stream, within what the provider's setting grants it
|
||||
// (ADR 0363 §5). Composed only with at least one subject.
|
||||
type Heard struct {
|
||||
Stream string
|
||||
Emitter string
|
||||
Filters []string
|
||||
}
|
||||
|
||||
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
|
||||
// on EVENTS, on that stream, with the subjects it was granted as its filters.
|
||||
func heardConsumer(p Principal, h Heard) Consumer {
|
||||
filters := append([]string(nil), h.Filters...)
|
||||
sort.Strings(filters)
|
||||
return Consumer{
|
||||
Name: consumerDurable(p),
|
||||
Stream: h.Stream,
|
||||
Filters: filters,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
|
||||
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
|
||||
"DEAD_LETTERS until a person delivers it again or drops it",
|
||||
}
|
||||
}
|
||||
|
||||
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
|
||||
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
|
||||
func heardGrants(p Principal, hears []Heard) []string {
|
||||
var out []string
|
||||
durable := consumerDurable(p)
|
||||
for _, h := range hears {
|
||||
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out,
|
||||
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
|
||||
"$JS.ACK."+h.Stream+"."+durable+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
|
||||
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
||||
var out []string
|
||||
for _, s := range streams {
|
||||
if s.Module != module {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.Subjects()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StreamIssued is one of a module's own streams as its membership names it (ADR 0363 §2): the events it
|
||||
// carries, as the bus spells them, and the subject an event of them is published on, `{event}` the event.
|
||||
type StreamIssued struct {
|
||||
Stream string `json:"stream"`
|
||||
Events []string `json:"events"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// HeardIssued is one module stream an assignment hears, as its membership names it (ADR 0363 §8): the
|
||||
// stream and the durable consumer on it the runtime binds.
|
||||
type HeardIssued struct {
|
||||
Stream string `json:"stream"`
|
||||
Consumer string `json:"consumer"`
|
||||
}
|
||||
|
||||
// streamsIssuedFor is every stream of its own a module's membership names.
|
||||
func streamsIssuedFor(d Declared) []StreamIssued {
|
||||
var out []StreamIssued
|
||||
for _, s := range d.Streams {
|
||||
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
|
||||
continue
|
||||
}
|
||||
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
|
||||
Subject: ModuleStreamSubject(d.Module, "{event}")})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
|
||||
return out
|
||||
}
|
||||
|
||||
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
|
||||
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
|
||||
type Pruner interface {
|
||||
ConsumerNames(stream string) ([]string, error)
|
||||
RemoveConsumer(stream, name string) error
|
||||
}
|
||||
|
||||
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
|
||||
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
|
||||
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
|
||||
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
|
||||
keep := map[string]bool{}
|
||||
for _, c := range wanted {
|
||||
keep[c.Stream+"/"+c.Name] = true
|
||||
}
|
||||
var removed []string
|
||||
for _, s := range streams {
|
||||
names, err := p.ConsumerNames(s.Name)
|
||||
if err != nil {
|
||||
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
|
||||
}
|
||||
for _, n := range names {
|
||||
if keep[s.Name+"/"+n] {
|
||||
continue
|
||||
}
|
||||
if err := p.RemoveConsumer(s.Name, n); err != nil {
|
||||
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
|
||||
}
|
||||
removed = append(removed, s.Name+"/"+n)
|
||||
}
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// On a real bus: a module's stream is made beside EVENTS without a clash, keeps what the module publishes on
|
||||
// its own subjects, a granted consumer hears only its part, a narrowed grant narrows it, and a withdrawn one
|
||||
// is removed (novox/hq ADR 0363).
|
||||
//
|
||||
// go test ./internal/broker/ -run TestAModuleStreamOnARealBus
|
||||
func TestAModuleStreamOnARealBus(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
for _, s := range []Stream{MeshStreams()[3], mailStream.Stream()} {
|
||||
_ = js.js.DeleteStream(s.Name)
|
||||
if err := js.EnsureStream(s); err != nil {
|
||||
t.Fatalf("%s: %v", s.Name, err)
|
||||
}
|
||||
defer func(name string) { _ = js.js.DeleteStream(name) }(s.Name)
|
||||
}
|
||||
for _, account := range []string{"mesh", "work"} {
|
||||
if _, err := js.js.Publish(ModuleStreamSubject("mesh-mail", "mail."+account+".message.added"), []byte("{}")); err != nil {
|
||||
t.Fatalf("publishing %s's mail: %v", account, err)
|
||||
}
|
||||
}
|
||||
if info, _ := js.js.StreamInfo(EventsStream); info.State.Msgs != 0 {
|
||||
t.Fatalf("EVENTS kept %d of the module stream's events", info.State.Msgs)
|
||||
}
|
||||
|
||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
||||
c := heardConsumer(reader, mailHeard)
|
||||
if err := js.EnsureConsumer(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 1 {
|
||||
t.Fatalf("the reader granted `mesh` has %d pending, want 1", info.NumPending)
|
||||
}
|
||||
narrowed := c
|
||||
narrowed.Filters = []string{"mesh.stream.mesh-mail.event.mail.mesh.message.changed"}
|
||||
if err := js.EnsureConsumer(narrowed); err != nil {
|
||||
t.Fatalf("narrowing the grant: %v", err)
|
||||
}
|
||||
removed, err := PruneHeard(js, []ModuleStream{mailStream}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(removed, []string{"MAIL/ace_reader"}) {
|
||||
t.Fatalf("removed %v", removed)
|
||||
}
|
||||
if names, _ := js.ConsumerNames("MAIL"); len(names) != 0 {
|
||||
t.Fatalf("still on MAIL: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mailStream is mesh-mail's own stream as the bus holds it (novox/hq ADR 0363).
|
||||
var mailStream = ModuleStream{Module: "mesh-mail", Name: "MAIL",
|
||||
Events: []string{"mail.*.message.added", "mail.*.message.changed", "mail.*.message.removed"},
|
||||
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{
|
||||
"mesh.stream.mesh-mail.event.mail.mesh.message.added", "mesh.stream.mesh-mail.event.mail.mesh.message.changed"}}
|
||||
|
||||
// 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()
|
||||
if s.Name != "MAIL" || s.Retention != RetentionLimits || s.MaxAge != 7*24*60*60 || s.MaxBytes != 1024<<20 || s.DiscardNew {
|
||||
t.Fatalf("composed as %+v", s)
|
||||
}
|
||||
want := []string{"mesh.stream.mesh-mail.event.mail.*.message.added", "mesh.stream.mesh-mail.event.mail.*.message.changed",
|
||||
"mesh.stream.mesh-mail.event.mail.*.message.removed"}
|
||||
if !reflect.DeepEqual(s.Subjects, want) {
|
||||
t.Fatalf("subjects %v", s.Subjects)
|
||||
}
|
||||
for _, own := range MeshStreams() {
|
||||
for _, a := range own.Subjects {
|
||||
for _, b := range s.Subjects {
|
||||
if SubjectsOverlap(a, b) {
|
||||
t.Fatalf("%s claims %s, which overlaps %s of %s: the bus would refuse the stream", s.Name, b, a, own.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if WirePattern("mail.**") != "mail.>" {
|
||||
t.Fatal("`**` is spelled `>` on the wire")
|
||||
}
|
||||
}
|
||||
|
||||
// The provider publishes its stream's events on their subjects and nowhere on EVENTS for them; a consumer
|
||||
// granted a part hears it through its own consumer on the stream, and one not granted has nothing there
|
||||
// (ADR 0363 §2, §5).
|
||||
func TestTheProviderPublishesItsStreamAndOnlyAGrantedConsumerHearsIt(t *testing.T) {
|
||||
provider, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-mail",
|
||||
Streams: []ModuleStream{mailStream}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(provider, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may not publish its own stream's event")
|
||||
}
|
||||
if MayPublish(provider, "mesh.mod.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may publish its stream's event on EVENTS")
|
||||
}
|
||||
if MayPublish(provider, "mesh.stream.other.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may publish on another module's stream")
|
||||
}
|
||||
|
||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
||||
perms, err := PermissionsFor(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{"$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader", "$JS.API.CONSUMER.INFO.MAIL.ace_reader",
|
||||
"$JS.ACK.MAIL.ace_reader.1"} {
|
||||
if !MayPublish(perms, s) {
|
||||
t.Errorf("the granted reader may not publish %s", s)
|
||||
}
|
||||
}
|
||||
if !MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.mesh.message.added") ||
|
||||
MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the reader's subscription is not what it was granted")
|
||||
}
|
||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_other") {
|
||||
t.Fatal("the reader may pull another's consumer")
|
||||
}
|
||||
|
||||
stranger, err := PermissionsFor(Principal{Kind: KindModule, Node: "ace", Module: "stranger",
|
||||
Consumes: []string{"billing.paid"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if MayPublish(stranger, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") ||
|
||||
MaySubscribe(stranger, "mesh.stream.mesh-mail.event.mail.mesh.message.added") {
|
||||
t.Fatal("a module not granted reaches the stream")
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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}},
|
||||
}}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var onMail, onEvents []Consumer
|
||||
for _, c := range ConsumersOf(users) {
|
||||
switch c.Consumer.Stream {
|
||||
case "MAIL":
|
||||
onMail = append(onMail, c.Consumer)
|
||||
case EventsStream:
|
||||
onEvents = append(onEvents, c.Consumer)
|
||||
}
|
||||
}
|
||||
if len(onMail) != 1 || onMail[0].Name != "ace_reader" || !slices.Equal(onMail[0].Filters, mailHeard.Filters) {
|
||||
t.Fatalf("on MAIL: %+v", onMail)
|
||||
}
|
||||
if len(onEvents) != 1 || onEvents[0].Name != "ace_reader" || !slices.Equal(onEvents[0].Filters, []string{AgainFilter("ace_reader")}) {
|
||||
t.Fatalf("on EVENTS: %+v", onEvents)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind != KindNodeTools || u.Node != "ace" {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
where := Placements{Nodes: map[string][]string{}}
|
||||
provider := MembershipFor("novox", Declared{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, where)
|
||||
if len(provider.Streams) != 1 || provider.Streams[0].Stream != "MAIL" ||
|
||||
provider.Streams[0].Subject != "mesh.stream.mesh-mail.event.{event}" ||
|
||||
!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)
|
||||
if !reflect.DeepEqual(reader.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}) {
|
||||
t.Fatalf("the reader's membership: %+v", reader.Hears)
|
||||
}
|
||||
if none := MembershipFor("ace", Declared{Module: "stranger", HearsAStream: true}, where); none.Hears != nil {
|
||||
t.Fatalf("a module not granted is told to bind %+v", none.Hears)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePruner struct {
|
||||
on map[string][]string
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (f *fakePruner) ConsumerNames(stream string) ([]string, error) { return f.on[stream], nil }
|
||||
func (f *fakePruner) RemoveConsumer(stream, name string) error {
|
||||
f.removed = append(f.removed, stream+"/"+name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A consumer that lost its grant has its consumer on the stream removed by the next assertion (ADR 0363 §6).
|
||||
func TestAConsumerThatLostItsGrantIsRemoved(t *testing.T) {
|
||||
f := &fakePruner{on: map[string][]string{"MAIL": {"ace_reader", "ace_former"}}}
|
||||
wanted := []Consumer{{Name: "ace_reader", Stream: "MAIL"}, {Name: "ace_former", Stream: EventsStream}}
|
||||
removed, err := PruneHeard(f, []ModuleStream{mailStream}, wanted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(removed, []string{"MAIL/ace_former"}) || !slices.Equal(f.removed, removed) {
|
||||
t.Fatalf("removed %v", removed)
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -141,6 +141,10 @@ type Principal struct {
|
||||
KeyedReads []KeyedRead
|
||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||
Logs []string
|
||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||
// subjects it was granted (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
Hears []Heard
|
||||
|
||||
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
|
||||
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||
@@ -637,6 +641,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, e := range p.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
// And its own streams' events, on their own subjects (novox/hq ADR 0363 §2).
|
||||
pub = append(pub, ownStreamGrants(p.Module, p.Streams)...)
|
||||
// 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
|
||||
@@ -678,6 +684,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, subject)
|
||||
}
|
||||
|
||||
// 2a. What it hears of a module's own stream, as it was granted (novox/hq ADR 0363 §5): the subjects,
|
||||
// and its own consumer on that stream, asked about, pulled from and acknowledged.
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
sub = append(sub, h.Filters...)
|
||||
}
|
||||
pub = append(pub, heardGrants(p, p.Hears)...)
|
||||
|
||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||
// role is hearing what it announced, not taking part in it.
|
||||
for _, w := range p.Watches {
|
||||
@@ -800,6 +816,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, e := range d.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
// And its own streams' events (novox/hq ADR 0363 §2).
|
||||
pub = append(pub, ownStreamGrants(d.Module, d.Streams)...)
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
@@ -838,7 +856,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// consumer is still the controller's to make, from the module's own principal.
|
||||
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}
|
||||
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)...)
|
||||
if _, consumes := ConsumerFor(own); !consumes {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ type Declared struct {
|
||||
State []Bucket
|
||||
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
||||
Logs []Log
|
||||
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
// Hears are the module streams it hears on this machine, each with the subjects it was granted
|
||||
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
|
||||
// or not, so its membership is issued again when a grant changes.
|
||||
Hears []Heard
|
||||
HearsAStream bool
|
||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||
Reads []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
@@ -129,7 +136,7 @@ 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),
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: d.Hears,
|
||||
})
|
||||
}
|
||||
if runtimeHere {
|
||||
|
||||
Reference in New Issue
Block a user