mesh/delivery merged: source.main moved to novox/mesh-delivery@a49fd9cc65f7
mesh/check checking 3192d89edfcb on a49fd9cc65f7
Review round 1 of issue 1945's fix found two ways the guard could let a loss through or hide one from the operator. A publish deny was never compared: grants.User carried no deny, so a widened allow that brings a new deny (a lost publish right) passed as an added right. The user now carries its deny; a deny the shadow adds is a loss, and while nats_users answers no deny for the loaded list, a user whose grants differ and whose shadow grant has a deny refuses, named as a deny the loaded list cannot be compared on. None of today's 41 differing users carries a deny (only the four node-tools users do, and they are the same in both lists). The ask said "takes none away" whenever rights were added, whatever else differed. Its words now name the added rights only when nothing else refuses (Refusals: unread list, Removing, a missing hash), and otherwise say what the yes would be refused for, naming the users; Decide uses the same Refusals. issue 1945
372 lines
14 KiB
Go
372 lines
14 KiB
Go
package grants
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
|
)
|
|
|
|
// 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"
|
|
// EventAsserted is said when the part created a bucket, log or stream a manifest declares (issue 1790).
|
|
EventAsserted = "asserted"
|
|
)
|
|
|
|
// Emits is every event the part says.
|
|
var Emits = []string{EventAsserted, EventMembership, EventUserList}
|
|
|
|
// 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"`
|
|
// Credentials says per user whether the vault holds its hash and how its line would differ (ADR 0547 clause 9).
|
|
Credentials Credentials `json:"credentials"`
|
|
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"`
|
|
// Asserted is each module whose declared streams this pass created, `<module> at <digest>: created …`, and
|
|
// NotAsserted each creation refused, `<module> <stream>: <why>` (issue 1790).
|
|
Asserted []string `json:"asserted,omitempty"`
|
|
NotAsserted []string `json:"not_asserted,omitempty"`
|
|
// Refused is every manifest the indexer holds that this pass could not read, `manifest of <module>: <why>`: its
|
|
// module counts as placed nowhere (issue 1945).
|
|
Refused []string `json:"refused,omitempty"`
|
|
// RootNotFree is each machine the composition needed a root-free word for and did not get one, `<machine>: not
|
|
// free, <why>`: a verified sender is composed as from no root-free machine there (issue 1945).
|
|
RootNotFree []string `json:"root_not_free,omitempty"`
|
|
// Sealed is the list sealed to the bus node as the key `user-list.sealed` names it (ADR 0547 clause 7).
|
|
Sealed *SealedState `json:"sealed,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.
|
|
const NothingTakesEffect = "memberships (on the assignments stream, unless one would leave a seat a placed module claims with no holder, or drop a capability its holder still asks for, which stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds 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
|
|
// Create creates a declared stream the bus lacks: the process's connection; nil in the tools, which ask the process.
|
|
Create Creator
|
|
// Asker asks the operator (take-over, hand-back): the process's client of the router; nil in the tools, which write
|
|
// the request and let the process ask it.
|
|
Asker Asker
|
|
// Logf logs what no state or condition says (an error of acting on an answer): the process's; nil in the tools.
|
|
Logf func(string, ...any)
|
|
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
|
|
Objects Objects
|
|
// Say says a condition to the operator: the process's; nil in the tools.
|
|
Say func(asks.Condition) error
|
|
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
|
|
Forget func(id string) error
|
|
Nonce func() string
|
|
// deciding is set while a take-over is decided, so the composition it makes asks and reconciles nothing.
|
|
deciding atomic.Bool
|
|
Now func() time.Time
|
|
|
|
cache Manifests
|
|
mu sync.Mutex
|
|
last Result
|
|
made creations
|
|
// askSeen is the revision of the last ask to compose (KeyAsked) this part acted on.
|
|
askSeen uint64
|
|
// heldSaid is the reason of the held-list condition last said (empty: none raised); heldKnown is whether one was said
|
|
// since the start, so the first pass after a start always says its state (a condition raised before a restart is cleared
|
|
// by it). holdMu guards both.
|
|
holdMu sync.Mutex
|
|
heldSaid string
|
|
heldKnown bool
|
|
}
|
|
|
|
// Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing
|
|
// streams again even where they were created at this manifest before.
|
|
type Ask struct {
|
|
By string `json:"by"`
|
|
At time.Time `json:"at"`
|
|
Assert string `json:"assert,omitempty"`
|
|
}
|
|
|
|
// askedAssert is the module a new ask names to assert by hand, read once per ask; empty when none.
|
|
func (p *Part) askedAssert() string {
|
|
raw, rev, found, err := p.Store.Get(StateUserList, KeyAsked)
|
|
if err != nil || !found || rev == p.askSeen {
|
|
return ""
|
|
}
|
|
p.askSeen = rev
|
|
var a Ask
|
|
_ = json.Unmarshal(raw, &a)
|
|
return a.Assert
|
|
}
|
|
|
|
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)
|
|
for _, g := range in.Refused {
|
|
res.Refused = append(res.Refused, g.Input+": "+g.Why)
|
|
}
|
|
recs, left := Records(in)
|
|
holdings := HoldingsOf(in)
|
|
res.RootNotFree = WithRootFree(&recs, p.Call)
|
|
st := UserListState{V: 1, Mode: p.SwitchState(), 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())
|
|
}
|
|
users = WithHashes(users, in.Hashes)
|
|
st.Digest, st.Users = Digest(users), len(users)
|
|
loaded, err := LoadedUsers(p.Call)
|
|
st.Compare = Compare(users, loaded, Placed(in)).Excusing(ExcusedBy(MultiHeld(recs)))
|
|
if err != nil {
|
|
st.Compare.Unread = err.Error()
|
|
}
|
|
st.Credentials = CredentialsOf(users, loaded, in.Hashes)
|
|
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})
|
|
}
|
|
}
|
|
}
|
|
// The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7).
|
|
if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil {
|
|
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
|
|
} else {
|
|
// The users the bus loaded: what a first list after take-over must keep where the part has not yet written one.
|
|
var bus, lost []string
|
|
if st.Compare.Unread == "" {
|
|
bus = names(loaded)
|
|
lost = holdings.LastHolders(holdings.ByUsers(loaded), holdings.ByUsers(users))
|
|
}
|
|
p.writeSealed(&res, list, n, leftOut, in, bus, lost)
|
|
}
|
|
ms := Memberships(recs)
|
|
res.Memberships = len(ms)
|
|
composed := make([]membership.Membership, 0, len(ms))
|
|
for _, m := range ms {
|
|
composed = append(composed, m.Membership)
|
|
}
|
|
seated := holdings.ByMemberships(composed...)
|
|
for _, key := range sortedKeys(ms) {
|
|
m, now := publish(p.Publish, ms[key], holdings, seated)
|
|
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)
|
|
forced := ""
|
|
if p.Create != nil {
|
|
forced = p.askedAssert()
|
|
}
|
|
for _, module := range sortedKeys(in.Manifests) {
|
|
if !assigned(in, module) {
|
|
continue
|
|
}
|
|
a := Assert(module, in.Digests[module], in.Manifests[module], held, heldErr)
|
|
// Created only where the bus was read and lacks it, never changed where it holds it (issue 1790).
|
|
if p.Create != nil && heldErr == nil {
|
|
if made := createMissing(p.Create, &p.made, &a, in.Manifests[module], forced == module); len(made) > 0 {
|
|
res.Asserted = append(res.Asserted, fmt.Sprintf("%s at %s: created %s", module, a.Manifest, strings.Join(made, ", ")))
|
|
p.say(&res, EventAsserted, map[string]any{"module": module, "manifest": a.Manifest, "created": made})
|
|
}
|
|
for _, why := range notCreated(a) {
|
|
res.NotAsserted = append(res.NotAsserted, module+" "+why)
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
if !p.deciding.Load() {
|
|
if err := p.RaiseRequested(res.UserList); err != nil {
|
|
res.Errors = append(res.Errors, err.Error())
|
|
}
|
|
if err := p.Reconcile(); err != nil {
|
|
res.Errors = append(res.Errors, err.Error())
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
func names(us []User) []string {
|
|
out := make([]string, 0, len(us))
|
|
for _, u := range us {
|
|
out = append(out, u.User)
|
|
}
|
|
return out
|
|
}
|