mesh-grants: take-over and hand-back are asked of the operator on their channel and run on their yes after the refusals; take-over no longer always refused; grants_take_over_state (issue 1945)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The three bus adapters for mesh-sdk go/asker on the part's own credential, as mesh-delivery-teller has them (copied: the
|
||||
// SDK's asker reaches the bus through a bundle's runtime; moving the adapters into the SDK is a change of its own). What
|
||||
// the account may do is the controller's composed grant for a module that uses the seat operator-channel: publish its own
|
||||
// ask and condition, hear its own decided event, and read the router's record of its own asks.
|
||||
|
||||
const recordWithin = 30 * time.Second
|
||||
|
||||
func publishVia(ctx context.Context, js jetstream.JetStream) func(subject string, body any, id string) error {
|
||||
return func(subject string, body any, id string) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var opts []jetstream.PublishOpt
|
||||
if id != "" {
|
||||
opts = append(opts, jetstream.WithMsgID(id))
|
||||
}
|
||||
_, err = js.Publish(ctx, subject, data, opts...)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id string) (*stdio.StateEntry, error) {
|
||||
return func(id string) (*stdio.StateEntry, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, recordWithin)
|
||||
defer cancel()
|
||||
msg, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_asks.$KV.asks."+module+"."+id, nil)
|
||||
if errors.Is(err, nats.ErrNoResponders) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status := msg.Header.Get("Status"); status == "404" || len(msg.Data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: id, Value: msg.Data}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func decidedVia(conn *nats.Conn, module string, logf func(string, ...any)) func(pattern string, handle func(stdio.Envelope) error) error {
|
||||
return func(pattern string, handle func(stdio.Envelope) error) error {
|
||||
_, err := conn.Subscribe(asks.DecidedSubject(module), func(m *nats.Msg) {
|
||||
if err := handle(stdio.Envelope{Key: pattern, Body: m.Data}); err != nil {
|
||||
logf("a warrant could not be handled and is read again from the router's record at the next start: %v", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func sayVia(ctx context.Context, js jetstream.JetStream, module string) func(c asks.Condition) error {
|
||||
return func(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject(module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := asks.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = js.Publish(ctx, subject, data)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
// created** (the operator, 2026-10-26 08:54; issue 1790), once per manifest, with the manifest's limits, on the event
|
||||
// that brought the manifest; one the bus holds is never changed. **The user list, the rest of the assertions and the
|
||||
// grants stay in shadow** until
|
||||
// the operator's take-over, which this milestone always refuses: they are composed, compared with what the bus loaded
|
||||
// the operator's take-over, asked on their channel and run on their yes after its refusals: they are composed, compared with what the bus loaded
|
||||
// and holds (through the bus module's own read tools) and kept in the part's own state, and take no effect.
|
||||
//
|
||||
// It holds no timer, and never exits for a refused read, write or publish: a refusal is said and tried again at the next
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-grants/internal/grants"
|
||||
@@ -117,6 +118,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
|
||||
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}}
|
||||
f.Watch = bus
|
||||
// The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on
|
||||
// the warrant once (mesh-sdk asker), after its refusals.
|
||||
client := &asker.Client{Module: account.Module, State: conn.State(grants.StateTakeover), Publish: publishVia(ctx, js),
|
||||
Record: recordVia(ctx, nc, account.Module), Subscribe: decidedVia(nc, account.Module, logger.Printf)}
|
||||
f.Part.Asker, f.Part.Say = client, sayVia(ctx, js, account.Module)
|
||||
if err := client.Listen(f.Part.Warranted, f.Part.Said); err != nil {
|
||||
logger.Printf("the operator's answers cannot be heard yet, so take-over and hand-back wait: %v", err)
|
||||
}
|
||||
f.Asked = func(wake func()) error {
|
||||
return conn.State(grants.StateUserList).Watch(grants.KeyAsked, func(c stdio.StateChange) error {
|
||||
if !c.Current {
|
||||
|
||||
@@ -97,20 +97,25 @@ func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
"changed, a difference is only said. Answers the comparison and the ask; call again to see what the bus holds.",
|
||||
Input: schema(map[string]string{"module": "the module"}, "module"),
|
||||
Run: func(a map[string]any) (any, error) { return p.AssertOne(grants.Answer(a, "module")) }},
|
||||
{Name: grants.ToolTakeOver, Description: "The operator's switch to the grants part's list (ADR 0547 clause 8), refused without the " +
|
||||
"operator's credential (a person's account), refused while any user of the shadow list differs from the loaded list (but " +
|
||||
"those placement no longer names), naming them; in this milestone always refused, since the enrolment users and the " +
|
||||
"persons cannot be composed. Every ask is recorded in the part's state takeover.",
|
||||
{Name: grants.ToolTakeOver, Description: "Ask the operator to switch the bus node to the grants part's list (ADR 0547 clause 8). " +
|
||||
"The operator's word is their yes on their channel (a warrant), never a caller's name: this tool only asks, once at a time. " +
|
||||
"After the yes, the switch is refused while the loaded list could not be read, a user differs between the lists " +
|
||||
"(an enrolment user or a person the part cannot compose among them), or the vault holds no hash for a user, and the " +
|
||||
"refusal is said to the operator naming the users. Every act is recorded in the part's state takeover.",
|
||||
Input: schema(map[string]string{"why": "why, kept with the act"}, "why"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return nil, p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
return p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolHandBack, Description: "The operator's switch back to the controller's list (ADR 0547 clause 8), refused without " +
|
||||
"the operator's credential; in shadow there is nothing to hand back, and it says so. Every ask is recorded.",
|
||||
{Name: grants.ToolHandBack, Description: "Ask the operator to switch the bus node back to the controller's list (ADR 0547 clause 8), " +
|
||||
"by their yes on their channel; in shadow there is nothing to hand back, and it says so. Every act is recorded.",
|
||||
Input: schema(map[string]string{"why": "why, kept with the act"}, "why"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return nil, p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " +
|
||||
"every act with what became of it (asked, switched, declined, expired, refused and why).",
|
||||
Input: schema(nil),
|
||||
Run: func(map[string]any) (any, error) { return p.TakeOverState() }},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -340,6 +342,8 @@ func TestGrants(t *testing.T) {
|
||||
// in create.go (issue 1790).
|
||||
publishOnly := filepath.Join("cmd", "mesh-grants", "publish.go")
|
||||
createOnly := filepath.Join("cmd", "mesh-grants", "create.go")
|
||||
// And the operator's asks and conditions, on the seat operator-channel's subjects, in asking.go (ADR 0547 clause 8).
|
||||
asksOnly := filepath.Join("cmd", "mesh-grants", "asking.go")
|
||||
files := 0
|
||||
for _, root := range []string{"cmd", "internal"} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
@@ -348,8 +352,11 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
files++
|
||||
src, err := os.ReadFile(path)
|
||||
if path != publishOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("%s publishes: only publish.go publishes, and only memberships", path)
|
||||
if path != publishOnly && path != asksOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("%s publishes: only publish.go publishes memberships, and asking.go the operator's asks", path)
|
||||
}
|
||||
if path == asksOnly && (strings.Count(string(src), ".Publish(") != 2 || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("asking.go publishes other than an ask, a cancel and a condition, through js.Publish twice")
|
||||
}
|
||||
if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 {
|
||||
t.Errorf("publish.go publishes other than one membership")
|
||||
@@ -620,9 +627,9 @@ func TestGrants(t *testing.T) {
|
||||
!slices.Contains(cr.LeftOut, "b.mod-y") {
|
||||
t.Errorf("a hash heard = %+v", after.UserList.Credentials)
|
||||
}
|
||||
err := p.TakeOver("person.operator", "hashes")
|
||||
if err == nil || !strings.Contains(err.Error(), "no hash for") || strings.Contains(err.Error(), "a.mod-x,") {
|
||||
t.Errorf("take-over = %v, want the users with no hash named", err)
|
||||
why := strings.Join(p.Decide(), "; ")
|
||||
if !strings.Contains(why, "no hash for") || strings.Contains(why, "a.mod-x,") {
|
||||
t.Errorf("take-over is refused for %q, want the users with no hash named", why)
|
||||
}
|
||||
for state, keys := range b.own {
|
||||
for k, v := range keys {
|
||||
@@ -734,54 +741,118 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
|
||||
b.loaded = append(b.loaded, grants.User{User: "b.gone"})
|
||||
fa := &fakeAsker{}
|
||||
var said []asks.Condition
|
||||
p.Asker, p.Say = fa, func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
warrant := func(id string) asks.Warrant { return asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen} }
|
||||
last := func() grants.Act {
|
||||
raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch)
|
||||
var s grants.Switch
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
if s.Open != "" && s.Acts[len(s.Acts)-1].Said != "asked" {
|
||||
t.Errorf("an ask is open and its act is not asked: %+v", s)
|
||||
}
|
||||
return s.Acts[len(s.Acts)-1]
|
||||
}
|
||||
asked := func(act string) string {
|
||||
t.Helper()
|
||||
var res struct{ Asked string }
|
||||
var err error
|
||||
var out any
|
||||
if act == "take-over" {
|
||||
out, err = p.TakeOver("shanks.node-tools", "a row")
|
||||
} else {
|
||||
out, err = p.HandBack("shanks.node-tools", "a row")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", act, err)
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
return res.Asked
|
||||
}
|
||||
yes, no := asks.Option{ID: "yes"}, asks.Option{ID: "no"}
|
||||
for _, row := range []struct {
|
||||
name, act, caller string
|
||||
says, never []string
|
||||
name, act string
|
||||
option asks.Option
|
||||
setup func()
|
||||
said, never []string // the refusal's words and the users it must not name
|
||||
state string
|
||||
outcome string
|
||||
}{
|
||||
{"take-over without the operator's credential", "take-over", "shanks.node-tools", []string{"operator's alone"}, nil},
|
||||
{"hand-back without the operator's credential", "hand-back", "unproven", []string{"operator's alone"}, nil},
|
||||
{"take-over while a user differs names it", "take-over", "person.operator", []string{"users differ", "b.mod-x", grants.NotComposable}, []string{"b.gone"}},
|
||||
{"hand-back in shadow", "hand-back", "person.operator", []string{"nothing to hand back"}, nil},
|
||||
{"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused"},
|
||||
{"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined"},
|
||||
{"a yes with the lists agreeing and every hash held switches", "take-over", yes, func() {
|
||||
b.states[grants.ReadAssignments] = nil
|
||||
b.assign("a", "nats")
|
||||
b.assign("a", "mod-x")
|
||||
b.assign("b", "mod-y")
|
||||
b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"})
|
||||
for _, u := range shadowOf(t, b) {
|
||||
b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1})
|
||||
}
|
||||
}, nil, nil, grants.TookOver, "switched"},
|
||||
{"a yes to hand back switches back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "switched"},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
var err error
|
||||
if row.act == "take-over" {
|
||||
err = p.TakeOver(row.caller, "a row")
|
||||
} else {
|
||||
err = p.HandBack(row.caller, "a row")
|
||||
row.setup()
|
||||
before := len(fa.asks)
|
||||
id := asked(row.act)
|
||||
if _, err := p.TakeOver("shanks.node-tools", "again"); err == nil || !strings.Contains(err.Error(), "already asked") {
|
||||
t.Errorf("a second ask while one is open = %v", err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("%s by %s was not refused", row.act, row.caller)
|
||||
p.Compose()
|
||||
p.Compose()
|
||||
if len(fa.asks) != before+1 || fa.asks[before].ID != id {
|
||||
t.Fatalf("the operator was asked %d times, want once: %+v", len(fa.asks)-before, fa.asks)
|
||||
}
|
||||
for _, s := range row.says {
|
||||
if !strings.Contains(err.Error(), s) {
|
||||
t.Errorf("refusal %q does not say %q", err, s)
|
||||
ask := fa.asks[before]
|
||||
if err := ask.Check(time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Errorf("the router would refuse the ask: %v", err)
|
||||
}
|
||||
if yesOpt, ok := ask.Option("yes"); !ok || yesOpt.Level != asks.Approve || !strings.HasPrefix(yesOpt.Binds, "sha256:") {
|
||||
t.Errorf("the yes does not approve and bind its act: %+v", yesOpt)
|
||||
}
|
||||
said = nil
|
||||
if err := p.Warranted(row.option, warrant(id)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := last()
|
||||
if l.Said != row.outcome || l.Ask != id || p.SwitchState() != row.state {
|
||||
t.Errorf("the act = %+v, the switch %s; want %s and %s", l, p.SwitchState(), row.outcome, row.state)
|
||||
}
|
||||
for _, w := range row.said {
|
||||
if !strings.Contains(l.Refused, w) || (row.outcome == "refused" && (len(said) != 1 || !strings.Contains(said[0].Explanation, w))) {
|
||||
t.Errorf("the refusal %q (said %v) does not say %q", l.Refused, said, w)
|
||||
}
|
||||
}
|
||||
for _, s := range row.never {
|
||||
if strings.Contains(err.Error(), s) {
|
||||
t.Errorf("refusal %q names %q", err, s)
|
||||
for _, w := range row.never {
|
||||
if strings.Contains(l.Refused, w) {
|
||||
t.Errorf("the refusal names %q", w)
|
||||
}
|
||||
}
|
||||
raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch)
|
||||
var s grants.Switch
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
last := s.Acts[len(s.Acts)-1]
|
||||
if s.State != grants.Shadow || last.Act != row.act || last.By != row.caller || last.Refused == "" {
|
||||
t.Errorf("the act is not recorded: %+v", s)
|
||||
if row.outcome == "switched" && len(said) != 0 {
|
||||
t.Errorf("a switch said a refusal: %v", said)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("take-over with only a user placement no longer names is refused for want of records alone", func(t *testing.T) {
|
||||
b.states[grants.ReadAssignments] = nil
|
||||
b.assign("a", "nats")
|
||||
b.assign("a", "mod-x")
|
||||
b.assign("b", "mod-y")
|
||||
b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"})
|
||||
err := p.TakeOver("person.operator", "lists agree")
|
||||
if err == nil || strings.Contains(err.Error(), "differ") || !strings.Contains(err.Error(), grants.NotComposable) {
|
||||
t.Errorf("take-over = %v, want refused for the enrolment users and persons alone", err)
|
||||
t.Run("hand-back in shadow is refused at once, with nothing asked", func(t *testing.T) {
|
||||
n := len(fa.asks)
|
||||
if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.Contains(err.Error(), "nothing to hand back") {
|
||||
t.Errorf("hand-back in shadow = %v", err)
|
||||
}
|
||||
p.Compose()
|
||||
if len(fa.asks) != n {
|
||||
t.Errorf("a refused hand-back asked the operator")
|
||||
}
|
||||
})
|
||||
t.Run("an ask that ends with no answer is closed and can be asked again", func(t *testing.T) {
|
||||
id := asked("take-over")
|
||||
p.Said(asker.Recorded, asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired}, nil)
|
||||
if l := last(); l.Said != string(asks.OutcomeExpired) {
|
||||
t.Errorf("the act = %+v", l)
|
||||
}
|
||||
asked("take-over")
|
||||
})
|
||||
|
||||
t.Run("only memberships published, nothing asserted", func(t *testing.T) {
|
||||
@@ -1092,6 +1163,10 @@ func TestGrants(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
type fakeAsker struct{ asks []asks.Ask }
|
||||
|
||||
func (f *fakeAsker) Ask(a asks.Ask) error { f.asks = append(f.asks, a); return nil }
|
||||
|
||||
func nonNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// state it takes effect in two things alone, each by a grant the operator allowed: it publishes memberships (2026-10-25
|
||||
// 22:31), and creates a bucket, log or stream a manifest declares that the bus lacks (2026-10-26 08:54, issue 1790). It
|
||||
// changes no stream the bus holds, writes no grant and no user list the bus node reads, until the operator's take-over,
|
||||
// which this milestone always refuses.
|
||||
// which the operator asks on their channel and the part runs on their yes, after its refusals.
|
||||
package grants
|
||||
|
||||
import (
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -42,9 +42,6 @@ const (
|
||||
// Emits is every event the part says.
|
||||
var Emits = []string{EventAsserted, 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)
|
||||
@@ -121,7 +118,12 @@ type Part struct {
|
||||
Publish Publisher
|
||||
// Create creates a declared stream the bus lacks: the process's connection; nil in the tools, which ask the process.
|
||||
Create Creator
|
||||
Now func() time.Time
|
||||
// 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
|
||||
// Say says a condition to the operator: the process's; nil in the tools.
|
||||
Say func(asks.Condition) error
|
||||
Now func() time.Time
|
||||
|
||||
cache Manifests
|
||||
mu sync.Mutex
|
||||
@@ -176,7 +178,7 @@ func (p *Part) Compose() Result {
|
||||
}
|
||||
recs, left := Records(in)
|
||||
res.RootNotFree = WithRootFree(&recs, p.Call)
|
||||
st := UserListState{V: 1, Mode: Shadow, LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode,
|
||||
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{}
|
||||
@@ -259,6 +261,9 @@ func (p *Part) Compose() Result {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be written: %v", StateTakeover, err))
|
||||
}
|
||||
}
|
||||
if err := p.RaiseRequested(); err != nil {
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -314,101 +319,3 @@ func sortedKeys[V any](m map[string]V) []string {
|
||||
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, ", ")))
|
||||
}
|
||||
}
|
||||
cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone
|
||||
var unhashed []string
|
||||
for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) {
|
||||
if !slices.Contains(gone, u) {
|
||||
unhashed = append(unhashed, u)
|
||||
}
|
||||
}
|
||||
if len(unhashed) > 0 {
|
||||
sort.Strings(unhashed)
|
||||
reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s",
|
||||
len(unhashed), strings.Join(unhashed, ", ")))
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The switch between the controller's user list and the grants part's (ADR 0547 clause 8). The operator's word is a
|
||||
// warrant: the operator's answer to an ask on a channel that proves who answered, never a caller's name, since an
|
||||
// operator's session and every agent's call as the same machine account (the operator, 2026-10-26).
|
||||
|
||||
// Shadow is the switch's state until take-over: the bus node follows the controller's list. TookOver is its state after.
|
||||
const (
|
||||
Shadow = "shadow"
|
||||
TookOver = "took-over"
|
||||
)
|
||||
|
||||
// KeyRequest is the key in StateTakeover where a tool leaves its request for the process to ask.
|
||||
const KeyRequest = "request"
|
||||
|
||||
// The two acts the operator may warrant.
|
||||
const (
|
||||
ActTakeOver = "take-over"
|
||||
ActHandBack = "hand-back"
|
||||
)
|
||||
|
||||
// Switch is the value of `takeover`: which list the bus node follows, every act on the switch and the ask open now.
|
||||
type Switch struct {
|
||||
V int `json:"v"`
|
||||
State string `json:"state"`
|
||||
// Open is the id of the ask the operator has not answered yet, empty when none.
|
||||
Open string `json:"open,omitempty"`
|
||||
Acts []Act `json:"acts"`
|
||||
}
|
||||
|
||||
// Act is one take-over or hand-back asked, answered, done or refused.
|
||||
type Act struct {
|
||||
Act string `json:"act"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
// Ask is the router's ask of the act; Said what became of it: asked, switched, declined, expired, refused.
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Said string `json:"said,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// Request is what a tool leaves for the process to ask: written once per act, marked raised when asked.
|
||||
type Request struct {
|
||||
ID string `json:"id"`
|
||||
Act string `json:"act"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Raised bool `json:"raised,omitempty"`
|
||||
}
|
||||
|
||||
// Asker is the client of the router the part asks through (mesh-sdk asker.Client).
|
||||
type Asker interface {
|
||||
Ask(a asks.Ask) error
|
||||
}
|
||||
|
||||
// keptActs is how many acts the switch keeps, newest last.
|
||||
const keptActs = 50
|
||||
|
||||
func (p *Part) readSwitch() (Switch, error) {
|
||||
s := Switch{V: 1, State: Shadow}
|
||||
raw, _, found, err := p.Store.Get(StateTakeover, KeySwitch)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if found {
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (p *Part) writeSwitch(s Switch) error {
|
||||
if len(s.Acts) > keptActs {
|
||||
s.Acts = s.Acts[len(s.Acts)-keptActs:]
|
||||
}
|
||||
if s.Acts == nil {
|
||||
s.Acts = []Act{}
|
||||
}
|
||||
data, _ := json.Marshal(s)
|
||||
_, err := p.Store.Put(StateTakeover, KeySwitch, data)
|
||||
return err
|
||||
}
|
||||
|
||||
// SwitchState is which list the bus node follows now.
|
||||
func (p *Part) SwitchState() string {
|
||||
if s, err := p.readSwitch(); err == nil && s.State != "" {
|
||||
return s.State
|
||||
}
|
||||
return Shadow
|
||||
}
|
||||
|
||||
// TakeOver is the tool: it asks the operator to switch the bus node to the grants part's list. It writes the request,
|
||||
// and the part's process asks it on the operator's channel; only the operator's yes (a warrant) runs the switch, after
|
||||
// the refusals of Decide. Asking again while an ask is open is refused.
|
||||
func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActTakeOver, caller, why) }
|
||||
|
||||
// HandBack is the tool: it asks the operator to switch the bus node back to the controller's list.
|
||||
func (p *Part) HandBack(caller, why string) (any, error) { return p.request(ActHandBack, caller, why) }
|
||||
|
||||
func (p *Part) request(act, caller, why string) (any, error) {
|
||||
s, err := p.readSwitch()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the switch cannot be read: %w", err)
|
||||
}
|
||||
a := Act{Act: act, By: caller, Why: why, At: p.now()}
|
||||
if s.Open != "" {
|
||||
return nil, fmt.Errorf("%s is already asked: the ask %s waits for the operator's answer", act, s.Open)
|
||||
}
|
||||
if act == ActHandBack && s.State == Shadow {
|
||||
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"
|
||||
s.Acts = append(s.Acts, a)
|
||||
_ = p.writeSwitch(s)
|
||||
return nil, fmt.Errorf("%s", a.Refused)
|
||||
}
|
||||
if act == ActTakeOver && s.State == TookOver {
|
||||
return nil, fmt.Errorf("the bus node follows the grants part's list already")
|
||||
}
|
||||
a.Ask = act + "-" + p.now().Format("20060102t150405")
|
||||
a.Said = "asked"
|
||||
req, _ := json.Marshal(Request{ID: a.Ask, Act: act, By: caller, Why: why, At: a.At})
|
||||
if _, err := p.Store.Put(StateTakeover, KeyRequest, req); err != nil {
|
||||
return nil, fmt.Errorf("the request could not be written: %w", err)
|
||||
}
|
||||
s.Open = a.Ask
|
||||
s.Acts = append(s.Acts, a)
|
||||
if err := p.writeSwitch(s); err != nil {
|
||||
return nil, fmt.Errorf("the act could not be recorded: %w", err)
|
||||
}
|
||||
// The process asks at its next pass: wake it.
|
||||
_, _ = p.AskCompose(caller)
|
||||
return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes switches. grants_take_over_state shows where it stands"}, nil
|
||||
}
|
||||
|
||||
// RaiseRequested is the process's: it asks the operator the request a tool left, once. Where the part has no asker (the
|
||||
// tools) it does nothing.
|
||||
func (p *Part) RaiseRequested() error {
|
||||
if p.Asker == nil {
|
||||
return nil
|
||||
}
|
||||
raw, rev, found, err := p.Store.Get(StateTakeover, KeyRequest)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
return fmt.Errorf("its own %s %s cannot be read: %v", StateTakeover, KeyRequest, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var r Request
|
||||
if json.Unmarshal(raw, &r) != nil || r.Raised || r.ID == "" {
|
||||
return nil
|
||||
}
|
||||
ask, err := p.AskOf(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.Asker.Ask(ask); err != nil {
|
||||
return fmt.Errorf("the operator could not be asked the %s: %v", r.Act, err)
|
||||
}
|
||||
r.Raised = true
|
||||
data, _ := json.Marshal(r)
|
||||
if _, err := p.Store.Put(StateTakeover, KeyRequest, data); err != nil {
|
||||
return fmt.Errorf("the ask %s was asked and its request could not be marked: %v", r.ID, err)
|
||||
}
|
||||
_ = rev
|
||||
return nil
|
||||
}
|
||||
|
||||
// AskOf is the ask of a request, in the words the operator reads: what changes, why, what it risks. Its yes binds the act.
|
||||
func (p *Part) AskOf(r Request) (asks.Ask, error) {
|
||||
verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The grants part's list becomes the bus's.",
|
||||
"The controller's list stays in force.", "The grants part has composed the user list the bus holds today, and it can take over writing it.\n"+
|
||||
"If the lists differ in any user, or a user has no hash in the vault, the switch is refused and you are told which."
|
||||
if r.Act == ActHandBack {
|
||||
verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The controller's list becomes the bus's again.",
|
||||
"The grants part's list stays in force.", "The bus follows the grants part's list now.\nThe controller still writes its list until it loses its lease, so the bus can follow it again."
|
||||
}
|
||||
bind, err := asks.ActDigest(asks.Act{"verb": verb, "module": "mesh-grants", "ask": r.ID})
|
||||
if err != nil {
|
||||
return asks.Ask{}, err
|
||||
}
|
||||
return asks.Ask{ID: r.ID, Headline: headline, Explanation: expl, Who: asks.Operator,
|
||||
Options: []asks.Option{{ID: "yes", Label: "Yes, switch", Does: does, Level: asks.Approve, Binds: bind},
|
||||
{ID: "no", Label: "No, leave it", Does: stays, Level: asks.Acknowledge}},
|
||||
Expires: p.now().Add(asks.ApproveLasts - time.Minute), OnExpiry: stays + " Nothing changes.", About: "mesh-grants-" + r.Act,
|
||||
Details: "Asked by " + r.By + ": " + r.Why}, nil
|
||||
}
|
||||
|
||||
// Warranted is the process's act on the operator's answer (asker.Client.Listen hands it once, after checking the ask, the
|
||||
// option and its level). A no is recorded; a yes runs the switch after the refusals, and a refusal is said to the operator.
|
||||
func (p *Part) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
s, err := p.readSwitch()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
act := ActTakeOver
|
||||
if strings.HasPrefix(w.Ask, ActHandBack+"-") {
|
||||
act = ActHandBack
|
||||
}
|
||||
a := Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask}
|
||||
if o.ID != "yes" {
|
||||
a.Said = "declined"
|
||||
return p.finish(s, a)
|
||||
}
|
||||
var reasons []string
|
||||
if act == ActTakeOver {
|
||||
reasons = p.Decide()
|
||||
} else if s.State == Shadow {
|
||||
reasons = []string{"nothing to hand back: the bus node follows the controller's list already"}
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
a.Said, a.Refused = "refused", strings.Join(reasons, "; ")
|
||||
p.sayRefused(act, a.Refused)
|
||||
return p.finish(s, a)
|
||||
}
|
||||
a.Said = "switched"
|
||||
if act == ActTakeOver {
|
||||
s.State = TookOver
|
||||
} else {
|
||||
s.State = Shadow
|
||||
}
|
||||
if p.Emit != nil {
|
||||
_ = p.Emit.Emit(EventUserList, map[string]any{"switch": s.State})
|
||||
}
|
||||
return p.finish(s, a)
|
||||
}
|
||||
|
||||
// Said is the process's note of an ask that ended with no yes or no (expired, or refused by the router): the switch
|
||||
// stays, and the ask is closed so the operator can be asked again.
|
||||
func (p *Part) Said(h asker.Handled, w asks.Warrant, _ error) {
|
||||
if h != asker.Recorded {
|
||||
return
|
||||
}
|
||||
s, err := p.readSwitch()
|
||||
if err != nil || s.Open != w.Ask {
|
||||
return
|
||||
}
|
||||
act := ActTakeOver
|
||||
if strings.HasPrefix(w.Ask, ActHandBack+"-") {
|
||||
act = ActHandBack
|
||||
}
|
||||
_ = p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask, Said: string(w.Outcome)})
|
||||
}
|
||||
|
||||
func (p *Part) finish(s Switch, a Act) error {
|
||||
if s.Open == a.Ask {
|
||||
s.Open = ""
|
||||
}
|
||||
s.Acts = append(s.Acts, a)
|
||||
return p.writeSwitch(s)
|
||||
}
|
||||
|
||||
// Decide is what stands between a take-over and the grants part's list, read from a composition made now: the loaded list
|
||||
// not read, any user the two lists keep apart (one the loaded list has that the shadow cannot compose among them, such as
|
||||
// an enrolment user or a person whose records are in the controller's store alone), and any user with no hash in the vault.
|
||||
// Nothing here but what is true now: a take-over is refused for these and for no other reason. Empty means none.
|
||||
func (p *Part) Decide() []string {
|
||||
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, ", ")))
|
||||
}
|
||||
}
|
||||
cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone
|
||||
var unhashed []string
|
||||
for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) {
|
||||
if !contains(gone, u) {
|
||||
unhashed = append(unhashed, u)
|
||||
}
|
||||
}
|
||||
if len(unhashed) > 0 {
|
||||
sort.Strings(unhashed)
|
||||
reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s",
|
||||
len(unhashed), strings.Join(unhashed, ", ")))
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sayRefused tells the operator a yes was given and the switch refused, with the reasons (the users that differ named).
|
||||
func (p *Part) sayRefused(act, why string) {
|
||||
if p.Say == nil {
|
||||
return
|
||||
}
|
||||
c := asks.Condition{Key: "mesh-grants." + act + "-refused", Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"},
|
||||
State: asks.StateRaised, Summary: act + " refused: " + why, Headline: "The user list switch was refused", Needs: "a look at why",
|
||||
Explanation: asks.NeedsYou + "a look at why. You said yes to the switch and it was refused: " + why, Resolved: "the switch is asked again and goes through"}
|
||||
_ = p.Say(c)
|
||||
}
|
||||
|
||||
// TakeOverState is the tool take-over-state: the switch, the request, and what became of each ask.
|
||||
func (p *Part) TakeOverState() (any, error) {
|
||||
s, err := p.readSwitch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts}
|
||||
if raw, _, found, err := p.Store.Get(StateTakeover, KeyRequest); err == nil && found {
|
||||
out["request"] = json.RawMessage(raw)
|
||||
}
|
||||
if s.Open == "" {
|
||||
out["says"] = "no ask is open"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -13,17 +13,18 @@ import (
|
||||
// The part's tools, as its manifest lists them. Module tools in this milestone: a `mesh-*` seat must be in mesh-sdk's own
|
||||
// seat list, which the running controller's composer does not know, so the part claims no seat until take-over.
|
||||
const (
|
||||
ToolGrants = "grants_grants"
|
||||
ToolMembership = "grants_membership"
|
||||
ToolUserList = "grants_user_list"
|
||||
ToolComposeNow = "grants_compose_now"
|
||||
ToolAssert = "grants_assert"
|
||||
ToolTakeOver = "grants_take_over"
|
||||
ToolHandBack = "grants_hand_back"
|
||||
ToolGrants = "grants_grants"
|
||||
ToolMembership = "grants_membership"
|
||||
ToolUserList = "grants_user_list"
|
||||
ToolComposeNow = "grants_compose_now"
|
||||
ToolAssert = "grants_assert"
|
||||
ToolTakeOver = "grants_take_over"
|
||||
ToolTakeOverState = "grants_take_over_state"
|
||||
ToolHandBack = "grants_hand_back"
|
||||
)
|
||||
|
||||
// Tools is every tool, in the manifest's order.
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack}
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState}
|
||||
|
||||
// WritesNoGrant is what grants says of the part's own grants in this milestone.
|
||||
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"mesh-controller.condition-raised",
|
||||
"mesh-controller.condition-cleared"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"invokes": [
|
||||
"nats.nats_streams",
|
||||
"nats.nats_user_can",
|
||||
@@ -35,6 +38,7 @@
|
||||
"grants_compose_now",
|
||||
"grants_assert",
|
||||
"grants_take_over",
|
||||
"grants_take_over_state",
|
||||
"grants_hand_back"
|
||||
],
|
||||
"state": [
|
||||
|
||||
Reference in New Issue
Block a user