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.
157 lines
5.3 KiB
Go
157 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|