The bus on NATS: both transports behind seams, and the rollout switch #87

Merged
jschoubben merged 40 commits from feat/nats-genesis into main 2026-09-27 17:36:41 +00:00
5 changed files with 578 additions and 0 deletions
Showing only changes of commit 0560c792d8 - Show all commits
+125
View File
@@ -0,0 +1,125 @@
package broker
import (
"fmt"
"sort"
)
// Every user the composed file should contain, derived from what the mesh knows.
//
// **The list is derived, never kept.** A stored user list would be a second account of who may
// reach the bus, able to disagree with the records it came from — and the disagreement would be
// invisible, because both would look internally consistent. So this is a pure function of the
// mesh's records, run again every time the file is written.
//
// Records are mirrored into this package's own types rather than imported from the catalogue, for
// the reason DeclaredSeat is: composing authority is a different job from parsing a manifest, and
// this package stays free of the other's types so a change to a manifest field cannot quietly widen
// a permission.
// Declared is one module on one node, as composing its authority needs it.
type Declared struct {
Module string
Emits []string
Consumes []string
Serves []string
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
// which is right: those seats are about who does a job, not about who may say what.
Holds []Seat
// Uses are the seats this module sends to.
Uses []Seat
}
// Records is what composing a user list needs to know about the mesh, and nothing more.
type Records struct {
// Nodes is every machine the mesh knows. Each gets a host user.
Nodes []string
// Assigned is the modules on each node, as they declare themselves.
Assigned map[string][]Declared
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
// answer goes to is scoped to the token and a shared one is one machine reading another's
// sealed credentials (design 25 §6).
Enrolling []string
// People is each person's name against the tools they may invoke, `*` for an administrator.
People map[string][]string
}
// Users is every user the composed file should contain, in the order it will be written.
//
// The controller is always first and always present: a mesh whose own controller is not in the file
// is a mesh that cannot be told anything, and there is no state of the records in which that is
// correct.
func Users(r Records) ([]Principal, error) {
out := []Principal{{Kind: KindController}}
for _, node := range sortedCopy(r.Nodes) {
out = append(out, Principal{Kind: KindNode, Node: node})
for _, d := range r.Assigned[node] {
out = append(out, Principal{
Kind: KindModule, Node: node, Module: d.Module,
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
Holds: d.Holds, Uses: d.Uses,
})
}
}
for _, node := range sortedCopy(r.Enrolling) {
out = append(out, Principal{Kind: KindEnrolment, Node: node})
}
for _, person := range sortedNames(r.People) {
out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]})
}
// Refused here rather than discovered by the server. Two users with one name is a file the
// server reads as one of them, and which one depends on the order — so a module assigned to a
// node twice, or a person named after nothing, is a composition that must not be written.
seen := map[string]string{}
for _, p := range out {
name := p.Username()
if name == "" || name == "." {
return nil, fmt.Errorf("a %s user has no name, so nothing could authenticate as it", p.Kind)
}
if first, already := seen[name]; already {
return nil, fmt.Errorf(
"two users would be called %q (a %s and a %s): the server would read the file as "+
"one of them, and which one depends on the order", name, first, p.Kind)
}
seen[name] = string(p.Kind)
}
return out, nil
}
// WithPasswords fills each user's hash from what the mesh minted, and says which users have none.
//
// **Separated from Users because they fail differently.** A user missing from the records is a bug
// in deriving them; a user with no password is a step that has not happened yet — a module assigned
// but never given a credential, a node enrolled before this existed. The second is ordinary and its
// remedy is to mint one, so it is named rather than returned as an error, and the caller decides
// whether a partial composition is worth writing.
func WithPasswords(principals []Principal, hashes map[string]string) (filled []Principal, missing []string) {
for _, p := range principals {
hash, ok := hashes[p.Username()]
if !ok || hash == "" {
missing = append(missing, p.Username())
continue
}
p.PasswordHash = hash
filled = append(filled, p)
}
return filled, missing
}
func sortedCopy(in []string) []string {
out := append([]string(nil), in...)
sort.Strings(out)
return out
}
func sortedNames(in map[string][]string) []string {
out := make([]string, 0, len(in))
for k := range in {
out = append(out, k)
}
sort.Strings(out)
return out
}
+156
View File
@@ -0,0 +1,156 @@
package broker
import (
"strings"
"testing"
)
// Deriving the bus's user list from the mesh's records.
//
// Every test here is about a way the list could be wrong that the server would not tell anybody
// about: a user missing, a user named twice, a user with authority it did not declare.
func someRecords() Records {
return Records{
Nodes: []string{"two", "one"},
Assigned: map[string][]Declared{
"one": {{Module: "telegram", Serves: []string{"status"}}},
"two": {{Module: "shop", Emits: []string{"order.placed"}}},
},
Enrolling: []string{"three"},
People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}},
}
}
func namesOf(t *testing.T, r Records) []string {
t.Helper()
users, err := Users(r)
if err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(users))
for _, u := range users {
out = append(out, u.Username())
}
return out
}
// The controller is always there. A mesh whose own controller is not in the file is a mesh that
// cannot be told anything, and there is no state of the records in which that is correct.
func TestTheControllerIsAlwaysInTheList(t *testing.T) {
for _, r := range []Records{{}, someRecords()} {
names := namesOf(t, r)
if len(names) == 0 || names[0] != "controller" {
t.Fatalf("the controller is not first in %v", names)
}
}
}
// One user per node, one per module per node, one per live token and one per person — and nothing
// else, because a user nobody derived is a user nobody can explain.
func TestEveryRecordBecomesExactlyOneUser(t *testing.T) {
names := namesOf(t, someRecords())
want := []string{
"controller",
"node.one", "one.telegram",
"node.two", "two.shop",
"enrol.three",
"person.ada",
}
if strings.Join(names, ",") != strings.Join(want, ",") {
t.Fatalf("derived %v\n want %v", names, want)
}
}
// Two users with one name is a file the server reads as one of them, and which one depends on the
// order. Refused here, where both can be named, rather than left to be whichever the server picked.
func TestTwoUsersWithOneNameAreRefused(t *testing.T) {
r := someRecords()
r.Assigned["one"] = append(r.Assigned["one"], Declared{Module: "telegram"})
_, err := Users(r)
if err == nil {
t.Fatal("a module assigned twice to one node composed two users with one name")
}
if !strings.Contains(err.Error(), "one.telegram") {
t.Fatalf("the refusal does not name the user: %v", err)
}
}
// A module's authority is what it declared and nothing more, carried through the derivation intact —
// because this is the step where a mistake would grant something no manifest asked for.
func TestAModulesAuthorityIsWhatItDeclared(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered"}}
users, err := Users(Records{
Nodes: []string{"one"},
Assigned: map[string][]Declared{"one": {{
Module: "shop", Emits: []string{"order.placed"}, Uses: []Seat{seat},
}}},
})
if err != nil {
t.Fatal(err)
}
perms, err := PermissionsFor(users[len(users)-1])
if err != nil {
t.Fatal(err)
}
has(t, perms.Publish, "mesh.mod.shop.event.order.placed")
has(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
// A seat it uses, not one it holds: it may submit work and may not publish the seat's own
// events, or it could lie about outcomes on a role somebody else fills.
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.event.delivered")
hasNot(t, perms.Subscribe, "mesh.seat.telegram-sender.accept.send")
}
// A user the mesh has never minted a password for is named rather than silently dropped or
// composed as a user anybody is. It is an ordinary situation — a module assigned a moment ago — and
// the remedy is to mint one, so the caller decides whether to write a partial file.
func TestAUserWithNoPasswordIsNamedRatherThanWritten(t *testing.T) {
users, err := Users(someRecords())
if err != nil {
t.Fatal(err)
}
filled, missing := WithPasswords(users, map[string]string{
"controller": "$2a$hash", "node.one": "$2a$hash",
})
if len(filled) != 2 {
t.Fatalf("composed %d users from two hashes", len(filled))
}
if len(missing) != len(users)-2 {
t.Fatalf("%d users are missing a password, of %d: %v", len(missing), len(users), missing)
}
for _, p := range filled {
if p.PasswordHash == "" {
t.Fatalf("%s was kept with no password, which is a user anybody is", p.Username())
}
}
}
// And the whole thing composes: records in, a file the server would read out.
func TestRecordsComposeIntoAFile(t *testing.T) {
users, err := Users(someRecords())
if err != nil {
t.Fatal(err)
}
hashes := map[string]string{}
for _, u := range users {
hashes[u.Username()] = "$2a$11$" + strings.Repeat("x", 22)
}
filled, missing := WithPasswords(users, hashes)
if len(missing) != 0 {
t.Fatalf("users with no password: %v", missing)
}
got, err := Compose(Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, filled)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`user: "controller"`, `user: "node.one"`, `user: "one.telegram"`,
`user: "enrol.three"`, `user: "person.ada"`,
`"_INBOX.enrol.three.>"`, `"mesh.mod.mesh-catalog.tool.catalog_tools"`,
} {
if !strings.Contains(got, want) {
t.Errorf("the composed file does not contain %s", want)
}
}
}
+138
View File
@@ -0,0 +1,138 @@
package inventory
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
// The bus's own users, as records.
//
// **Only the credential is kept here.** A user's *authority* is derived from what its module
// declares, every time the file is written (novox/hq ADR 0043) — a stored copy of a permission list
// would be a second account of a user's authority, able to disagree with the first, and the
// disagreement would be invisible until somebody compared a composed file with a manifest.
//
// What cannot be derived is the password, and on the bus being built it has to outlive its own
// minting: the whole user list is one file, rewritten whenever any of it changes, so a person's
// access change would blank every module's password if the mesh kept nothing (design 25 §4, and the
// migration beside this).
// BusUser is one user of the bus, as the mesh records it.
type BusUser struct {
Username string
Kind string
Node string
Module string
// PasswordHash is what the composed file carries. The plaintext is returned once, by Mint, and
// then exists only where it was sealed.
PasswordHash string
}
// The kinds of bus user the mesh records. The same words the composer uses, so a row and a
// principal do not need a translation table between them.
const (
BusController = "controller"
BusNode = "node"
BusModule = "module"
BusEnrolment = "enrolment"
BusPerson = "person"
)
// MintBusPassword makes a bus password and records its hash under a username, replacing whatever was
// there, and returns the plaintext **once**.
//
// **Once is the whole contract.** The caller seals it to whoever will use it — into an enrolment
// reply, into a module's sealed environment — and the mesh keeps only the hash, so a credential is
// never recoverable from the store. A caller that loses it must mint again, which is a rotation and
// is meant to feel like one.
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
if u.Username == "" || u.Kind == "" {
return "", errors.New("a bus user needs a username and a kind")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("cannot generate a bus password: %w", err)
}
password := base64.RawURLEncoding.EncodeToString(raw)
// The cost the server will pay on every connection. Left at the library's default rather than
// raised: a node reconnecting after a network blip pays it, and the mesh's own links reconnect
// far more often than a person logs in anywhere.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("cannot hash a bus password: %w", err)
}
if _, 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 update
set kind = excluded.kind, node = excluded.node, module = excluded.module,
password_hash = excluded.password_hash, minted_at = now()`,
u.Username, u.Kind, u.Node, u.Module, string(hash)); err != nil {
return "", fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
}
return password, nil
}
// BusUsers is every user the composed file should contain, by username.
//
// Returned as a map because the composer asks by username: the principals are derived from records
// elsewhere, and this is only what each one's password is. A principal with no row here has no
// password, and the composer refuses it rather than writing a user anybody is.
func (i *Inventory) BusUsers(ctx context.Context) (map[string]BusUser, error) {
rows, err := i.store.Pool().Query(ctx,
`select username, kind, node, module, password_hash from bus_user order by username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]BusUser{}
for rows.Next() {
var u BusUser
if err := rows.Scan(&u.Username, &u.Kind, &u.Node, &u.Module, &u.PasswordHash); err != nil {
return nil, err
}
out[u.Username] = u
}
return out, rows.Err()
}
// BusUserHash is one user's hash, or false when the mesh has never minted one for it.
func (i *Inventory) BusUserHash(ctx context.Context, username string) (string, bool, error) {
var hash string
err := i.store.Pool().QueryRow(ctx,
`select password_hash from bus_user where username = $1`, username).Scan(&hash)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return hash, err == nil, err
}
// ForgetBusUser removes one user, so the next composition does not contain it.
//
// **Removal is what makes revocation real here.** On a bus with a management call, deleting an
// account ends its connections; here the credential stops working when the file no longer names it,
// which is the next composition — so forgetting the row and composing are one act, and a caller
// that does the first without the second has revoked nothing.
func (i *Inventory) ForgetBusUser(ctx context.Context, username string) error {
_, err := i.store.Pool().Exec(ctx, `delete from bus_user where username = $1`, username)
return err
}
// ForgetBusUsersOf removes every user belonging to one node — its host's, and every module assigned
// to it. What a forgotten node leaves behind on the bus is otherwise a set of credentials for a
// machine the mesh no longer knows.
func (i *Inventory) ForgetBusUsersOf(ctx context.Context, node string) error {
if node == "" {
return errors.New("forgetting the bus users of no node would forget every user that has none")
}
_, err := i.store.Pool().Exec(ctx, `delete from bus_user where node = $1`, node)
return err
}
+123
View File
@@ -0,0 +1,123 @@
package inventory
import (
"context"
"testing"
"golang.org/x/crypto/bcrypt"
)
// The bus's users as records — against a real store, because what is being checked is that the
// column exists, the upsert behaves, and a plaintext is returned exactly once.
func aBusUser(module string) BusUser {
return BusUser{Username: "one." + module, Kind: BusModule, Node: "one", Module: module}
}
// The plaintext comes back once and the store keeps only a hash that verifies against it. **A
// credential recoverable from the mesh's store is one whose blast radius is the store's**, so what
// is asserted is that the password is not in there.
func TestABusPasswordIsReturnedOnceAndOnlyItsHashIsKept(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
password, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
if password == "" {
t.Fatal("no password came back, so nothing can be sealed to the module")
}
hash, known, err := inv.BusUserHash(ctx, "one.shop")
if err != nil || !known {
t.Fatalf("the user was not recorded: %v %v", known, err)
}
if hash == password {
t.Fatal("the store holds the password itself")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
t.Fatalf("the recorded hash does not verify the password it was made from: %v", err)
}
}
// Minting again replaces what was there rather than failing or adding a second row: that is a
// rotation, and the old credential stops working at the next composition.
func TestMintingAgainRotatesRatherThanAddsAUser(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
first, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
second, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("minting twice produced the same password")
}
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 1 {
t.Fatalf("%d users after two mints for one name", len(users))
}
hash := users["one.shop"].PasswordHash
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(second)); err != nil {
t.Fatal("the kept hash is not the newest password's")
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(first)) == nil {
t.Fatal("the previous password still verifies, so a rotation revoked nothing")
}
}
// Forgetting a node takes every credential that belonged to it — its host's and every module
// assigned to it. What a forgotten node leaves behind otherwise is a working set of credentials for
// a machine the mesh no longer knows.
func TestForgettingANodeTakesItsBusUsersWithIt(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
for _, u := range []BusUser{
{Username: "node.one", Kind: BusNode, Node: "one"},
aBusUser("shop"),
{Username: "node.two", Kind: BusNode, Node: "two"},
{Username: "controller", Kind: BusController},
} {
if _, err := inv.MintBusPassword(ctx, u); err != nil {
t.Fatal(err)
}
}
if err := inv.ForgetBusUsersOf(ctx, "one"); err != nil {
t.Fatal(err)
}
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
if _, still := users["node.one"]; still {
t.Fatal("a forgotten node's host credential still works")
}
if _, still := users["one.shop"]; still {
t.Fatal("a module on a forgotten node still has a credential")
}
// And nothing else went with it: the controller has no node, and another machine's user is
// another machine's.
for _, kept := range []string{"node.two", "controller"} {
if _, ok := users[kept]; !ok {
t.Fatalf("%s was removed with another node's users", kept)
}
}
}
// Forgetting the users of no node would forget every user that has none — the controller and every
// person — so it is refused rather than run.
func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) {
inv := ForTest(t)
if err := inv.ForgetBusUsersOf(context.Background(), ""); err == nil {
t.Fatal("forgetting the bus users of no node was allowed")
}
}
@@ -0,0 +1,36 @@
-- Every bus user's password hash, because the file has to be written again.
--
-- novox/hq design 25 §4, task 1.7. On the bus the mesh runs on today an account is created by a
-- management call: the mesh mints a password, hands it over, seals the plaintext to whoever will
-- use it, and keeps nothing. That works because the broker remembers.
--
-- The bus being built has no management call — its users are a file the controller composes, and
-- **the whole file is written every time any of it changes**. So the first person's access change
-- would silently blank every module's password. The hash has to outlive its own minting, which is
-- state the mesh did not need before and does now.
--
-- Keyed by username, because the username is exactly what the composed file needs and what a
-- principal derives from its own identity. Nothing else about the user is here: **permissions are
-- not stored.** They are derived from what each module declares, every time the file is written
-- (ADR 0043) — a stored copy would be a second account of a user's authority, able to disagree
-- with the first, and the disagreement would be invisible until somebody compared a file with a
-- manifest.
--
-- The hash and not the password. A file on a node's disk holds the hash, and so does this: a
-- credential recoverable from the mesh's store is one whose blast radius is the store's.
create table bus_user (
username text primary key,
-- kind and what it names, so a user whose subject is gone can be found and removed: a module
-- unassigned, a node forgotten, a token spent. Recorded rather than parsed back out of the
-- username, because a name is for the server and a parser over it would be a second grammar.
kind text not null,
node text not null default '',
module text not null default '',
password_hash text not null,
minted_at timestamptz not null default now()
);
-- Finding every user of one kind, and every user belonging to one node — which is what removing a
-- node, or composing after an assignment, asks.
create index bus_user_kind on bus_user (kind);
create index bus_user_node on bus_user (node) where node <> '';