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)
This commit is contained in:
@@ -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"]}],` +
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 `<module>.<stream, lower case>.<machine>` (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)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,10 @@
|
||||
{
|
||||
"name": "user-list",
|
||||
"history": 20
|
||||
},
|
||||
{
|
||||
"name": "withheld",
|
||||
"history": 5
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
|
||||
Reference in New Issue
Block a user