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 <> '';