Grant a module's bus state asked in requires to the accounts that run its code (issue 1966, recovery) #188

Merged
mesh-tools merged 1 commits from fix/1966-requires-state into main 2026-11-09 12:56:35 +00:00
11 changed files with 188 additions and 11 deletions
+87
View File
@@ -3051,6 +3051,93 @@ func TestGrants(t *testing.T) {
}
})
// Issue 1966 (F2), the conformance row: an entry asked in `requires` is granted where its old section is. For a
// key-value entry and an object store, the old section and the new form compose the same subjects for the module's
// own account, its process naming it and the tool runner carrying it, and issue the same state; a stream is granted
// its own subjects (the bus module names them under mesh.bus.<module>.<local>). Another module's account is granted
// nothing of them, and a binding naming another module's entry is granted to nobody.
t.Run("a requires entry is granted where its old section is", func(t *testing.T) {
runner := buscompose.Declared{Module: "node-tools", ToolRunner: true, Serves: []string{"node_tools"}}
other := buscompose.Declared{Module: "mod-d", Serves: []string{"mod_d"}}
withC := func(c buscompose.Declared) buscompose.Records {
return buscompose.Records{Nodes: []string{"a"}, Assigned: map[string][]buscompose.Declared{"a": {runner, other, c}}}
}
proc := []buscompose.ProcessPrincipal{{Process: "p", State: []string{"kept"}}}
old := buscompose.Declared{Module: "mod-c", Serves: []string{"mod_c"}, Processes: proc,
State: []buscompose.Bucket{{Module: "mod-c", Name: "kept"}}, Objects: []buscompose.ObjectBucket{{Module: "mod-c", Name: "files", MaxMiB: 64}}}
kv := membership.BusBinding{Local: "kept", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "mod-c_kept"}
obj := membership.BusBinding{Local: "files", Storage: catalogue.StorageObjectStore, Template: catalogue.TemplateObjects, ObjectStore: "mod-c_files"}
seen := membership.BusBinding{Local: "seen", Storage: catalogue.StorageStream, Template: catalogue.TemplateEvents, Stream: "mod-c_seen", Subject: "mesh.bus.mod-c.seen"}
moved := buscompose.Declared{Module: "mod-c", Serves: []string{"mod_c"}, Processes: proc, Bindings: []membership.BusBinding{kv, obj}}
perms := func(r buscompose.Records) map[string]buscompose.Permissions {
users, err := buscompose.Users(r)
if err != nil {
t.Fatal(err)
}
out := map[string]buscompose.Permissions{}
for _, u := range users {
p, err := buscompose.PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
name := string(u.Kind) + " " + u.Module + " " + u.Process
p.Publish, p.Subscribe = slices.Compact(slices.Sorted(slices.Values(p.Publish))), slices.Compact(slices.Sorted(slices.Values(p.Subscribe)))
out[name] = p
}
return out
}
was, now := perms(withC(old)), perms(withC(moved))
kinds := map[buscompose.Kind]bool{}
for name, p := range was {
kinds[buscompose.Kind(strings.Fields(name)[0])] = true
if !slices.Equal(p.Publish, now[name].Publish) || !slices.Equal(p.Subscribe, now[name].Subscribe) {
t.Errorf("%s: the new form composes otherwise than the old section:\nold publish %v\nnew publish %v\nold subscribe %v\nnew subscribe %v",
name, p.Publish, now[name].Publish, p.Subscribe, now[name].Subscribe)
}
}
for _, k := range []buscompose.Kind{buscompose.KindModule, buscompose.KindProcess, buscompose.KindNodeTools} {
if !kinds[k] {
t.Errorf("no %s principal composed: %v", k, slices.Collect(maps.Keys(was)))
}
}
for _, subject := range []string{"$KV.mod-c_kept.x", "$O.mod-c_files.M.x", "$JS.API.STREAM.INFO.KV_mod-c_kept"} {
for name, p := range now {
if name == string(buscompose.KindModule)+" mod-c " || name == string(buscompose.KindNodeTools)+" node-tools " {
if !buscompose.MayPublish(p, subject) {
t.Errorf("%s may not publish %s", name, subject)
}
} else if strings.Contains(name, " mod-d ") && buscompose.MayPublish(p, subject) {
t.Errorf("another module's account %s may publish %s", name, subject)
}
}
}
// The same state issued: the old section's, and the binding's through mesh-sdk's one rule.
oldIssued := buscompose.MembershipFor("a", old, buscompose.Assignments{})
newIssued := buscompose.MembershipFor("a", moved, buscompose.Assignments{})
wantKept, _ := oldIssued.StateIssued("kept")
if gotKept, ok := newIssued.StateIssued("kept"); !ok || gotKept != wantKept || len(newIssued.Bindings) != 2 {
t.Errorf("the issued state differs: old %+v, new %+v (%v), bindings %+v", wantKept, gotKept, ok, newIssued.Bindings)
}
// A stream is granted its own subjects to the module and the runner; a binding naming another module's entry, to nobody.
streamed := moved
streamed.Bindings = append(slices.Clone(moved.Bindings), seen,
membership.BusBinding{Local: "theirs", Storage: catalogue.StorageKeyValue, Bucket: "mod-d_kept"},
membership.BusBinding{Local: "elsewhere", Storage: catalogue.StorageStream, Stream: "mod-c_elsewhere", Subject: "mesh.bus.mod-d.elsewhere"})
for name, p := range perms(withC(streamed)) {
owner := name == string(buscompose.KindModule)+" mod-c " || name == string(buscompose.KindNodeTools)+" node-tools "
for _, subject := range []string{"mesh.bus.mod-c.seen.x", "$JS.API.STREAM.MSG.GET.mod-c_seen"} {
if buscompose.MayPublish(p, subject) != owner {
t.Errorf("%s may publish %s: %v", name, subject, !owner)
}
}
for _, subject := range []string{"$KV.mod-d_kept.x", "$JS.API.STREAM.INFO.KV_mod-d_kept", "mesh.bus.mod-d.elsewhere.x"} {
if buscompose.MayPublish(p, subject) {
t.Errorf("%s may publish %s, another module's entry", name, subject)
}
}
}
})
// TLS-5 (A7's request; ADR 0615 §5): the tool runner, which consumes the owners' views events and keeps a findings state,
// gets its durable consumer on each views stream, made by the part, and its own findings bucket, as any manifest's.
t.Run("the tool runner's consumes of the owners' views get their consumers, and its findings state its grant", func(t *testing.T) {
@@ -7,6 +7,7 @@ import (
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// **The bus module's process has a bus account of its own** (novox/hq ADR 0595 §4, §5; design 25 § 12.2, § 12.3;
@@ -224,3 +225,43 @@ func withoutActing(served []string) []string {
}
return out
}
// boundGrants are a module's bus state asked in `requires`, as the grants of the account running its code take it
// (ADR 0595 §1, ADR 0596 §2, design 25 § 12.2; issue 1966): the local names of its key-value entries, granted as an old
// `state` is; the bus names of its object stores, granted as an old `objects` is; and what its streams (events, work
// queue, log, bare) are published and read on. A ledger has its own account (ledger.go) and is not among them.
type boundGrants struct {
States, Objects, Streams []string
}
// boundOf is one module's bindings as boundGrants, for its own account and the tool runner carrying it, through this one
// function. **Only the owner reaches it**: a binding is granted only where it names the module's own entry, its names on
// the bus being the mesh's `<module>_<local>` (BusStateStreams) and a stream's subjects under `mesh.bus.<module>.<local>`;
// a binding naming any other is granted nothing.
func boundOf(module string, bindings []membership.BusBinding) boundGrants {
var g boundGrants
for _, b := range bindings {
base := module + "_" + b.Local
if !safeSubject.MatchString(module) || !safeSubject.MatchString(b.Local) || strings.Contains(b.Local, "_") || strings.Contains(module, "_") {
continue
}
switch b.Storage {
case catalogue.StorageKeyValue:
if b.Bucket == base {
g.States = append(g.States, b.Local)
}
case catalogue.StorageObjectStore:
if b.ObjectStore == base {
g.Objects = append(g.Objects, base)
}
case catalogue.StorageStream:
if b.Stream != base || b.Subject != "mesh.bus."+module+"."+b.Local {
continue
}
g.Streams = append(g.Streams, b.Subject+".>",
"$JS.API.STREAM.INFO."+base, "$JS.API.STREAM.MSG.GET."+base, "$JS.API.DIRECT.GET."+base+".>",
"$JS.API.CONSUMER.CREATE."+base+".>", "$JS.API.CONSUMER.DELETE."+base+".>", "$JS.FC."+base+".>")
}
}
return g
}
@@ -58,6 +58,9 @@ func MembershipFor(node string, d Declared, where Assignments) Membership {
}
}
m.State = stateIssuedFor(d, node)
// Its bindings, from the source its grants are composed from (Declared.Bindings, issue 1966): one composition carries
// both, so the composed side and the pass's agree.
m.Bindings = d.Bindings
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
m.Streams = streamsIssuedFor(d)
m.Hears = HeardIssuedFor(node, d)
@@ -27,6 +27,7 @@ import (
mv "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// A Kind is what a principal is, which decides the shape of its authority rather than its
@@ -132,6 +133,9 @@ type Principal struct {
// Objects are the bus names of its module's own object buckets, and ReadsObjects those of others it reads (novox/hq
// ADR 0592 §5, §9).
Objects, ReadsObjects []string
// Bound are its module's bus state as the bus module created it (Declared.Bindings), for a KindModule principal: each
// entry granted as its old section's is (boundOf, issue 1966).
Bound []membership.BusBinding
// Nodes are every node the records know, for a read of a state kept one bucket per node (ComposedRead, ADR
// 0593 §6): set only where the principal or a module it carries reads one.
Nodes []string
@@ -996,14 +1000,17 @@ func PermissionsFor(p Principal) (Permissions, error) {
// 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and
// watched, its own written too.
kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State, PerMachine: p.PerMachine, ReadOnly: p.ReadOnly, Reads: p.Reads,
KeyedReads: p.KeyedReads, Nodes: p.Nodes}
// Its bus state asked in `requires` is granted as the old sections are (boundOf, ADR 0595 §1, issue 1966).
bound := boundOf(p.Module, p.Bound)
kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: append(slices.Clone(p.State), bound.States...), PerMachine: p.PerMachine,
ReadOnly: p.ReadOnly, Reads: p.Reads, KeyedReads: p.KeyedReads, Nodes: p.Nodes}
pub = append(pub, stateGrants(kept)...)
pub = append(pub, bound.Streams...)
// And it hears each of those buckets created, so one not on the bus at its start is bound when it appears
// (novox/hq issue 1790; mesh-sdk process.AwaitBucket).
sub = append(sub, stateCreatedHeard(kept)...)
// And its object buckets (novox/hq ADR 0592 §5, §9): its own written, others' its owners grant it read only.
pub = append(pub, objectGrants(p.Objects, p.ReadsObjects)...)
pub = append(pub, objectGrants(append(slices.Clone(p.Objects), bound.Objects...), p.ReadsObjects)...)
// And its logs (novox/hq ADR 0297): appended to and read directly, its own alone.
pub = append(pub, logGrants(p.Module, p.Logs)...)
pub = append(pub, readLogGrants(p.ReadLogs)...)
@@ -1103,9 +1110,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
// does not write another's bucket through it is the runtime's to keep, from the membership
// each assignment is issued, as it keeps each module's events under that module's own name.
for _, d := range p.Carries {
pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State),
// Its bus state asked in `requires` as its old sections' (boundOf, ADR 0595 §1, §9, issue 1966).
bound := boundOf(d.Module, d.Bindings)
pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: append(stateNames(d.State), bound.States...),
PerMachine: perMachineNames(d.State), ReadOnly: readOnlyNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads, Nodes: p.Nodes})...)
pub = append(pub, objectGrants(objectNames(d.Objects), d.ReadsObjects)...)
pub = append(pub, objectGrants(append(objectNames(d.Objects), bound.Objects...), d.ReadsObjects)...)
pub = append(pub, bound.Streams...)
// A bundle reads every node's compositions on the runtime's own connection (ADR 0593 §6), which hears a node's
// bucket made later by any stream's creation.
if slices.Contains(d.Reads, ComposedRead) && !slices.Contains(sub, familyCreatedAdvisory) {
@@ -2,6 +2,7 @@ package buscompose
import (
"fmt"
"slices"
"github.com/nats-io/nats.go/jetstream"
@@ -97,14 +98,15 @@ func objectNames(objects []ObjectBucket) []string {
func writesOwnObjects(p Principal, pattern string) bool {
switch p.Kind {
case KindModule:
for _, b := range p.Objects {
// Its own, from the old section or bound in `requires` (boundOf, issue 1966).
for _, b := range append(slices.Clone(p.Objects), boundOf(p.Module, p.Bound).Objects...) {
if pattern == "$O."+b+".>" {
return true
}
}
case KindNodeTools:
for _, d := range p.Carries {
for _, b := range objectNames(d.Objects) {
for _, b := range append(objectNames(d.Objects), boundOf(d.Module, d.Bindings).Objects...) {
if pattern == "$O."+b+".>" {
return true
}
@@ -10,6 +10,7 @@ import (
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// Every user the composed file should contain, derived from what the mesh knows.
@@ -109,6 +110,10 @@ type Declared struct {
// Ledgers are the local names of the ledgers it asks for in `requires` (catalogue.StorageLedger); each assignment of a
// module with any gets a ledger principal of its own (issue 1966 D5, ADR 0597 §9).
Ledgers []string
// Bindings are its bus state as the bus module created it (membership.BusBinding, ADR 0595 §1, §7), kept by the grants
// part from the bus module's `created`: the module's own account and the tool runner carrying it are granted each
// entry's subjects (boundOf, issue 1966), in the same pass that writes the binding into its membership.
Bindings []membership.BusBinding
}
// ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker
@@ -295,7 +300,7 @@ func Users(r Records) ([]Principal, error) {
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
PerMachine: perMachineNames(d.State), ReadOnly: readOnlyNames(d.State), KeyedReads: d.KeyedReads,
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
Objects: objectNames(d.Objects), ReadsObjects: d.ReadsObjects,
Objects: objectNames(d.Objects), ReadsObjects: d.ReadsObjects, Bound: d.Bindings,
})
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
@@ -190,6 +190,24 @@ func OwnHears(s Store, node string) ([]membership.HeardIssued, error) {
return m.Hears, nil
}
// withBound sets what the bus module said it created on in: the streams of every binding kept (Inputs.Bound) and the
// bindings themselves (Inputs.Bindings), read from the part's own state in one place for the pass and for its tools.
func (p *Part) withBound(in *Inputs) error {
bound, err := p.boundStreams()
if err != nil {
return err
}
in.Bound, in.Bindings = bound, map[string][]membership.BusBinding{}
for module := range bound {
binds, err := p.bindingsOf(module)
if err != nil {
return err
}
in.Bindings[module] = binds
}
return nil
}
// boundStreams are, by module, the streams on the bus of every binding kept (BoundKey): what the bus module's process
// may still define, an orphan's among them (buscompose.BusStateNames).
func (p *Part) boundStreams() (map[string][]string, error) {
@@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/membership"
"slices"
"sort"
"strings"
@@ -140,6 +141,9 @@ type Inputs struct {
// Bound is, by module, the streams of each bus-state entry the bus module said it created and has not dropped, from
// the part's own state memberships (bindings.go): set by the pass, never read from another part.
Bound map[string][]string
// Bindings are, by module, the bindings kept (bindings.go), in local-name order: set by the pass with Bound, never read
// from another part. The module's own account and the tool runner carrying it are granted each (issue 1966).
Bindings map[string][]membership.BusBinding
// Digests is each module's manifest digest, by module.
Digests map[string]string
// Fingerprints is the fingerprint of the new form the indexer's trunk value names for each module, by module: what a
+1 -3
View File
@@ -294,10 +294,8 @@ func (p *Part) Compose() Result {
p.Views.SayUnsaid()
in := p.inputs()
p.keepBrokers(in)
if bound, err := p.boundStreams(); err != nil {
if err := p.withBound(&in); err != nil {
res.Errors = append(res.Errors, err.Error())
} else {
in.Bound = bound
}
for _, g := range in.Refused {
res.Refused = append(res.Refused, g.Input+": "+g.Why)
@@ -96,6 +96,9 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) {
}
d.Hears, d.HearsAStream, d.Consumes = hearsOf(m, declared, node, values, d.Consumes)
d.Reads, d.ReadsObjects, d.ReadsRefused = readsOfObjects(m.Module, node, d.Reads, d.ReadsRefused, declared, values)
// Its bindings as kept (bindings.go): the same source its membership's `bindings` is written from in this pass,
// so a grant and its binding are written together, never one before the other (issue 1966, the kind of issue 249).
d.Bindings = in.Bindings[m.Module]
return d
}
d := declare(m)
@@ -111,6 +111,9 @@ func (p *Part) MembershipOf(node, module string) (any, error) {
return nil, errors.New("membership names a node and a module, or neither for every membership held back")
}
in := p.inputs()
// What the bus module said it created, as the pass reads it, so the composed side carries the grants and bindings the
// pass writes (issue 1966).
boundErr := p.withBound(&in)
recs, left := ListRecords(in)
notFree := WithRootFree(&recs, p.Call)
m, ok := Memberships(recs)[node+"."+module]
@@ -139,6 +142,9 @@ func (p *Part) MembershipOf(node, module string) (any, error) {
out["published"] = map[string]any{"revision": rev, "digest": digestOf(was), "membership": was}
}
out["last_pass"] = p.passOf(key)
if boundErr != nil {
out["bindings"] = "not readable: " + boundErr.Error()
}
return out, nil
}