From e564eaf75f304e4d6920af2a3b23b48d7802845f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 23:51:48 +0100 Subject: [PATCH] Grants: write the hearings it withholds to its state withheld on change, so the self-check's probe D-heard reads them from the bus and not the controller's store (issue 1955) --- modules/mesh-grants/grants_test.go | 28 +++++ modules/mesh-grants/internal/grants/part.go | 14 ++- .../mesh-grants/internal/grants/withheld.go | 101 ++++++++++++++++++ modules/mesh-grants/module.json | 4 + 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/withheld.go diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 53b8e9b..7a9725e 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -584,6 +584,34 @@ func TestGrants(t *testing.T) { } }) + t.Run("withheld: a hearing of a restricted stream in the tool runner is written on change only, and deleted once heard (issue 1955 L7)", func(t *testing.T) { + const mail = `{"module":"mesh-mail","version":"1","streams":[{"name":"mail","events":["mail.*.message.added"],"max-age-days":7,` + + `"grant":{"config":"grants","events":"mail.{key}.**"}}],"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},` + + `{"id":"grants","trusted":true,"type":"file","path":"${dir:state}/grants.json","mode":"0600","merge":"json","takes-settings":["grants"],"content":"{\"grants\": {}}"}]}` + b := fixture() + b.manifestOf("mesh-mail", mail) + b.manifestOf("reader", `{"module":"reader","version":"1","consumes":["mesh-mail.mail.box.message.added"]}`) + b.assign("a", "mesh-mail") + b.assign("a", "reader") + b.put(grants.ReadResolved, "a.mesh-mail.grants", map[string]any{"node": "a", "module": "mesh-mail", "key": "grants", "value": map[string]any{"box": []string{"reader"}}}) + p := partOn(b) + res := p.Compose() + const key = "reader.mail.a" + var h grants.WithheldHearing + if res.Withheld == nil || !slices.Equal(res.Withheld.Written, []string{key}) || json.Unmarshal(b.own[grants.StateWithheld][key], &h) != nil || + h.Module != "reader" || h.Node != "a" || h.Stream != "MAIL" || h.Emitter != "mesh-mail" || !strings.Contains(h.Said, "runs-as") { + t.Fatalf("withheld = %+v, stored %+v (errors %v), want %s written naming the remedy", res.Withheld, h, res.Errors, key) + } + if again := p.Compose(); again.Withheld == nil || len(again.Withheld.Written) != 0 || again.Withheld.Withheld != 1 { + t.Errorf("a second pass wrote %+v, want nothing (on change only)", again.Withheld) + } + b.manifestOf("reader", `{"module":"reader","version":"1","runs-as":"reader","resources":[{"id":"account","type":"user","name":"reader","shell":"/usr/bin/nologin","home":"/var/lib/reader"}],`+ + `"own-secrets":{"broker":"/var/lib/r/broker"},"secrets-owner":"reader","consumes":["mesh-mail.mail.box.message.added"]}`) + if heard := p.Compose(); heard.Withheld == nil || !slices.Equal(heard.Withheld.Deleted, []string{key}) || b.own[grants.StateWithheld][key] != nil { + t.Errorf("once reader runs as its own account the pass did %+v (errors %v), want %s deleted", heard.Withheld, heard.Errors, key) + } + }) + t.Run("root-free: the controller's word fills the composition, failing closed (issue 1945)", func(t *testing.T) { const router = `{"module":"messenger","version":"1","runs-as":"messenger","resources":[{"id":"account","type":"user","name":"messenger","shell":"/usr/bin/nologin","home":"/var/lib/messenger"}],"own-secrets":{"broker":"/var/lib/m/broker"},"secrets-owner":"messenger",` + `"seats":[{"name":"operator-channel","scope":"mesh","serves":["open"]},{"name":"channel","scope":"mesh","kinded":true,"accepts":["show"]}],` + diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index ca23375..38da1f4 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -34,7 +34,7 @@ const ( ) // States is every state the part keeps. -var States = []string{StateAccounts, StateAssertions, StateGrants, StateMemberships, StateTakeover, StateUserList} +var States = []string{StateAccounts, StateAssertions, StateGrants, StateMemberships, StateTakeover, StateUserList, StateWithheld} // The events the part says. const ( @@ -125,9 +125,11 @@ type Result struct { // Accounts is what the pass wrote of the wanted accounts the vault mints from (ADR 0561 §2). Accounts *AccountsWritten `json:"accounts,omitempty"` // Grants is what the pass wrote of its grants (grants.go). - Grants *GrantsWritten `json:"grants,omitempty"` - Errors []string `json:"errors,omitempty"` - PublishedOrAsserted string `json:"published_or_asserted"` + Grants *GrantsWritten `json:"grants,omitempty"` + // Withheld is what the pass wrote of the hearings it withholds (withheld.go). + Withheld *WithheldWritten `json:"withheld,omitempty"` + Errors []string `json:"errors,omitempty"` + PublishedOrAsserted string `json:"published_or_asserted"` } // NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships. @@ -279,6 +281,10 @@ func (p *Part) Compose() Result { if p.Store != nil { p.writeAccounts(&res, in, listRecs) } + // The hearings withheld of what runs, for the self-check's probe D-heard (withheld.go). + if p.Store != nil { + p.writeWithheld(&res, in, recs) + } list, n, leftOut, err := ComposeList(listRecs, in.Hashes) // The grants the assigner reads once the lease is handed away, in shadow until then (ADR 0547 §4, §5); after take-over // one that adds a permission only once the bus loaded a list that carries it (issue 774). diff --git a/modules/mesh-grants/internal/grants/withheld.go b/modules/mesh-grants/internal/grants/withheld.go new file mode 100644 index 0000000..6e7260b --- /dev/null +++ b/modules/mesh-grants/internal/grants/withheld.go @@ -0,0 +1,101 @@ +package grants + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" +) + +// The hearings the part withholds (novox/hq ADR 0376 §2; issue 1955 L7, the lead 2026-10-29): a module granted to hear a +// restricted stream that runs in its machine's tool runner (no runs-as) hears none of it, since nothing is composed for +// the account every session there shares. The part writes each such hearing to its state `withheld`, on change only, so the +// self-check's probe D-heard reads it from the bus in place of the controller's store. +// +// Layout: key `..` (the controller's condition id of it); value WithheldHearing. +const StateWithheld = "withheld" + +// WithheldHearing is one withheld hearing: the machine, the module, the restricted stream and its emitter, the plain +// words of it and the remedy (buscompose.Withheld.Said), and when the part first wrote it as it stands. +type WithheldHearing struct { + V int `json:"v"` + Node string `json:"node"` + Module string `json:"module"` + Stream string `json:"stream"` + Emitter string `json:"emitter"` + Said string `json:"said"` + At time.Time `json:"at"` +} + +// WithheldKey is a withheld hearing's key in StateWithheld. +func WithheldKey(w buscompose.Withheld) string { + return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node +} + +// WithheldWritten is what one pass did to StateWithheld. +type WithheldWritten struct { + Withheld int `json:"withheld"` + Written []string `json:"written,omitempty"` + Deleted []string `json:"deleted,omitempty"` + // Kept is set when a read of the pass failed: no key of a hearing no longer withheld is deleted then. + Kept string `json:"kept,omitempty"` +} + +// writeWithheld writes each withheld hearing of what runs whose key differs (its At aside), and deletes the key of one no +// longer withheld when every input of the pass was read. +func (p *Part) writeWithheld(res *Result, in Inputs, recs buscompose.Records) { + now := buscompose.WithheldOf(recs) + done := &WithheldWritten{Withheld: len(now)} + res.Withheld = done + want := map[string]WithheldHearing{} + for _, w := range now { + want[WithheldKey(w)] = WithheldHearing{V: 1, Node: w.Node, Module: w.Module, Stream: w.Stream, Emitter: w.Emitter, Said: w.Said()} + } + for _, key := range sortedKeys(want) { + h := want[key] + raw, _, found, err := p.Store.Get(StateWithheld, key) + if err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateWithheld, key, err)) + continue + } + if found { + var was WithheldHearing + if json.Unmarshal(raw, &was) == nil { + was.At = time.Time{} + if was == h { + continue + } + } + } + h.At = p.now() + data, _ := json.Marshal(h) + if _, err := p.Store.Put(StateWithheld, key, data); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateWithheld, key, err)) + continue + } + done.Written = append(done.Written, key) + } + held, err := p.Store.Keys(StateWithheld) + if err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be listed, so no hearing no longer withheld is deleted: %v", StateWithheld, err)) + return + } + if len(in.ReadFailures()) > 0 { + done.Kept = "a read of this pass failed, so no hearing no longer withheld is deleted" + return + } + sort.Strings(held) + for _, key := range held { + if _, still := want[key]; still { + continue + } + if err := p.Store.Delete(StateWithheld, key); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateWithheld, key, err)) + continue + } + done.Deleted = append(done.Deleted, key) + } +} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 2d25d3a..822b9e3 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -78,6 +78,10 @@ { "name": "user-list", "history": 20 + }, + { + "name": "withheld", + "history": 5 } ], "own-secrets": {