The bus on NATS: both transports behind seams, and the rollout switch #87
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user