The mesh composes the accounts; the module owns its server
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.
This commit is contained in:
+42
-2
@@ -365,20 +365,60 @@ func Compose(s Server, principals []Principal) (string, error) {
|
||||
fmt.Fprintf(&b, "port: %d\n", s.ClientPort)
|
||||
fmt.Fprintf(&b, "http: 127.0.0.1:%d\n\n", s.MonitoringPort)
|
||||
|
||||
// **No `verify`, and it said `verify: true` until this configuration was run.** That setting
|
||||
// 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
|
||||
// (ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection in the
|
||||
// mesh is refused at the TLS handshake before any password is looked at, and the error —
|
||||
// "client didn't provide a certificate" — reads as a fault in the client.
|
||||
//
|
||||
// TLS is still required: the block is what requires it, and verify only decides whether client
|
||||
// certificates are checked.
|
||||
b.WriteString("tls {\n")
|
||||
fmt.Fprintf(&b, " cert_file: %q\n", s.TLSCert)
|
||||
fmt.Fprintf(&b, " key_file: %q\n", s.TLSKey)
|
||||
fmt.Fprintf(&b, " ca_file: %q\n", s.TLSCA)
|
||||
b.WriteString(" verify: true\n")
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
b.WriteString("jetstream {\n")
|
||||
fmt.Fprintf(&b, " store_dir: %q\n", s.StoreDir)
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
accounts, err := ComposeAccounts(sorted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(accounts)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ComposeAccounts is the accounts block alone — every user, and nothing about the server.
|
||||
//
|
||||
// **This is the only part of the configuration the mesh writes, and the split is deliberate.** A
|
||||
// server's ports, its TLS paths and its store directory are properties of the container the module
|
||||
// raises: they live in its image and its mounts, and they change when it does. The controller has no
|
||||
// business knowing them, and a controller that did would have to be kept in step with a Dockerfile
|
||||
// it never sees. What only the mesh knows is *who may connect*, so that is what it writes, and the
|
||||
// module's own configuration includes it.
|
||||
//
|
||||
// Four things checked against a running server before this shape was committed to: a user in an
|
||||
// included file authenticates; an unknown user is refused, so the include is the whole authority
|
||||
// rather than an addition to something; a publish outside a user's grant is refused; and rewriting
|
||||
// this file alone and signalling a reload makes a new user appear **without dropping the connection
|
||||
// the mesh already has** — which is what makes every later account, permission or person change cost
|
||||
// nothing (task 1.2's payoff).
|
||||
func ComposeAccounts(principals []Principal) (string, error) {
|
||||
sorted := append([]Principal(nil), principals...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# The mesh's users, composed by the controller. Do not edit: the next\n")
|
||||
b.WriteString("# composition overwrites it. Permissions are derived from what each module\n")
|
||||
b.WriteString("# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\n")
|
||||
|
||||
// One account for the mesh: accounts in NATS isolate subject spaces entirely, and the mesh is
|
||||
// one space (design 25 §4). The cost of that — that permissions are the only isolation — is
|
||||
// paid above, in the scoping of every inbox and every ack subject.
|
||||
// paid in the scoping of every inbox and every ack subject.
|
||||
b.WriteString("accounts {\n MESH {\n users = [\n")
|
||||
for _, p := range sorted {
|
||||
perms, err := PermissionsFor(p)
|
||||
|
||||
+4
-1
@@ -9,13 +9,16 @@ tls {
|
||||
cert_file: "/tls/tls.crt"
|
||||
key_file: "/tls/tls.key"
|
||||
ca_file: "/tls/ca.crt"
|
||||
verify: true
|
||||
}
|
||||
|
||||
jetstream {
|
||||
store_dir: "/data"
|
||||
}
|
||||
|
||||
# The mesh's users, composed by the controller. Do not edit: the next
|
||||
# composition overwrites it. Permissions are derived from what each module
|
||||
# declares and nothing else (novox/hq ADR 0043, design 29 §2).
|
||||
|
||||
accounts {
|
||||
MESH {
|
||||
users = [
|
||||
|
||||
@@ -154,3 +154,50 @@ func TestRecordsComposeIntoAFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user