diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index f6977bdc..6e49c15d 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -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) diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 6c25e558..e8e717d0 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -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. diff --git a/modules/mesh-grants/internal/buscompose/state.go b/modules/mesh-grants/internal/buscompose/state.go index 6bb69ab0..5e879a34 100644 --- a/modules/mesh-grants/internal/buscompose/state.go +++ b/modules/mesh-grants/internal/buscompose/state.go @@ -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-` (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 diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 5d521da9..b5450d65 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -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, })