On the node whose compositions carry the bus's list, the engine also watches mesh-grants' key user-list.sealed. While it says follow grants, the object it names is read by name, opened with this machine's sealing key, checked against the key's digest and the guard, and written to the same file at 0600 only when its digest differs; the bus reloads it itself. The have says which list is followed and this machine's sealing key, which the grants part seals to. The follower's test waits for its watch to catch up before a later put, which raced under load. Pins mesh-sdk 175a5567.
373 lines
14 KiB
Go
373 lines
14 KiB
Go
package userlist
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
"github.com/novox/mesh-host/internal/agent"
|
|
)
|
|
|
|
const node = "novox"
|
|
|
|
func key() (ed25519.PublicKey, ed25519.PrivateKey) {
|
|
pub, k, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return pub, k
|
|
}
|
|
|
|
var controllerPub, controllerKey = key()
|
|
var _, strangerKey = key()
|
|
|
|
func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
|
|
|
|
// feed is a watch the test writes to: the keys there before the watch, Caught, then each put.
|
|
type feed struct {
|
|
ch chan agent.Update
|
|
rev uint64
|
|
first []agent.Update
|
|
// watched is closed once the watch caught up, so a later put is not read before the keys there before it.
|
|
watched chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (f *feed) Watch(context.Context, string) (<-chan agent.Update, error) {
|
|
for _, u := range f.first {
|
|
f.ch <- u
|
|
}
|
|
f.ch <- agent.Update{Caught: true}
|
|
f.once.Do(func() {
|
|
if f.watched != nil {
|
|
close(f.watched)
|
|
}
|
|
})
|
|
return f.ch, nil
|
|
}
|
|
|
|
func list(users ...string) string {
|
|
var b strings.Builder
|
|
b.WriteString("authorization {\n users = [\n")
|
|
for _, u := range users {
|
|
b.WriteString(` {user: "` + u + `", password: "$2a$11$x"}` + "\n")
|
|
}
|
|
b.WriteString(" ]\n}\n")
|
|
return b.String()
|
|
}
|
|
|
|
// comp is a composition of module at fp holding the user list content (none when empty), signed by signer; by digest
|
|
// when byDigest, as the controller sends a file over 64 KiB; for another node or module than its key when asked.
|
|
func comp(t *testing.T, module, fp, content string, signer ed25519.PrivateKey, p put) (string, []byte) {
|
|
t.Helper()
|
|
owner := module
|
|
if p.asModule != "" {
|
|
owner = p.asModule
|
|
}
|
|
server, _ := json.Marshal(map[string]any{"id": owner + ".server-conf", "type": "file", "module": owner,
|
|
"path": "/var/lib/nats-module/conf/nats.conf", "content": "include accounts.conf\n"})
|
|
rs := []json.RawMessage{server}
|
|
if content != "" {
|
|
r := map[string]any{"id": owner + ".bus-users", "type": "file", "module": owner,
|
|
"path": "/var/lib/nats-module/conf/accounts.conf", "content": content, "mode": "0600"}
|
|
if p.byDigest {
|
|
delete(r, "content")
|
|
r["digest"], r["size"] = Digest([]byte(content)), len(content)
|
|
}
|
|
users, _ := json.Marshal(r)
|
|
rs = append(rs, users)
|
|
}
|
|
c := delivery.Composed{Node: node, Module: module, FP: fp, Resources: rs}
|
|
if p.asNode != "" {
|
|
c.Node = p.asNode
|
|
}
|
|
if p.asModule != "" {
|
|
c.Module = p.asModule
|
|
}
|
|
k, err := delivery.ComposedKey(node, module, fp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signed, err := delivery.SignComposed(c, signer)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := delivery.EncodeComposed(signed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return k, raw
|
|
}
|
|
|
|
// gput is the grants part's key as the bus node hears it at the watch's start (ADR 0547 clauses 7, 8): which list to
|
|
// follow, and the list sealed to the node's key; one sealed to another key, or one that does not open to the key's digest.
|
|
type gput struct {
|
|
follow, content string
|
|
otherKey, badDigest bool
|
|
}
|
|
|
|
type put struct {
|
|
module, fp, content string
|
|
stranger, deleted bool
|
|
byDigest bool
|
|
asNode, asModule string
|
|
}
|
|
|
|
// Defends novox/hq ADR 0547 §8: the bus node writes the newest signed list alone, only when it changed, and reports it.
|
|
func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) {
|
|
one, two, three := list("controller", "a"), list("controller", "a", "b"), list("controller", "a", "b", "c")
|
|
five := list("controller", "a", "b", "c", "d")
|
|
for _, c := range []struct {
|
|
name string
|
|
before, after []put
|
|
again bool
|
|
failWrite bool
|
|
onDisk string
|
|
wantWrites []string // contents written, in order
|
|
wantUsers int
|
|
wantErr string
|
|
wantRefused bool
|
|
grants []gput
|
|
// wantSource and wantFollows are what the last have says it took the list from and follows.
|
|
wantSource, wantFollows string
|
|
}{
|
|
{name: "the list there is written once", before: []put{{module: "nats", fp: fpOf("a"), content: two}},
|
|
wantWrites: []string{two}, wantUsers: 3},
|
|
{name: "a newer revision is written, the older fingerprint is not",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: one}, {module: "nats", fp: fpOf("b"), content: two}},
|
|
after: []put{{module: "nats", fp: fpOf("a"), content: three}},
|
|
wantWrites: []string{two, three}, wantUsers: 4},
|
|
{name: "a composition signed by a stranger is no source",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: one}},
|
|
after: []put{{module: "nats", fp: fpOf("b"), content: three, stranger: true}},
|
|
wantWrites: []string{one}, wantUsers: 2},
|
|
{name: "a composition without the list is ignored", before: []put{{module: "web", fp: fpOf("a"), content: ""}}},
|
|
{name: "a deleted key is no source",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: one}, {module: "nats", fp: fpOf("b"), content: two}},
|
|
after: []put{{module: "nats", fp: fpOf("b"), content: "", deleted: true}, {module: "nats", fp: fpOf("a"), content: three}},
|
|
wantWrites: []string{two, three}, wantUsers: 4},
|
|
{name: "asked again, the same source is written again", before: []put{{module: "nats", fp: fpOf("a"), content: two}},
|
|
again: true, wantWrites: []string{two, two}, wantUsers: 3},
|
|
{name: "a list sent by digest counts the users of what it names",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: three, byDigest: true}},
|
|
wantWrites: []string{three}, wantUsers: 4},
|
|
{name: "another node's composition under this node's key is no source",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: two, asNode: "ace"}}},
|
|
{name: "another module's composition under the bus module's key is no source",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: two, asModule: "web"}}},
|
|
{name: "a list without the controller is refused and the file kept",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: list("a", "b")}},
|
|
onDisk: two, wantUsers: 2, wantErr: "holds no user \"controller\"", wantRefused: true},
|
|
{name: "a list that drops more than half is refused and the file kept",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: one}},
|
|
onDisk: five, wantUsers: 2, wantErr: "drops more than half", wantRefused: true},
|
|
{name: "a list that drops half is written", before: []put{{module: "nats", fp: fpOf("a"), content: two}},
|
|
onDisk: list("controller", "a", "b", "c", "d", "e"), wantWrites: []string{two}, wantUsers: 3},
|
|
{name: "a failed write is said and kept", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, failWrite: true,
|
|
wantWrites: []string{two}, wantUsers: 3, wantErr: "disk full"},
|
|
// ADR 0547 clauses 7, 8: never both lists; the grants part's from take-over, the controller's otherwise.
|
|
{name: "from take-over the grants part's list is written into the same file",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three}},
|
|
wantWrites: []string{two, three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants},
|
|
{name: "in shadow the grants part's list is not taken",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowController, content: three}},
|
|
wantWrites: []string{two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController},
|
|
{name: "a grants list that does not open to its digest is refused and the file kept",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, badDigest: true}},
|
|
onDisk: two, wantWrites: []string{two}, wantErr: "could not be had", wantRefused: true, wantSource: delivery.GrantsUserListSource},
|
|
{name: "a grants list sealed to another key is refused and the file kept",
|
|
before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, otherKey: true}},
|
|
onDisk: two, wantWrites: []string{two}, wantErr: "sealed to another key", wantRefused: true, wantSource: delivery.GrantsUserListSource},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
f := &feed{ch: make(chan agent.Update, 64), watched: make(chan struct{})}
|
|
objects := map[string]string{}
|
|
var mu sync.Mutex
|
|
var writes []string
|
|
var reports []Written
|
|
reported := make(chan struct{}, 64)
|
|
gf := &feed{ch: make(chan agent.Update, 64)}
|
|
sealed := map[string][]byte{}
|
|
nodeKey, err := seal.GenerateKeyPair()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, g := range c.grants {
|
|
to := nodeKey.PublicKey
|
|
if g.otherKey {
|
|
other, _ := seal.GenerateKeyPair()
|
|
to = other.PublicKey
|
|
}
|
|
digest := delivery.UserListDigest([]byte(g.content))
|
|
if g.badDigest {
|
|
digest = delivery.UserListDigest([]byte("another list"))
|
|
}
|
|
object, err := delivery.SealUserList([]byte(g.content), to)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed[delivery.UserListObjectName(digest)] = object
|
|
raw, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: g.follow, Digest: digest,
|
|
Object: delivery.UserListObjectName(digest), SealedTo: delivery.SealingKeyID(to), Node: node, Revision: uint64(i + 1)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gf.first = append(gf.first, agent.Update{Key: delivery.GrantsUserListKey, Value: raw, Revision: uint64(i + 1)})
|
|
}
|
|
fl := New(Env{Node: node, Composed: f, Grants: gf,
|
|
Fetch: func(_ context.Context, name string) ([]byte, error) {
|
|
if o, ok := sealed[name]; ok {
|
|
return o, nil
|
|
}
|
|
return nil, errors.New("no object " + name)
|
|
},
|
|
Open: func(b seal.Box) ([]byte, error) { out, err := seal.Open(b, nodeKey.PrivateKey); return []byte(out), err },
|
|
Key: func() string { return nodeKey.PublicKey },
|
|
Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub} },
|
|
Resolve: func(_ context.Context, r json.RawMessage) ([]byte, error) {
|
|
var res struct{ Content, Digest string }
|
|
_ = json.Unmarshal(r, &res)
|
|
if res.Digest != "" {
|
|
body, ok := objects[res.Digest]
|
|
if !ok {
|
|
return nil, errors.New("no object " + res.Digest)
|
|
}
|
|
return []byte(body), nil
|
|
}
|
|
return []byte(res.Content), nil
|
|
},
|
|
OnDisk: func(json.RawMessage) ([]byte, error) {
|
|
if c.onDisk == "" {
|
|
return nil, nil
|
|
}
|
|
return []byte(c.onDisk), nil
|
|
},
|
|
Write: func(_ context.Context, module string, r json.RawMessage, body []byte) (string, bool, error) {
|
|
mu.Lock()
|
|
writes = append(writes, string(body))
|
|
mu.Unlock()
|
|
if c.failWrite {
|
|
return "", false, errors.New("disk full")
|
|
}
|
|
return Digest(body), true, nil
|
|
},
|
|
Report: func(_ context.Context, module string, w Written) {
|
|
mu.Lock()
|
|
reports = append(reports, w)
|
|
mu.Unlock()
|
|
reported <- struct{}{}
|
|
},
|
|
Now: func() time.Time { return time.Date(2026, 10, 25, 21, 50, 0, 0, time.UTC) },
|
|
})
|
|
update := func(p put) agent.Update {
|
|
f.rev++
|
|
signer := controllerKey
|
|
if p.stranger {
|
|
signer = strangerKey
|
|
}
|
|
k, raw := comp(t, p.module, p.fp, p.content, signer, p)
|
|
if p.byDigest {
|
|
objects[Digest([]byte(p.content))] = p.content
|
|
}
|
|
if p.deleted {
|
|
return agent.Update{Key: k, Revision: f.rev, Deleted: true}
|
|
}
|
|
return agent.Update{Key: k, Value: raw, Revision: f.rev}
|
|
}
|
|
for _, p := range c.before {
|
|
f.first = append(f.first, update(p))
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
done := make(chan error, 1)
|
|
go func() { done <- fl.Run(ctx) }()
|
|
<-f.watched
|
|
for _, p := range c.after {
|
|
f.ch <- update(p)
|
|
}
|
|
if c.again {
|
|
// After the first write is reported, so the look again is not folded into it.
|
|
select {
|
|
case <-reported:
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
fl.Again()
|
|
}
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
mu.Lock()
|
|
n := len(writes)
|
|
mu.Unlock()
|
|
if n >= len(c.wantWrites) {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("writes %d, want %d", n, len(c.wantWrites))
|
|
case <-time.After(5 * time.Millisecond):
|
|
}
|
|
}
|
|
if c.wantRefused {
|
|
select {
|
|
case <-reported:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("a refused list was not reported")
|
|
}
|
|
}
|
|
if c.wantSource != "" {
|
|
for until := time.Now().Add(5 * time.Second); ; {
|
|
if _, st := fl.State(); st.Source == c.wantSource && (c.wantErr == "" || st.Error != "") || time.Now().After(until) {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
time.Sleep(50 * time.Millisecond) // nothing more is written
|
|
cancel()
|
|
<-done
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if strings.Join(writes, "|") != strings.Join(c.wantWrites, "|") {
|
|
t.Fatalf("written %q, want %q", writes, c.wantWrites)
|
|
}
|
|
module, st := fl.State()
|
|
if c.wantSource != "" && (st.Source != c.wantSource || st.Key != nodeKey.PublicKey ||
|
|
(c.wantFollows != "" && st.Follows != c.wantFollows)) {
|
|
t.Fatalf("the have says source %q follows %q key %t; want %q %q", st.Source, st.Follows, st.Key == nodeKey.PublicKey, c.wantSource, c.wantFollows)
|
|
}
|
|
if c.wantRefused {
|
|
if !strings.Contains(st.Error, c.wantErr) || st.Digest != Digest([]byte(c.onDisk)) || st.Users != c.wantUsers {
|
|
t.Fatalf("refused list's state %+v", st)
|
|
}
|
|
return
|
|
}
|
|
if len(c.wantWrites) == 0 {
|
|
if module != "" || len(reports) != 0 {
|
|
t.Fatalf("a node with no list wrote or said something: %q %+v", module, reports)
|
|
}
|
|
return
|
|
}
|
|
if module != "nats" || st.Users != c.wantUsers || st.Wanted != Digest([]byte(c.wantWrites[len(c.wantWrites)-1])) {
|
|
t.Errorf("state %q %+v", module, st)
|
|
}
|
|
if c.wantErr != "" {
|
|
if !strings.Contains(st.Error, c.wantErr) || st.Digest != "" {
|
|
t.Errorf("a failed write is not kept: %+v", st)
|
|
}
|
|
} else if st.Digest != st.Wanted || st.Error != "" {
|
|
t.Errorf("written %+v", st)
|
|
}
|
|
if len(reports) == 0 || reports[len(reports)-1] != st {
|
|
t.Errorf("reports %+v, last state %+v", reports, st)
|
|
}
|
|
})
|
|
}
|
|
}
|