diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index f499281..71cee3b 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -702,6 +702,23 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string) } defer js.Close() + // **Its own user, before anything else.** The controller's account is created by the installer at + // a bootstrap password, before there is a controller to mint one — so nothing recorded a hash for + // it, and the first composition would leave the writer out of the file it was writing. Recorded + // only if absent: a credential the mesh minted since is the one that counts. + // **Its own user, before anything else it does here.** The controller's account is created by the + // installer at a bootstrap password, before there is a controller to mint one — so nothing + // recorded a hash for it, and the first composition would leave the writer out of the file it was + // writing: a bus nothing can connect to, produced by the thing connected to it. Recorded only if + // absent, so a restart cannot put the bootstrap credential back over a rotated one. + if user, password, _ := broker.CredentialIn(address); user != "" && password != "" { + if err := inv.SeedBusUser(ctx, inventory.BusUser{ + Username: user, Kind: inventory.BusController, + }, password); err != nil { + return fmt.Errorf("cannot record the credential this control plane is using: %w", err) + } + } + nodes, err := inv.Nodes(ctx) if err != nil { return err diff --git a/internal/broker/genesis_template_test.go b/internal/broker/genesis_template_test.go new file mode 100644 index 0000000..1260f85 --- /dev/null +++ b/internal/broker/genesis_template_test.go @@ -0,0 +1,126 @@ +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 +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 45da6b0..295f2e4 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -167,7 +167,6 @@ func PermissionsFor(p Principal) (Permissions, error) { // like the catalogue does — which is why no holder needs to publish into anybody's inbox. for _, seat := range meshSeatsTheControllerUses { pub = append(pub, "mesh.seat."+seat+".accept.>") - sub = append(sub, "mesh.seat."+seat+".event.>") } // The two events it reacts to, and its ack subject on the stream they arrive from diff --git a/internal/broker/onnats.go b/internal/broker/onnats.go index f6efa54..12d81ed 100644 --- a/internal/broker/onnats.go +++ b/internal/broker/onnats.go @@ -35,6 +35,26 @@ func OnNATS() (address string, on bool, err error) { return address, true, nil } +// CredentialIn reads the user and password out of a bus address, and the address without them. +// +// The controller's own credential arrives in its address, the way the old bus's does. Split out so the +// controller can record a hash of what it is actually using: its user is created by the installer at a +// bootstrap password, before the controller exists to mint one, and a composition that left itself out +// would produce a bus the writer cannot connect to. +func CredentialIn(address string) (user, password, bare string) { + at := strings.LastIndex(address, "@") + if at < 0 { + return "", "", address + } + scheme := "" + rest := address[:at] + if i := strings.Index(rest, "://"); i >= 0 { + scheme, rest = rest[:i+3], rest[i+3:] + } + user, password, _ = strings.Cut(rest, ":") + return user, password, scheme + address[at+1:] +} + // MustBeOneBus refuses a configuration that names both buses for the mesh's own traffic. // // **Both clients ship and that is the point; both being live is not.** The rollout moves every node diff --git a/internal/broker/onnats_test.go b/internal/broker/onnats_test.go index 19e78e9..f21bc87 100644 --- a/internal/broker/onnats_test.go +++ b/internal/broker/onnats_test.go @@ -38,3 +38,23 @@ func TestOneBusOrNeitherIsAllowed(t *testing.T) { } } } + +// The controller's own credential arrives in its address, and has to be readable out of it — its user +// is created by the installer at a bootstrap password, before the controller exists to mint one. +func TestACredentialIsReadOutOfABusAddress(t *testing.T) { + for _, c := range []struct{ in, user, password, bare string }{ + {"nats://controller:secret@127.0.0.1:4222", "controller", "secret", "nats://127.0.0.1:4222"}, + {"controller:secret@127.0.0.1:4222", "controller", "secret", "127.0.0.1:4222"}, + {"nats://127.0.0.1:4222", "", "", "nats://127.0.0.1:4222"}, + {"127.0.0.1:4222", "", "", "127.0.0.1:4222"}, + // A password containing an at-sign: split on the last one, or the address becomes part of the + // credential and the connection goes somewhere nobody named. + {"nats://controller:a@b@127.0.0.1:4222", "controller", "a@b", "nats://127.0.0.1:4222"}, + } { + user, password, bare := CredentialIn(c.in) + if user != c.user || password != c.password || bare != c.bare { + t.Errorf("%q read as %q/%q at %q; wanted %q/%q at %q", + c.in, user, password, bare, c.user, c.password, c.bare) + } + } +} diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 8e44a6e..f64b2e8 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,7 +24,7 @@ accounts { users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>"] } - subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.>", "mesh.seat.mesh-build-machine.event.built"] } + subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { diff --git a/internal/inventory/bususers.go b/internal/inventory/bususers.go index c9a43a7..780a193 100644 --- a/internal/inventory/bususers.go +++ b/internal/inventory/bususers.go @@ -136,3 +136,30 @@ func (i *Inventory) ForgetBusUsersOf(ctx context.Context, node string) error { _, err := i.store.Pool().Exec(ctx, `delete from bus_user where node = $1`, node) return err } + +// SeedBusUser records a hash of a credential the mesh did not mint, so a composition contains it. +// +// **Genesis is the reason this exists.** The controller's own user is created before the controller +// runs — by the installer, at a well-known bootstrap password, the way the store's and the old bus's +// are (`postgres:bootstrap`, `guest:guest`). Nothing minted it, so nothing recorded a hash for it, and +// the controller's first composition would leave itself out of the very file it was writing: a bus +// nothing can connect to, produced by the thing connected to it. +// +// Idempotent, and it does not overwrite. A credential the mesh *did* mint is the one that counts, so +// once there is a row this does nothing — otherwise a restart would put the bootstrap password back +// over a rotated one. +func (i *Inventory) SeedBusUser(ctx context.Context, u BusUser, password string) error { + if u.Username == "" || u.Kind == "" || password == "" { + return errors.New("a bus user needs a username, a kind and the credential it is using") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("cannot hash a bus password: %w", err) + } + _, err = i.store.Pool().Exec(ctx, + `insert into bus_user (username, kind, node, module, password_hash) + values ($1, $2, $3, $4, $5) + on conflict (username) do nothing`, + u.Username, u.Kind, u.Node, u.Module, string(hash)) + return err +} diff --git a/internal/inventory/bususers_test.go b/internal/inventory/bususers_test.go index 7af4523..199b660 100644 --- a/internal/inventory/bususers_test.go +++ b/internal/inventory/bususers_test.go @@ -121,3 +121,40 @@ func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) { t.Fatal("forgetting the bus users of no node was allowed") } } + +// The controller's own user is created by the installer, so the mesh has to be able to record a +// credential it did not mint — or the first composition leaves the writer out of the file it writes. +func TestACredentialTheMeshDidNotMintIsRecordedOnceAndNotOverwritten(t *testing.T) { + inv := ForTest(t) + ctx := context.Background() + + if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController}, + "bootstrap"); err != nil { + t.Fatal(err) + } + hash, known, err := inv.BusUserHash(ctx, "controller") + if err != nil || !known { + t.Fatalf("the credential was not recorded: %v %v", known, err) + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")); err != nil { + t.Fatalf("what was recorded does not verify the credential given: %v", err) + } + + // Minted since, then seeded again — which is what a restart does. The rotation must stand, or + // every restart would put the bootstrap password back over it. + minted, err := inv.MintBusPassword(ctx, BusUser{Username: "controller", Kind: BusController}) + if err != nil { + t.Fatal(err) + } + if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController}, + "bootstrap"); err != nil { + t.Fatal(err) + } + hash, _, err = inv.BusUserHash(ctx, "controller") + if err != nil { + t.Fatal(err) + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(minted)); err != nil { + t.Fatal("a restart put the bootstrap credential back over a rotated one") + } +}