G-delivery 5 (grants half): a read-only state is written by the process naming it alone

The bus module's spend record must not be writable by the tool runner. A state declared read-only gets read on
the module's own account and the tool runner carrying it (direct get, a watch's consumer) and no publish on its
$KV subjects; the process whose bus state names it writes it. The judge rule and the manifest field follow
with mesh-sdk's read-only.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 18:13:41 +01:00
parent 441b6d2922
commit 276886d8f6
4 changed files with 46 additions and 7 deletions
@@ -1097,7 +1097,8 @@ func theBusModulesProcessKeepsBusState(t *testing.T) {
Emits: []string{"created", "refused"}}},
Consumes: []string{"mesh-delivery-indexer.manifest-accepted"}, Uses: []Seat{{Name: "operator-channel", Accepts: []string{"condition"}, Emits: []string{"decided"}, ByCaller: []string{"condition", "decided"}}},
Streams: []ModuleStream{{Module: "nats", Name: "BROKER_EVENTS", Events: []string{"created", "refused"}, MaxAgeDays: 30, MaxMiB: 256}},
Processes: []ProcessPrincipal{{Process: "keeper", State: []string{"asked"}, Keeps: true}, {Process: "other"},
State: []Bucket{{Module: "nats", Name: "spent", ReadOnly: true}},
Processes: []ProcessPrincipal{{Process: "keeper", State: []string{"asked", "spent"}, Keeps: true}, {Process: "other"},
{Process: "nats-tools", Tools: true, Removes: []string{catalogue.RemovesEngineActs}}}},
{Module: "mod-x", Processes: []ProcessPrincipal{{Process: "sneak", Keeps: true}},
Streams: []ModuleStream{{Module: "mod-x", Name: "SECRETS", Events: []string{"sealed"}, Restricted: true}}},
@@ -1142,6 +1143,23 @@ func theBusModulesProcessKeepsBusState(t *testing.T) {
}
}
}
// Review round 2 (security F2): nats' read-only state spent is written by the process naming it in its bus state
// alone; the tool runner carrying nats, and nats' own account, read it (a direct get, a watch's consumer) and publish
// nothing on its $KV subjects, so neither writes nor deletes a spend record.
for _, sub := range []string{"$KV.nats_spent.ask-1"} {
if !MayPublish(keeper, sub) {
t.Errorf("the process naming spent may not write %s", sub)
}
}
for _, u := range []string{"a.node-tools"} {
p := perms[u]
if MayPublish(p, "$KV.nats_spent.ask-1") {
t.Errorf("%s may write or delete nats' read-only spent", u)
}
if u == "a.node-tools" && (!MayPublish(p, "$JS.API.DIRECT.GET.KV_nats_spent.$KV.nats_spent.ask-1") || !MayPublish(p, "$JS.API.CONSUMER.CREATE.KV_nats_spent.x")) {
t.Errorf("the tool runner may not read nats' spent")
}
}
for _, s := range []string{"EVENTS", "KV_mesh-grants_memberships", "KV_mod-x_other"} {
if MayPublish(keeper, "$JS.API.STREAM.CREATE."+s) || MayPublish(keeper, "$JS.API.STREAM.DELETE."+s) {
t.Errorf("the bus module's process may define %s", s)
@@ -189,6 +189,8 @@ type Principal struct {
// PerMachine is the local names of its state keyed by machine, reaching the key named for Node and
// no other; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260).
PerMachine []string
// ReadOnly is the local names of its state only its module's processes write: read, never written (review round 2).
ReadOnly []string
KeyedReads []KeyedRead
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
Logs []string
@@ -987,7 +989,7 @@ 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, Reads: p.Reads,
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}
pub = append(pub, stateGrants(kept)...)
// And it hears each of those buckets created, so one not on the bus at its start is bound when it appears
@@ -1095,7 +1097,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
// 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),
PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads, Nodes: p.Nodes})...)
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)...)
// 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.
@@ -4,6 +4,7 @@ import (
"fmt"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"slices"
"strings"
"time"
)
@@ -40,6 +41,9 @@ type Bucket struct {
// KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its
// own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends.
KeyTTL bool
// ReadOnly says only the module's processes that name it in their bus state write it (review round 2): the module's
// own account and the tool runner carrying it read it, and publish nothing on its subjects.
ReadOnly bool
// Whole is the bucket's name where it is not BucketName(Module, Name): one node's bucket of a state kept one bucket per
// node, the node-engine's compositions `composed-<node>` (ADR 0593 §6, EngineComposedBuckets).
Whole string
@@ -154,7 +158,9 @@ func stateGrants(a stateAccess) []string {
continue
}
read(bucket, "")
out = append(out, "$KV."+bucket+".>")
if !slices.Contains(a.ReadOnly, name) {
out = append(out, "$KV."+bucket+".>")
}
}
// **And it says what it keeps** (novox/hq ADR 0495 clause 2, issue 1790): a module keeping state announces it on its
// own event subject, from its code; refused, its readers are read as before.
@@ -255,12 +261,25 @@ type KeyedRead struct {
type stateAccess struct {
Module, Node string
Keeps, PerMachine []string
Reads []string
KeyedReads []KeyedRead
// ReadOnly are the names of Keeps only a process writes: read here, never written.
ReadOnly []string
Reads []string
KeyedReads []KeyedRead
// Nodes are every node, whose bucket of a per-node state (delivery.ComposedRead) a read of it reaches.
Nodes []string
}
// readOnlyNames is the local names of a module's buckets only its processes write.
func readOnlyNames(buckets []Bucket) []string {
var out []string
for _, b := range buckets {
if b.ReadOnly {
out = append(out, b.Name)
}
}
return out
}
// perMachineNames is the local names of a module's buckets keyed by machine.
func perMachineNames(buckets []Bucket) []string {
var out []string
@@ -281,7 +281,7 @@ func Users(r Records) ([]Principal, error) {
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
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,
})