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.
343 lines
12 KiB
Go
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))
|
|
}
|