Genesis can raise a mesh on the new bus, and the carried user list is checked against the composer

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.
This commit is contained in:
2026-09-27 16:39:19 +02:00
parent e4e960ec1c
commit 8e2824201a
8 changed files with 248 additions and 2 deletions
+17
View File
@@ -702,6 +702,23 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
} }
defer js.Close() 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) nodes, err := inv.Nodes(ctx)
if err != nil { if err != nil {
return err return err
+126
View File
@@ -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
}
-1
View File
@@ -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. // like the catalogue does — which is why no holder needs to publish into anybody's inbox.
for _, seat := range meshSeatsTheControllerUses { for _, seat := range meshSeatsTheControllerUses {
pub = append(pub, "mesh.seat."+seat+".accept.>") 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 // The two events it reacts to, and its ack subject on the stream they arrive from
+20
View File
@@ -35,6 +35,26 @@ func OnNATS() (address string, on bool, err error) {
return address, true, nil 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. // 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 // **Both clients ship and that is the point; both being live is not.** The rollout moves every node
+20
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ accounts {
users = [ users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { { 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.>"] } 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" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
+27
View File
@@ -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) _, err := i.store.Pool().Exec(ctx, `delete from bus_user where node = $1`, node)
return err 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
}
+37
View File
@@ -121,3 +121,40 @@ func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) {
t.Fatal("forgetting the bus users of no node was allowed") 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")
}
}