With no send left nothing else writes a missed grant, so the take-in of a done key returns it and tries again every half minute under an urgent condition until it is written. plan-moved and sent go with the plans. The controller's first-start deletions are said on a granted subject, a node hears only its backup holder, and the operator's account writes the hand path, an object replaced included (ADR 0539 clauses 2, 7, 10 and 13).
198 lines
8.1 KiB
Go
198 lines
8.1 KiB
Go
package broker
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/novox/mesh-controller/internal/beside"
|
|
)
|
|
|
|
// **The first user list the installer carries must be the one the controller would compose.**
|
|
//
|
|
// At genesis there is no mesh to write the bus's user list, so the installer carries one: the
|
|
// controller's own account, at a bootstrap password, the way the store is reached at
|
|
// `postgres:bootstrap` (novox/hq design 25 §4, task 1.7). It is written by hand in a template and
|
|
// derived in code here, which is two statements of one fact — so this compares them.
|
|
//
|
|
// Getting it wrong is the worst kind of silent: a controller whose carried permissions are narrower
|
|
// than the ones it derives comes up, connects, and is refused on the first thing it tries, with an
|
|
// authorisation error that names a subject and not the template that forgot it. And a mesh cannot be
|
|
// raised twice to find out.
|
|
func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T) {
|
|
accounts := theCarriedAccounts(t)
|
|
|
|
want, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
carriedPub := subjectsIn(accounts, "publish")
|
|
carriedSub := subjectsIn(accounts, "subscribe")
|
|
|
|
if diff := requiredPublishGap(t, want.Publish, carriedPub); len(diff) > 0 {
|
|
t.Errorf("the installer's user list does not let the controller publish %v — it would come up "+
|
|
"and be refused on the first thing it tried", diff)
|
|
}
|
|
if diff := missing(want.Subscribe, carriedSub); len(diff) > 0 {
|
|
t.Errorf("the installer's user list does not let the controller subscribe %v", diff)
|
|
}
|
|
// And nothing wider than what it derives, or genesis quietly grants a privilege the composition
|
|
// takes away again on the first push.
|
|
// Except the retired build seats' grants, which the installer's list still names until genesis stops installing the
|
|
// builder (issue 1152): the controller derives none of them (R4g, ADR 0525 §10), the first push takes them away.
|
|
carriedPub = slices.DeleteFunc(slices.Clone(carriedPub), func(s string) bool {
|
|
return strings.HasPrefix(s, "mesh.seat.node-build-agent.") || strings.HasPrefix(s, "mesh.seat.mesh-build-machine.") ||
|
|
strings.HasPrefix(s, "$KV.SEAT_NODE_BUILD_AGENT_") || strings.HasPrefix(s, "$KV.SEAT_MESH_BUILD_MACHINE_") ||
|
|
// And the plans' events release 6 retired (novox/hq ADR 0539), until mesh-host's genesis template drops them.
|
|
s == "mesh.seat.mesh-controller.event.plan-moved" || s == "mesh.seat.mesh-controller.event.sent"
|
|
})
|
|
if diff := missing(carriedPub, want.Publish); len(diff) > 0 {
|
|
t.Errorf("the installer's user list lets the controller publish %v, which it does not derive", diff)
|
|
}
|
|
if diff := missing(carriedSub, want.Subscribe); len(diff) > 0 {
|
|
t.Errorf("the installer's user list lets the controller subscribe %v, which it does not derive", diff)
|
|
}
|
|
|
|
// The credential is the bootstrap one and the hash really is of it, because a hash of something
|
|
// else is a controller that cannot log in to the bus it was just given.
|
|
hash := regexp.MustCompile(`\$2[aby]?\$[0-9]+\$[A-Za-z0-9./]{53}`).FindString(accounts)
|
|
if hash == "" {
|
|
t.Fatal("the installer's user list carries no password hash")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")); err != nil {
|
|
t.Fatalf("the carried hash does not verify the bootstrap credential the template also carries: %v", err)
|
|
}
|
|
}
|
|
|
|
// requiredPublishGap is what in want is missing from carried, leaving out a grant DegradableGrants names
|
|
// and saying why in the test log instead (novox/hq issue 835, the forge check of mesh-controller#345): the
|
|
// merge check reads mesh-host at the commit the machines run, which a change to mesh-host's examples alone
|
|
// never moves, so the carried list can lag the controller's derived one without the controller refusing to
|
|
// come up.
|
|
func requiredPublishGap(t *testing.T, want, carried []string) []string {
|
|
t.Helper()
|
|
diff := missing(want, carried)
|
|
var required []string
|
|
for _, subject := range diff {
|
|
if reason, ok := degradableReason(subject); ok {
|
|
t.Logf("the installer's user list does not let the controller publish %s, but that falls back "+
|
|
"rather than refuses: %s", subject, reason)
|
|
continue
|
|
}
|
|
required = append(required, subject)
|
|
}
|
|
return required
|
|
}
|
|
|
|
// degradableReason says whether a subject is one of DegradableGrants, and why.
|
|
func degradableReason(subject string) (string, bool) {
|
|
for _, g := range DegradableGrants {
|
|
if g.Subject == subject {
|
|
return g.Reason, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// TestADegradableGrantMissingFromTheInstallersListPasses is the forge check's own case (mesh-controller#345,
|
|
// novox/hq issue 835): an installer list that lacks operator-channel.tell alone, because mesh-host has not
|
|
// yet built with the grant, still passes — the controller falls back to the secret-given condition rather
|
|
// than coming up refused.
|
|
func TestADegradableGrantMissingFromTheInstallersListPasses(t *testing.T) {
|
|
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
|
|
carried := []string{"mesh.seat.node-service-manager.tool.restart.*"}
|
|
if diff := requiredPublishGap(t, want, carried); len(diff) > 0 {
|
|
t.Errorf("a degradable grant missing from the installer's list must not fail the genesis test, got %v", diff)
|
|
}
|
|
}
|
|
|
|
// TestANonDegradableGrantMissingFromTheInstallersListStillFails proves the exemption is named, not general:
|
|
// every other grant the controller derives stays required.
|
|
func TestANonDegradableGrantMissingFromTheInstallersListStillFails(t *testing.T) {
|
|
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
|
|
carried := []string{"mesh.seat.operator-channel.tool.tell"}
|
|
diff := requiredPublishGap(t, want, carried)
|
|
if len(diff) != 1 || diff[0] != "mesh.seat.node-service-manager.tool.restart.*" {
|
|
t.Errorf("a missing grant that is not degradable must still fail the genesis test, got %v", diff)
|
|
}
|
|
}
|
|
|
|
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
|
func theCarriedAccounts(t *testing.T) string {
|
|
t.Helper()
|
|
for _, r := range theTemplate(t) {
|
|
if r["id"] == "bus-accounts" {
|
|
content, _ := r["content"].(string)
|
|
if content == "" {
|
|
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
|
}
|
|
return content
|
|
}
|
|
}
|
|
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
|
return ""
|
|
}
|
|
|
|
// theTemplate is the installer's bundle, as resources: the node-engine's, as a merge check clones it at
|
|
// the commit the mesh runs, or as captured in testdata/beside (internal/beside, novox/hq issue 432).
|
|
func theTemplate(t *testing.T) []map[string]any {
|
|
t.Helper()
|
|
path := filepath.Join(beside.Dir(t, "mesh-host"), "examples", "foundation-first-node.lock")
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The template is JSON with line comments, which is how every one of them is written.
|
|
var lines []string
|
|
for _, l := range strings.Split(string(raw), "\n") {
|
|
if !strings.HasPrefix(strings.TrimSpace(l), "//") {
|
|
lines = append(lines, l)
|
|
}
|
|
}
|
|
var bundle struct {
|
|
Resources []map[string]any `json:"resources"`
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
|
|
t.Fatalf("the template is not readable: %v", err)
|
|
}
|
|
return bundle.Resources
|
|
}
|
|
|
|
// subjectsIn reads one allow-list out of a composed accounts file.
|
|
func subjectsIn(accounts, which string) []string {
|
|
found := regexp.MustCompile(which + `: \{ allow: \[([^\]]*)\]`).FindStringSubmatch(accounts)
|
|
if len(found) != 2 {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, part := range strings.Split(found[1], ",") {
|
|
if s := strings.Trim(strings.TrimSpace(part), `"`); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// missing is what is in want and not in got.
|
|
func missing(want, got []string) []string {
|
|
have := map[string]bool{}
|
|
for _, g := range got {
|
|
have[g] = true
|
|
}
|
|
var out []string
|
|
for _, w := range want {
|
|
if !have[w] {
|
|
out = append(out, w)
|
|
}
|
|
}
|
|
return out
|
|
}
|