The delivery question, decided. The alternative was a manifest field enumerating the server's ports, TLS paths and store directory so the controller could write a whole configuration file. That is wrong: those are properties of the container the module raises, they live in its image and its mounts, and the controller would have to be kept in step with a Dockerfile it never sees. So the mesh writes only what only the mesh knows — who may connect — and the module's own configuration includes it. `ComposeAccounts` is that file. A test says what must *not* be in it as plainly as what must: no port, no tls block, no store_dir. Each of those in the mesh's file is a value the controller would then own, and the module could no longer change its own image without the mesh agreeing. `bus-users` is where a module wants it written, and **asking is not enough to receive it**: the file holds every user's password hash, so a module that could ask for it could read every credential on the bus. The claim on `mesh-broker` authorises it, checked from the manifest alone. A holder with nothing composed is refused rather than given an empty file, for the reason a certificate is — a bus with no user list refuses every connection in the mesh and looks like a machine problem. Six claims checked against a running server before any of this was committed to, and two of them changed what got written: **An absolute include path is resolved relative to the including file's directory.** `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf makes the server look for /etc/nats-server/etc/nats/accounts.conf and refuse to start. So both files share one directory, and the module declares its own as a file resource beside the mesh's. **`verify: true` was refusing every connection in the mesh.** It makes the server demand a *client* certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and authenticates with the password the mesh minted, and so does a module's runtime. Every connection died at the TLS handshake before any password was looked at, with an error — "client didn't provide a certificate" — that reads as a fault in the client. Removed. TLS is still required; verify only decides whether client certificates are checked. The other four: a user in an included file authenticates, an unknown user is refused so the include is the whole authority rather than an addition, a publish outside a grant is refused, and rewriting the mesh's half alone makes a new user appear — noticed by the module's own watcher, with no signal from outside, and without dropping the connection the mesh already had. That last one is task 1.2's payoff, collected.
204 lines
7.2 KiB
Go
204 lines
7.2 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The accounts block alone is what the mesh writes, and it holds nothing about the server.
|
|
//
|
|
// **The split is the whole design decision** (ComposeAccounts): ports, TLS paths and a store
|
|
// directory are properties of the container the module raises, and a controller that wrote them
|
|
// would have to be kept in step with a Dockerfile it never sees. So this test says what must not be
|
|
// in the file as plainly as what must.
|
|
func TestWhatTheMeshWritesIsUsersAndNothingAboutTheServer(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 := ComposeAccounts(filled)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, want := range []string{"accounts {", `user: "controller"`, `user: "one.telegram"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("the accounts file does not contain %s", want)
|
|
}
|
|
}
|
|
// None of the server's own settings. Each of these in the mesh's file is a value the controller
|
|
// would then own, and the module could no longer change its own image without the mesh agreeing.
|
|
for _, absent := range []string{"port:", "http:", "jetstream", "tls {", "store_dir", "cert_file"} {
|
|
if strings.Contains(got, absent) {
|
|
t.Errorf("the accounts file contains %q, which belongs to the module that raises the "+
|
|
"server, not to the mesh", absent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A user with no password is refused here too, not only by the whole-file composition: this is the
|
|
// function the controller actually calls, and a user without a password is a user anybody is.
|
|
func TestTheAccountsFileRefusesAUserWithNoPassword(t *testing.T) {
|
|
if _, err := ComposeAccounts([]Principal{{Kind: KindController}}); err == nil {
|
|
t.Fatal("a user with no password hash was written")
|
|
}
|
|
}
|