From 0560c792d84d1d25c8bcff2d672e89c7b789a07f Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:44:53 +0200 Subject: [PATCH] 1.7, first half: the mesh can say who its bus users are, and hold their keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces the composer has been waiting for since it was written. **The credential has to outlive its own minting.** On the bus the mesh runs on today an account is a management call: mint a password, hand it over, seal the plaintext to whoever will use it, keep nothing — which works because the broker remembers. Here the users are one file, rewritten whenever any of it changes, so keeping nothing would mean the first person's access change silently blanking every module's password. So a bus user's bcrypt hash is now recorded, keyed by the username the file needs, and the plaintext comes back exactly once. Verified against a real store that the hash verifies the password it was made from, that the password itself is not in there, that minting again rotates rather than adds, and that forgetting a node takes its host's and its modules' credentials with it. **Permissions are not stored, and that is the point.** Only the credential is kept. Authority is derived from what each module declares, every time the file is written (ADR 0043) — a stored permission list would be a second account of a user's authority, able to disagree with the records it came from, and both would look internally consistent while they did. `Users` derives the list: the controller always first and always present, one user per node, one per module per node, one per live token, one per person. Two users with one name is refused where both can be named, rather than left to be whichever one the server happened to read. A user the mesh has never minted a password for is *named* rather than dropped or written as a user anybody is: that is an ordinary situation with an obvious remedy, and the caller decides whether a partial file is worth writing. What remains of 1.7: delivering the file to the node that runs the server, and minting at enrolment and assignment — which is transport-coupled, because a node on the old bus must not be handed a credential for the new one. --- internal/broker/users.go | 125 ++++++++++++++ internal/broker/users_test.go | 156 ++++++++++++++++++ internal/inventory/bususers.go | 138 ++++++++++++++++ internal/inventory/bususers_test.go | 123 ++++++++++++++ .../0033-the-bus-keeps-its-users-hashes.sql | 36 ++++ 5 files changed, 578 insertions(+) create mode 100644 internal/broker/users.go create mode 100644 internal/broker/users_test.go create mode 100644 internal/inventory/bususers.go create mode 100644 internal/inventory/bususers_test.go create mode 100644 internal/inventory/migrations/0033-the-bus-keeps-its-users-hashes.sql diff --git a/internal/broker/users.go b/internal/broker/users.go new file mode 100644 index 0000000..f6fc289 --- /dev/null +++ b/internal/broker/users.go @@ -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 +} diff --git a/internal/broker/users_test.go b/internal/broker/users_test.go new file mode 100644 index 0000000..5787b9e --- /dev/null +++ b/internal/broker/users_test.go @@ -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) + } + } +} diff --git a/internal/inventory/bususers.go b/internal/inventory/bususers.go new file mode 100644 index 0000000..c9a43a7 --- /dev/null +++ b/internal/inventory/bususers.go @@ -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 +} diff --git a/internal/inventory/bususers_test.go b/internal/inventory/bususers_test.go new file mode 100644 index 0000000..7af4523 --- /dev/null +++ b/internal/inventory/bususers_test.go @@ -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") + } +} diff --git a/internal/inventory/migrations/0033-the-bus-keeps-its-users-hashes.sql b/internal/inventory/migrations/0033-the-bus-keeps-its-users-hashes.sql new file mode 100644 index 0000000..0ac7ee0 --- /dev/null +++ b/internal/inventory/migrations/0033-the-bus-keeps-its-users-hashes.sql @@ -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 <> '';