Files
mesh-delivery/modules/mesh-grants/internal/grants/part.go
T
jschoubben b4af9cdb7a mesh-grants publishes only a first or an additive membership, and holds no lock across the bus (review of 1ff5049)
Composed without a root-free judgement, a membership would drop a kind's verified-sender and the
operator's channel approvals would stop being honoured; any loss now stays a shadow difference,
named. A tool no longer waits on a pass's round trip. compose-now says the controller serves no
verb to write a module's grant again.
2026-10-25 22:52:00 +01:00

343 lines
12 KiB
Go

package grants
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The part's own states, as its manifest declares them, and the keys of the two that hold one value.
const (
StateUserList = "user-list"
StateMemberships = "memberships"
StateAssertions = "assertions"
StateTakeover = "takeover"
KeyList = "list"
KeySwitch = "switch"
// KeyPass is the last pass's result, and KeyAsked a tool's ask to compose again now, both in StateUserList.
KeyPass = "pass"
KeyAsked = "asked"
)
// States is every state the part keeps.
var States = []string{StateAssertions, StateMemberships, StateTakeover, StateUserList}
// The events the part says.
const (
EventUserList = "user-list-changed"
EventMembership = "membership-changed"
)
// Emits is every event the part says.
var Emits = []string{EventMembership, EventUserList}
// Shadow is the switch's one state in this milestone: the bus node follows the controller's list.
const Shadow = "shadow"
// Store is the part's own state, by local name: the only thing the part writes.
type Store interface {
Get(state, key string) ([]byte, uint64, bool, error)
Put(state, key string, value []byte) (uint64, error)
}
// Emitter says one of the part's events.
type Emitter interface {
Emit(event string, body any) error
}
// UserListState is the value of `user-list` in shadow: the list the grants part composed (users and grants, no hashes),
// what the bus loaded, and how they differ. Inputs is the digest of all it says but At: a value is written again only
// when it changes.
type UserListState struct {
V int `json:"v"`
Mode string `json:"mode"`
Inputs string `json:"inputs"`
Digest string `json:"digest"`
Users int `json:"users"`
// LeftOut are the assignments the shadow list leaves out, each with why.
LeftOut []Gap `json:"left_out"`
Compare Comparison `json:"compare"`
Loaded *delivery.UserListWritten `json:"loaded,omitempty"`
BusNode string `json:"bus_node,omitempty"`
Gaps []Gap `json:"gaps"`
At time.Time `json:"at"`
}
func (s UserListState) inputs() string {
s.At, s.Inputs = time.Time{}, ""
raw, _ := json.Marshal(s)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
// Result is what one composition did.
type Result struct {
UserList UserListState `json:"user_list"`
Revision uint64 `json:"revision"`
Written bool `json:"written"`
Memberships int `json:"memberships"`
MembershipsWritten int `json:"memberships_written"`
Published []string `json:"published,omitempty"`
NotPublished []string `json:"not_published,omitempty"`
Assertions int `json:"assertions"`
AssertionsWritten int `json:"assertions_written"`
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.
const NothingTakesEffect = "memberships only (on the assignments stream, a first one or one that only adds to the last published; a loss stays a shadow difference); the user list, assertions and grants stay in shadow until take-over"
// Part is the grants part: it reads, calls and writes through these, and nothing else.
type Part struct {
Read Reader
Call Caller
Store Store
Emit Emitter
// Publish publishes memberships: the process's connection; nil in the tools, which ask the process instead.
Publish Publisher
Now func() time.Time
cache Manifests
mu sync.Mutex
last Result
}
func (p *Part) now() time.Time {
if p.Now == nil {
return time.Now().UTC()
}
return p.Now().UTC()
}
// Compose composes the user list, the memberships and the assertions from what the bus holds now, compares them, and
// writes each of its own keys whose value changed, saying each change. It never stops at a refusal: a read refused is
// a gap, a write refused is said in Errors, and the next event composes again.
func (p *Part) Compose() Result {
// No lock is held across a round trip on the bus: the reads, calls, publishes and writes below take none, and the
// lock is taken only to keep the result, so a tool answers while a pass waits on the bus.
res := Result{PublishedOrAsserted: NothingTakesEffect}
defer func() {
p.mu.Lock()
p.last = res
p.mu.Unlock()
}()
in := ReadInputs(p.Read, &p.cache)
recs, left := Records(in)
st := UserListState{V: 1, Mode: Shadow, LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode,
Gaps: append(append([]Gap(nil), in.Gaps...), MembershipGaps...), At: p.now()}
if st.LeftOut == nil {
st.LeftOut = []Gap{}
}
users, err := ComposeUsers(recs)
if err != nil {
res.Errors = append(res.Errors, "the shadow list could not be composed: "+err.Error())
}
st.Digest, st.Users = Digest(users), len(users)
loaded, err := LoadedUsers(p.Call)
st.Compare = Compare(users, loaded, Placed(in))
if err != nil {
st.Compare.Unread = err.Error()
}
st.Inputs = st.inputs()
res.UserList = st
if raw, rev, found, err := p.Store.Get(StateUserList, KeyList); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be read: %v", StateUserList, err))
} else {
var was UserListState
if found && json.Unmarshal(raw, &was) == nil && was.Inputs == st.Inputs {
res.Revision = rev
} else if data, err := json.Marshal(st); err == nil {
if rev, err := p.Store.Put(StateUserList, KeyList, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be written: %v", StateUserList, err))
} else {
res.Revision, res.Written = rev, true
p.say(&res, EventUserList, map[string]any{"digest": st.Digest, "revision": rev, "users": st.Users,
"differing": len(st.Compare.Differing()), "mode": Shadow})
}
}
}
ms := Memberships(recs)
res.Memberships = len(ms)
for _, key := range sortedKeys(ms) {
m, now := publish(p.Publish, ms[key])
switch {
case now:
res.Published = append(res.Published, key)
p.say(&res, EventMembership, map[string]any{"node": m.Node, "module": m.Module, "digest": m.Digest, "published": true})
case m.Published != MembershipUnchanged:
res.NotPublished = append(res.NotPublished, key+": "+m.Published)
}
data, _ := json.Marshal(m)
if p.changed(&res, StateMemberships, key, data) {
res.MembershipsWritten++
}
}
held, heldErr := HeldStreams(p.Call)
for _, module := range sortedKeys(in.Manifests) {
if !assigned(in, module) {
continue
}
a := Assert(module, in.Digests[module], in.Manifests[module], held, heldErr)
res.Assertions++
data, _ := json.Marshal(a)
if p.changed(&res, StateAssertions, module, data) {
res.AssertionsWritten++
}
}
if _, _, found, err := p.Store.Get(StateTakeover, KeySwitch); err == nil && !found {
data, _ := json.Marshal(Switch{V: 1, State: Shadow, Acts: []Act{}})
if _, err := p.Store.Put(StateTakeover, KeySwitch, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be written: %v", StateTakeover, err))
}
}
return res
}
// Last is the result of the last pass this part made, kept under the lock alone.
func (p *Part) Last() Result {
p.mu.Lock()
defer p.mu.Unlock()
return p.last
}
func assigned(in Inputs, module string) bool {
for _, ms := range in.Assigned {
for _, m := range ms {
if m == module {
return true
}
}
}
return false
}
// changed writes key when its value differs from the one held, and says whether it wrote.
func (p *Part) changed(res *Result, state, key string, data []byte) bool {
raw, _, found, err := p.Store.Get(state, key)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", state, key, err))
return false
}
if found && bytes.Equal(raw, data) {
return false
}
if _, err := p.Store.Put(state, key, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", state, key, err))
return false
}
return true
}
func (p *Part) say(res *Result, event string, body any) {
if p.Emit == nil {
return
}
if err := p.Emit.Emit(event, body); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("the event %s could not be said: %v", event, err))
}
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Switch is the value of `takeover`: which list the bus node follows, and every act on the switch.
type Switch struct {
V int `json:"v"`
State string `json:"state"`
Acts []Act `json:"acts"`
}
// Act is one take-over or hand-back asked, done or refused.
type Act struct {
Act string `json:"act"`
By string `json:"by"`
Why string `json:"why,omitempty"`
At time.Time `json:"at"`
Refused string `json:"refused,omitempty"`
}
// keptActs is how many acts the switch keeps, newest last.
const keptActs = 50
// Operator says whether a caller is the operator's credential: a person's own bus account (`person.<name>`), which no
// session of an agent holds (an agent calls as its machine's tool runner).
func Operator(caller string) bool {
name, ok := strings.CutPrefix(caller, "person.")
return ok && name != ""
}
// NotComposable is why take-over is refused in this milestone, whatever the lists say.
const NotComposable = "the enrolment users and the persons cannot be composed: they live only in the controller's store, and take-over waits until they have records on the bus (ADR 0547 clause 9, version 26)"
// record keeps an act on the switch; a refused write is said in the answer.
func (p *Part) record(a Act) string {
raw, _, found, err := p.Store.Get(StateTakeover, KeySwitch)
if err != nil {
return fmt.Sprintf("; the act is not recorded: %v", err)
}
s := Switch{V: 1, State: Shadow}
if found {
_ = json.Unmarshal(raw, &s)
}
s.Acts = append(s.Acts, a)
if len(s.Acts) > keptActs {
s.Acts = s.Acts[len(s.Acts)-keptActs:]
}
data, _ := json.Marshal(s)
if _, err := p.Store.Put(StateTakeover, KeySwitch, data); err != nil {
return fmt.Sprintf("; the act is not recorded: %v", err)
}
return ""
}
// TakeOver is the operator's switch to the grants part's list. It is refused without the operator's credential, while
// any user of the shadow list differs from the loaded list (but those both agree are gone), and in this milestone
// always, since the enrolment users and the persons cannot be composed. Every ask is recorded.
func (p *Part) TakeOver(caller, why string) error {
a := Act{Act: "take-over", By: caller, Why: why, At: p.now()}
if !Operator(caller) {
a.Refused = fmt.Sprintf("take-over is the operator's alone: %s is not a person's account", caller)
return fmt.Errorf("%s%s", a.Refused, p.record(a))
}
res := p.Compose()
var reasons []string
if c := res.UserList.Compare; !c.Equal() {
if c.Unread != "" {
reasons = append(reasons, "the loaded list could not be read: "+c.Unread)
}
if d := c.Differing(); len(d) > 0 {
reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list: %s", len(d), strings.Join(d, ", ")))
}
}
reasons = append(reasons, NotComposable)
a.Refused = "take-over refused: " + strings.Join(reasons, "; ")
return fmt.Errorf("%s%s", a.Refused, p.record(a))
}
// HandBack is the operator's switch back to the controller's list. Refused without the operator's credential, and
// while the part is in shadow, since the bus node follows the controller's list already. Every ask is recorded.
func (p *Part) HandBack(caller, why string) error {
a := Act{Act: "hand-back", By: caller, Why: why, At: p.now()}
if !Operator(caller) {
a.Refused = fmt.Sprintf("hand-back is the operator's alone: %s is not a person's account", caller)
return fmt.Errorf("%s%s", a.Refused, p.record(a))
}
a.Refused = "nothing to hand back: the grants part is in shadow and never took over, so the bus node follows the controller's list already"
return fmt.Errorf("%s%s", a.Refused, p.record(a))
}