Merge pull request 'Grant a module's bus state asked in requires to the accounts that run its code (issue 1966, recovery)' (#188) from fix/1966-requires-state into main
This commit was merged in pull request #188.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user