The mesh writes its own user list, and at genesis there is no mesh yet to write it. So the installer carries the first one — the controller's own account at a well-known bootstrap password, exactly as the store is reached at `postgres:bootstrap` and the old bus at `guest:guest`, and rotated with them. From the controller's first composition onward the file is the controller's. That left a gap I would not have found by reading: the controller's own account is created before there is a controller to mint one, so nothing recorded a hash for it, and its first composition would have left the writer out of the file it was writing — a bus nothing can connect to, produced by the thing connected to it. It now records a hash of the credential it is actually using, and only if none is recorded, so a restart cannot put the bootstrap password back over a rotated one. The carried list and the derived one are two statements of one fact, so a test compares them: every subject the controller derives must be in the template, and nothing wider. It earned itself immediately — the composer was granting both a role's whole event branch and the one event it actually follows, which is a wider way of saying the same thing, and the wider one wins. Only the submitting half of a role is granted now; what comes back is named exactly. Getting this 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 naming a subject and not the template that forgot it — and a mesh cannot be raised twice to find out.
127 lines
4.5 KiB
Go
127 lines
4.5 KiB
Go
package broker
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// **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 := missing(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.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
|
func theCarriedAccounts(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Skipf("the host's checkout is not beside this one: %v", 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)
|
|
}
|
|
for _, r := range bundle.Resources {
|
|
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 ""
|
|
}
|
|
|
|
// 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
|
|
}
|