654 lines
33 KiB
Go
654 lines
33 KiB
Go
package buscompose
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/announce"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
|
|
// byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own
|
|
// internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the
|
|
// controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller,
|
|
// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy
|
|
// gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and
|
|
// only those more.
|
|
// createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it.
|
|
var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `)
|
|
|
|
// beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3,
|
|
// issue 1126), by user: a node's account reads every node's own facts, `<node>.node` of the have bucket where the node's
|
|
// record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the
|
|
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
|
|
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
|
|
// (issue 1790) is the other row beyond, matched by createdAdvisory.
|
|
//
|
|
// And the watchman's dead letters (novox/hq ADR 0564 clause 5, issue 1952 S5): its own consumer on DEAD_LETTER_NOTICES and
|
|
// a read of DEAD_LETTERS, the controller's job until the lease write.
|
|
func beyondTheControllers(user string) map[string]bool {
|
|
out := map[string]bool{}
|
|
if strings.HasSuffix(user, "."+WatchmanModule) {
|
|
for _, s := range WatchmanDeadLetterGrants() {
|
|
out[s] = true
|
|
}
|
|
}
|
|
node, ok := strings.CutPrefix(user, "node.")
|
|
if !ok {
|
|
return out
|
|
}
|
|
for _, s := range append(append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...), NodeComposeAdvisories()...) {
|
|
out[s] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
|
|
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
|
|
func mayBeyondTheControllers(user string) map[string]bool {
|
|
if _, ok := strings.CutPrefix(user, "node."); !ok {
|
|
return nil
|
|
}
|
|
return map[string]bool{VaultOwnCall(Principal{Kind: KindNode, Node: strings.TrimPrefix(user, "node.")}): true,
|
|
VaultSecretChanged: true}
|
|
}
|
|
|
|
// engineBeyond is a node's engine holding its node seat and asking the operator in its own name (novox/hq ADR 0563 §1,
|
|
// §3), beyond the controller's on purpose: its own node's verbs, events and discovery, the bus's discovery asked, and
|
|
// the router's seat traffic under node-engine-<node>.
|
|
func engineBeyond(user, g string) bool {
|
|
node, ok := strings.CutPrefix(user, "node.")
|
|
if !ok {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
|
|
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
|
|
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".")
|
|
}
|
|
|
|
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
|
|
// and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone.
|
|
func createsAnAccountsConsumer(user, g string) bool {
|
|
return strings.HasSuffix(user, "."+GrantsModule) &&
|
|
(strings.HasPrefix(g, "$JS.API.CONSUMER.CREATE.") || strings.HasPrefix(g, "$JS.API.CONSUMER.INFO."))
|
|
}
|
|
|
|
// quotedToken is a quoted token of a composed list.
|
|
var quotedToken = regexp.MustCompile(`"[^"]*"`)
|
|
|
|
// grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its
|
|
// password's hash, every subject it may publish or subscribe to), by user.
|
|
func grantsByUser(list string) (string, map[string]map[string]bool) {
|
|
var outside strings.Builder
|
|
users := map[string]map[string]bool{}
|
|
user := ""
|
|
for _, line := range strings.SplitAfter(list, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok {
|
|
user, _, _ = strings.Cut(name, `"`)
|
|
users[user] = map[string]bool{}
|
|
} else if user != "" && trimmed == "]" {
|
|
user = ""
|
|
}
|
|
if user == "" {
|
|
outside.WriteString(line)
|
|
continue
|
|
}
|
|
for _, q := range quotedToken.FindAllString(line, -1) {
|
|
users[user][strings.Trim(q, `"`)] = true
|
|
}
|
|
}
|
|
return outside.String(), users
|
|
}
|
|
|
|
// grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it
|
|
// names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and
|
|
// the copy's beyond them are exactly those, every one of them there.
|
|
func grantsAsTheControllers(t *testing.T, got, want string) {
|
|
t.Helper()
|
|
gotOutside, gotUsers := grantsByUser(got)
|
|
wantOutside, wantUsers := grantsByUser(want)
|
|
if gotOutside != wantOutside {
|
|
t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside)
|
|
}
|
|
for user, was := range wantUsers {
|
|
now, ok := gotUsers[user]
|
|
if !ok {
|
|
t.Errorf("the copy has no user %s", user)
|
|
continue
|
|
}
|
|
intended := beyondTheControllers(user)
|
|
for g := range was {
|
|
if !now[g] {
|
|
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
|
}
|
|
}
|
|
for g := range now {
|
|
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) &&
|
|
!createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) {
|
|
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
|
|
}
|
|
}
|
|
for g := range intended {
|
|
if !now[g] {
|
|
t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g)
|
|
}
|
|
}
|
|
}
|
|
for user := range gotUsers {
|
|
if _, ok := wantUsers[user]; !ok {
|
|
t.Errorf("the copy has a user %s the controller's has not", user)
|
|
}
|
|
}
|
|
}
|
|
|
|
const SignersBucketName = "mesh-vault_signers"
|
|
|
|
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
|
// ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole
|
|
// proposal laid over), and none of its open proposals; it writes neither.
|
|
reads := strings.Join(NodeComposeReads("laptop"), " ")
|
|
for bucket, granted := range map[string]bool{delivery.PlacementBucket: true, delivery.PlacementCandidatesBucket: true,
|
|
delivery.PlacementProposalsBucket: false} {
|
|
if strings.Contains(reads, "$JS.API.DIRECT.GET.KV_"+bucket+".>") != granted || strings.Contains(reads, "$KV."+bucket+".laptop") {
|
|
t.Errorf("a node is granted %s: %v, want %v", bucket, !granted, granted)
|
|
}
|
|
}
|
|
// It hears the creation of those two buckets, and of no other.
|
|
if got := NodeComposeAdvisories(); len(got) != 2 || got[0] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement" ||
|
|
got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" {
|
|
t.Errorf("a node hears the creation of %v", got)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var want []struct {
|
|
Name, List string
|
|
Missing []string
|
|
Err string
|
|
Memberships json.RawMessage
|
|
}
|
|
if err := json.Unmarshal(raw, &want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixtures := UserListFixtures()
|
|
if len(want) != len(fixtures) {
|
|
t.Fatalf("%d fixtures, %d lists the controller composed", len(fixtures), len(want))
|
|
}
|
|
for i, f := range fixtures {
|
|
t.Run(f.Name, func(t *testing.T) {
|
|
if want[i].Name != f.Name {
|
|
t.Fatalf("fixture %q against the controller's %q", f.Name, want[i].Name)
|
|
}
|
|
list, missing, err := ComposedFor(f)
|
|
got := ""
|
|
if err != nil {
|
|
got = err.Error()
|
|
}
|
|
if got != want[i].Err {
|
|
t.Fatalf("error %q, the controller's %q", got, want[i].Err)
|
|
}
|
|
if strings.Join(missing, ",") != strings.Join(want[i].Missing, ",") {
|
|
t.Errorf("left out %v, the controller %v", missing, want[i].Missing)
|
|
}
|
|
// The one row the copy composes beyond the controller's, on purpose (novox/hq issue 1790, 2026-10-26, after
|
|
// the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state
|
|
// missing at its start is bound when it appears. The controller's composer is frozen and its list no longer
|
|
// loaded; while it is not, this row is left out of the comparison and nothing else.
|
|
grantsAsTheControllers(t, list, want[i].List)
|
|
// And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the
|
|
// grants part publishes them.
|
|
raw, err := json.Marshal(MembershipsFor(f))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var gotM, wasM any
|
|
if err := json.Unmarshal(raw, &gotM); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(gotM, wasM) {
|
|
t.Errorf("the memberships differ from the controller's:\n%s", raw)
|
|
}
|
|
})
|
|
}
|
|
// An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters
|
|
// (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever).
|
|
t.Run("an asker reads its seat's dead letters", func(t *testing.T) {
|
|
build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true}
|
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for subject, want := range map[string]bool{
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true,
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true,
|
|
"$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true,
|
|
"$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true,
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false,
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false,
|
|
"$JS.API.STREAM.PURGE.DEAD_LETTERS": false,
|
|
"$JS.FC.DEAD_LETTERS.x1": false,
|
|
} {
|
|
if got := MayPublish(perms, subject); got != want {
|
|
t.Errorf("may publish %s: %v, want %v", subject, got, want)
|
|
}
|
|
}
|
|
plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
|
Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") {
|
|
t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS")
|
|
}
|
|
})
|
|
// ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any
|
|
// runtime may call them, and it asks the operator under node-engine-<node> alone.
|
|
t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) {
|
|
router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
|
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
|
|
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
|
|
"one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}},
|
|
"two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, u := range users {
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") ||
|
|
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one")
|
|
if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne {
|
|
t.Errorf("%s answers one's root-free: %v", u.Username(), answers)
|
|
}
|
|
switch {
|
|
case u.Kind == KindNode && u.Node == "one":
|
|
for subject, want := range map[string]bool{
|
|
"mesh.seat.operator-channel.accept.ask.node-engine-one": true,
|
|
"mesh.seat.operator-channel.accept.cancel.node-engine-one": true,
|
|
"mesh.seat.operator-channel.accept.ask.node-engine-two": false,
|
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true,
|
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false,
|
|
"mesh.seat." + EngineSeat + ".event.done.one": true,
|
|
"mesh.seat." + EngineSeat + ".event.done.two": false,
|
|
"$SRV.INFO": true,
|
|
} {
|
|
if got := MayPublish(perms, subject); got != want {
|
|
t.Errorf("node one may publish %s: %v, want %v", subject, got, want)
|
|
}
|
|
}
|
|
if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") ||
|
|
MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") ||
|
|
!MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") {
|
|
t.Errorf("node one hears %v", perms.Subscribe)
|
|
}
|
|
case u.Kind == KindNodeTools || u.Module == "messenger":
|
|
if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") {
|
|
t.Errorf("%s cannot call one's root-free", u.Username())
|
|
}
|
|
}
|
|
}
|
|
})
|
|
// Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no
|
|
// other node's.
|
|
t.Run("a node asks the vault its own secrets", func(t *testing.T) {
|
|
one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true}
|
|
perms, err := PermissionsFor(one)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) {
|
|
t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)),
|
|
MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})))
|
|
}
|
|
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) {
|
|
t.Error("a node asks own while the vault does not serve it")
|
|
}
|
|
if !slices.Contains(perms.Subscribe, VaultSecretChanged) {
|
|
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
|
|
}
|
|
// L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's
|
|
// `signers` and the lease key, never writing either.
|
|
signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true})
|
|
signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key"
|
|
leaseGet := "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder"
|
|
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, leaseGet: true,
|
|
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false,
|
|
"$KV." + LeaseBucket + ".holder": false} {
|
|
if MayPublish(signs, subject) != want {
|
|
t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want)
|
|
}
|
|
}
|
|
if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) {
|
|
t.Error("a node asks sign or reads signers while the vault does not serve sign")
|
|
}
|
|
// And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126).
|
|
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
|
|
"two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
get := func(key string) string {
|
|
return "$JS.API.DIRECT.GET.KV_" + ResolvedBucket + ".$KV." + ResolvedBucket + "." + key
|
|
}
|
|
for _, u := range users {
|
|
if u.Kind != KindNode {
|
|
continue
|
|
}
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u.Node == "one" && (!reflect.DeepEqual(u.ServedReads, []string{"two.mailer.domain"}) || !MayPublish(perms, get("two.mailer.domain")) ||
|
|
MayPublish(perms, get("two.mailer.sitename"))) {
|
|
t.Errorf("node one reads %v of two's resolved values", u.ServedReads)
|
|
}
|
|
if u.Node == "two" && len(u.ServedReads) != 0 {
|
|
t.Errorf("node two is granted its own served keys apart: %v", u.ServedReads)
|
|
}
|
|
}
|
|
})
|
|
t.Run("the controller's golden server configuration", func(t *testing.T) {
|
|
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
|
got, err := Compose(
|
|
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
|
|
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
|
|
[]Principal{
|
|
{Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"},
|
|
{Kind: KindEnrolment, Node: "one", PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
|
|
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
|
|
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
|
|
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
|
|
{Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat},
|
|
Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"},
|
|
{Kind: KindModule, Node: "two", Module: "audit",
|
|
Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"},
|
|
{Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"},
|
|
{Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true,
|
|
Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want, err := os.ReadFile(filepath.Join("testdata", "composed.conf"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
grantsAsTheControllers(t, got, string(want))
|
|
})
|
|
// 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by
|
|
// its user, at the subject `$KV.mesh-grants_accounts.<user>`, read by the vault alone; and the vault's account-issued
|
|
// names the account, never a value.
|
|
t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) {
|
|
for _, w := range []WantedAccount{
|
|
{User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222",
|
|
Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}},
|
|
{User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send",
|
|
URL: "nats://novox.mesh:4222"},
|
|
{User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client",
|
|
URL: "nats://novox.mesh:4222", Fingerprint: "ab"},
|
|
} {
|
|
if err := w.Valid(); err != nil {
|
|
t.Fatalf("%s: %v", w.User, err)
|
|
}
|
|
raw, err := json.Marshal(w)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := ReadWantedAccount(w.User, raw)
|
|
if err != nil || !back.Same(w) {
|
|
t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err)
|
|
}
|
|
if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User {
|
|
t.Errorf("%s is kept at %s", w.User, got)
|
|
}
|
|
if _, err := ReadWantedAccount("someone.else", raw); err == nil {
|
|
t.Errorf("%s was read under another user's key", w.User)
|
|
}
|
|
}
|
|
for _, bad := range []WantedAccount{
|
|
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"},
|
|
{User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"},
|
|
{User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"},
|
|
{User: "controller", Kind: KindController, URL: "nats://a:1"},
|
|
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"},
|
|
} {
|
|
if bad.Valid() == nil {
|
|
t.Errorf("%+v is taken as a wanted account", bad)
|
|
}
|
|
}
|
|
if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) {
|
|
t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts])
|
|
}
|
|
e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1}
|
|
raw, _ := json.Marshal(e)
|
|
if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") ||
|
|
AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" {
|
|
t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject)
|
|
}
|
|
})
|
|
// 774/AC3 (novox/hq ADR 0561 §2): the account the vault seals is, byte for byte, what the controller hands for each of
|
|
// its three kinds: testdata/controller-credentials.json was written by the controller's own code (its "how").
|
|
t.Run("a held account is the controller's shape byte for byte", func(t *testing.T) {
|
|
raw, err := os.ReadFile(filepath.Join("testdata", "controller-credentials.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var captured struct {
|
|
Rows []struct{ Name, Held string }
|
|
}
|
|
if err := json.Unmarshal(raw, &captured); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
vault := ClaimedSeat{Seat: "mesh-vault", Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued",
|
|
"rotate", "move-in", "node-key", "own", "move-asks"}}
|
|
controller := ClaimedSeat{Seat: "mesh-controller", Scope: "mesh", Serves: strings.Split("tools,calls,status,nodes,"+
|
|
"node,placement-export,modules,seats,builds,plans,delivery-plan,delivery-order,delivery-check,deliver,"+
|
|
"delivery-stop,delivery-backups-again,delivery-walks,plan,assign,unassign,pin,unpin,push,rotate,give,issued,"+
|
|
"secret-move,secret-ask,issue,token,settings,command,queue,cancel,clear,replay,try,kill,pause,resume,hand-act,"+
|
|
"drill,warranted,hand-acts,durations,conditions,healers,doctor,upgrade,bus,retire,cleanup,judge-directories,"+
|
|
"root-free,dead-letters,data,build,artifacts,held,collect,move-placement,move-config,mirrors,images", ",")}
|
|
at := "nats://10.0.0.1:4222"
|
|
wanted := map[string]struct {
|
|
w WantedAccount
|
|
password string
|
|
}{
|
|
"module": {WantedAccount{User: "laptop.cap-bot", Kind: KindModule, Node: "laptop", Module: "cap-bot", URL: at,
|
|
Fingerprint: "ab:cd", Claims: []ClaimedSeat{vault, {Seat: "node-service-manager", Scope: "node",
|
|
Serves: []string{"status"}}, controller, {Seat: "no-such-seat", Scope: "node"}}}, "pw-module"},
|
|
"module-no-claims-no-fingerprint": {WantedAccount{User: "laptop.cap-plain", Kind: KindModule, Node: "laptop",
|
|
Module: "cap-plain", URL: at}, "pw-plain"},
|
|
"provisioner": {WantedAccount{User: "provisioner.laptop", Kind: KindProvisioner, Node: "laptop", Module: "nats",
|
|
URL: at, Fingerprint: "ab:cd"}, "pw-prov"},
|
|
"process": {WantedAccount{User: "laptop.mesh-mail.send", Kind: KindProcess, Node: "laptop", Module: "mesh-mail",
|
|
Process: "send", URL: at, Fingerprint: "ab:cd"}, "pw-proc"},
|
|
}
|
|
if len(captured.Rows) != len(wanted) {
|
|
t.Fatalf("%d rows captured, %d wanted", len(captured.Rows), len(wanted))
|
|
}
|
|
for _, row := range captured.Rows {
|
|
c, ok := wanted[row.Name]
|
|
if !ok {
|
|
t.Fatalf("no wanted account for the captured %s", row.Name)
|
|
}
|
|
got, err := HeldCredential(c.w, c.password)
|
|
if err != nil || string(got) != row.Held {
|
|
t.Errorf("%s:\n got %s (%v)\nwant %s", row.Name, got, err, row.Held)
|
|
}
|
|
}
|
|
if _, err := HeldCredential(wanted["module"].w, ""); err == nil {
|
|
t.Error("an account was held with no password")
|
|
}
|
|
})
|
|
// 774 subtasks 6 and 8 (novox/hq ADR 0561 §3, §6): minting and the grants part's consumers turn on only while the
|
|
// lease is handed to a holder that is no controller; the vault and the grants part alone read the lease, whatever the
|
|
// controller announced; a module's consumer is created as the controller's EnsureConsumer creates it.
|
|
t.Run("the lease turns minting on only when handed away; a consumer is created as the controller's", func(t *testing.T) {
|
|
for _, row := range []struct {
|
|
name string
|
|
raw string
|
|
found bool
|
|
err error
|
|
on bool
|
|
}{
|
|
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
|
|
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
|
|
{"expired or deleted", ``, false, nil, false},
|
|
{"unreadable", ``, false, errors.New("refused"), false},
|
|
{"not a holder", `[1]`, true, nil, false},
|
|
{"both a controller and a handed holder", `{"instance":"novox/1","handed-to":"x"}`, true, nil, false},
|
|
} {
|
|
if on, why := LeaseHandedAway([]byte(row.raw), row.found, row.err); on != row.on || why == "" {
|
|
t.Errorf("%s: on %v (%s), want %v", row.name, on, why, row.on)
|
|
}
|
|
// The assigner's grants source follows it: the grants part's bucket only for a handed-away lease (issue 774).
|
|
on, _ := LeaseHandedAway([]byte(row.raw), row.found, row.err)
|
|
if bucket, _ := delivery.GrantSource(on); (bucket == delivery.PartGrantsBucket) != row.on {
|
|
t.Errorf("%s: the grants source is %s", row.name, bucket)
|
|
}
|
|
}
|
|
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox")}
|
|
for reader, want := range map[string]bool{VaultSeat: true, GrantsModule: true, "mesh-delivery-assigner": true, "mesh-delivery-view": false} {
|
|
granted, _ := ReadsAnnouncedBy(reader, []string{ControllerLease}, announced)
|
|
if (len(granted) == 1) != want {
|
|
t.Errorf("%s reads the lease: %v, want %v", reader, granted, want)
|
|
}
|
|
}
|
|
c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}})
|
|
cfg := ConsumerConfig(c)
|
|
if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second ||
|
|
cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 ||
|
|
cfg.Description == "" {
|
|
t.Errorf("a module's consumer is created as %+v", cfg)
|
|
}
|
|
// The grants part is granted the create of each account's consumer by its stream and name, and no other.
|
|
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
|
|
{Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, u := range users {
|
|
if u.Module != GrantsModule || u.Kind != KindModule {
|
|
continue
|
|
}
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") ||
|
|
MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") {
|
|
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
|
}
|
|
}
|
|
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
|
|
healthChecks(t)
|
|
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
|
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
|
|
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
|
|
t.Errorf("a process's queue consumer is created as %+v", q)
|
|
}
|
|
})
|
|
}
|
|
|
|
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
|
|
func healthChecks(t *testing.T) {
|
|
t.Helper()
|
|
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"),
|
|
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
|
|
for _, c := range []struct {
|
|
reader, read string
|
|
may bool
|
|
}{
|
|
{"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true},
|
|
{"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false},
|
|
{"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false},
|
|
{"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false},
|
|
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
|
|
} {
|
|
granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced)
|
|
if (len(granted) == 1) != c.may {
|
|
t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may)
|
|
}
|
|
}
|
|
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
|
|
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
|
|
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
|
|
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts", ControllerConditions}},
|
|
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}},
|
|
{Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}},
|
|
{Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}},
|
|
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: BusToolsProcess}, {Process: "other"}}},
|
|
{Module: "shop", Processes: []ProcessPrincipal{{Process: BusToolsProcess}}},
|
|
}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
may := map[string][]string{
|
|
"a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"},
|
|
WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule,
|
|
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"},
|
|
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
|
|
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts"},
|
|
}
|
|
mayNot := map[string][]string{
|
|
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
|
|
"a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"},
|
|
"a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch"},
|
|
"a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch"},
|
|
// The watchman never deletes or changes the controller's consumer (clause 5).
|
|
WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule},
|
|
// The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts
|
|
// remove nothing, and a process of that name in another module is no tools account.
|
|
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
|
|
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts"},
|
|
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
|
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
|
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, u := range users {
|
|
name := u.Username()
|
|
if u.Module == WatchmanModule && u.Kind == KindModule {
|
|
name = WatchmanModule
|
|
}
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
for _, subject := range may[name] {
|
|
seen[name] = true
|
|
if !MayPublish(perms, subject) {
|
|
t.Errorf("%s may not publish %s", name, subject)
|
|
}
|
|
}
|
|
for _, subject := range mayNot[name] {
|
|
seen[name] = true
|
|
if MayPublish(perms, subject) {
|
|
t.Errorf("%s may publish %s", name, subject)
|
|
}
|
|
}
|
|
}
|
|
for name := range mayNot {
|
|
if !seen[name] {
|
|
t.Errorf("no user %s was composed", name)
|
|
}
|
|
}
|
|
}
|