Task 1.4. The foundation set only — a seat's streams come at registration and a module's consumers at assignment, neither of which has happened at genesis (ADR 0118). Asserted rather than created: a stream that was deleted, or a mesh raised from a backup, must converge rather than run without the guarantee its messages assume. Two things the definitions have to get right, both tested: - CONTROL names its subjects instead of taking mesh.control.>, because heartbeats live under that prefix and a stream of them competes for retention with the messages that matter - EVENTS filters on the event token, which is why that token exists; a filter over a module's whole namespace would persist every tool call Overlapping filters are refused where the set is written: NATS accepts two streams matching one subject and stores the message twice under two retentions, which nothing reports. Adds nats.go as a dependency; it pulled golang.org/x/* forward. Full suite green.
325 lines
13 KiB
Go
325 lines
13 KiB
Go
// Composing the bus's own configuration.
|
|
//
|
|
// On AMQP an account was made by calling the broker's management API (management.go). On NATS it
|
|
// is *composed*: the controller writes accounts, users and per-subject permissions into one file
|
|
// the host keeps current, and the server reloads it in place (novox/hq ADR 0106 — never through a
|
|
// management API; design 25 §4).
|
|
//
|
|
// Everything here is pure. Given the principals, it returns the file's text — so the whole of the
|
|
// mesh's authority model is testable as strings, with no server, which is what management.go's
|
|
// `modulePermissions` already did for the half of it that could be.
|
|
//
|
|
// **NATS closes a gap AMQP left open.** management.go records it plainly: LavinMQ has no topic
|
|
// permissions, so an emitting module is granted the events exchange whole, and ADR 0042's origin
|
|
// reservation — a module publishes only under its own name — is "stamped by the sdk, not enforced
|
|
// here". NATS permissions are per subject, so that reservation becomes something the server
|
|
// refuses rather than something a library promises.
|
|
package broker
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// A Kind is what a principal is, which decides the shape of its authority rather than its
|
|
// contents: a module's comes from its declaration, a host's from its node, and the controller's
|
|
// and the enrolment user's are fixed.
|
|
type Kind string
|
|
|
|
const (
|
|
KindModule Kind = "module"
|
|
KindNode Kind = "node"
|
|
KindController Kind = "controller"
|
|
KindEnrolment Kind = "enrolment"
|
|
)
|
|
|
|
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
|
// emits (novox/hq ADR 0118, design 29 §5).
|
|
type Seat struct {
|
|
Name string
|
|
Accepts []string
|
|
Emits []string
|
|
Serves []string
|
|
Versions []string // protocol versions served beside the current one; empty for v1 only
|
|
}
|
|
|
|
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
|
// nothing else (novox/hq ADR 0043), over the three namespaces of design 29 §2: its own, the seats
|
|
// it holds, and the seats it uses.
|
|
type Principal struct {
|
|
Kind Kind
|
|
Node string
|
|
Module string
|
|
|
|
Emits []string
|
|
Consumes []string
|
|
Serves []string
|
|
|
|
Holds []Seat
|
|
Uses []Seat
|
|
|
|
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
|
// and never appears here: this file is written to a node's disk and read by a server, and a
|
|
// secret that can be read from a configuration file is a secret with a wider blast radius
|
|
// than the one it protects (novox/hq design 29 §10).
|
|
PasswordHash string
|
|
}
|
|
|
|
// safeSubject refuses anything that would change the meaning of a subject rather than sit inside
|
|
// one. A name carrying a dot would silently widen a permission by adding a token; a name carrying
|
|
// `>` or `*` would widen it to a wildcard, which is the whole authority model gone.
|
|
var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|
|
|
// Username is how a principal is named to the server. The node is part of it, so the same module
|
|
// on two machines holds two users, each sealed to its own — the rule management.go already
|
|
// applies, kept.
|
|
func (p Principal) Username() string {
|
|
switch p.Kind {
|
|
case KindModule:
|
|
return p.Node + "." + p.Module
|
|
case KindNode:
|
|
return "node." + p.Node
|
|
case KindController:
|
|
return "controller"
|
|
case KindEnrolment:
|
|
return "enrolment"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
|
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
|
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
|
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
|
|
|
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
|
type Permissions struct {
|
|
Publish []string
|
|
Subscribe []string
|
|
// AllowResponses lets a principal reply to a request it received, on the reply subject that
|
|
// request carried, once.
|
|
//
|
|
// **This is what makes scoped inboxes possible at all**, and design 25 §4 did not say it. If
|
|
// every user's inbox is private to it, a module serving a tool cannot publish the answer —
|
|
// the answer goes to the *caller's* inbox, which the responder has no permission for. The two
|
|
// ways out are granting responders `_INBOX.>`, which is precisely the blanket grant §4
|
|
// refuses, or this: the server itself permits one reply to the subject of a message the user
|
|
// actually received, and nothing else. The authority is bounded by having been asked.
|
|
AllowResponses bool
|
|
}
|
|
|
|
// PermissionsFor derives a principal's authority. Pure, and the only place authority is decided:
|
|
// a permission that cannot be derived from a declaration is a permission nobody can explain.
|
|
func PermissionsFor(p Principal) (Permissions, error) {
|
|
for _, part := range []struct{ what, value string }{
|
|
{"node", p.Node}, {"module", p.Module},
|
|
} {
|
|
if part.value == "" {
|
|
continue
|
|
}
|
|
if !safeSubject.MatchString(part.value) {
|
|
return Permissions{}, fmt.Errorf(
|
|
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part.value)
|
|
}
|
|
}
|
|
|
|
var pub, sub []string
|
|
switch p.Kind {
|
|
case KindController:
|
|
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
|
// stream definitions (design 25 §3), so it alone reaches the JetStream API.
|
|
pub = []string{"mesh.control.>", "mesh.node.>", "mesh.build.>", "$JS.API.>"}
|
|
sub = []string{"mesh.control.>", "mesh.build.>", "$JS.API.>"}
|
|
|
|
case KindEnrolment:
|
|
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
|
// an event, or subscribe any inbox but the one its own token derives (design 25 §6).
|
|
pub = []string{"mesh.control.enrol"}
|
|
sub = []string{}
|
|
|
|
case KindNode:
|
|
// A host publishes its own node's control traffic and subscribes its own declaration —
|
|
// and nothing of any other node's.
|
|
pub = []string{"mesh.control." + p.Node + ".>"}
|
|
sub = []string{"mesh.node." + p.Node + ".declare"}
|
|
|
|
case KindModule:
|
|
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
|
// else may publish into it, so an event's source is a fact the server enforces rather
|
|
// than a claim in the body (design 29 §2).
|
|
own := "mesh.mod." + p.Module
|
|
for _, e := range p.Emits {
|
|
pub = append(pub, own+".event."+e)
|
|
}
|
|
for _, t := range p.Serves {
|
|
sub = append(sub, own+".tool."+t)
|
|
}
|
|
|
|
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
|
|
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
|
for _, c := range p.Consumes {
|
|
emitter, event, ok := strings.Cut(c, ".")
|
|
if !ok {
|
|
return Permissions{}, fmt.Errorf(
|
|
"%q does not name an emitter and an event: a consumed event is <module>.<event>", c)
|
|
}
|
|
sub = append(sub, "mesh.mod."+emitter+".event."+event)
|
|
}
|
|
|
|
// 3. Seats it holds: full participation.
|
|
for _, s := range p.Holds {
|
|
for _, a := range s.Accepts {
|
|
sub = append(sub, seatSubject(s, "accept", a))
|
|
}
|
|
for _, e := range s.Emits {
|
|
pub = append(pub, seatSubject(s, "event", e))
|
|
}
|
|
for _, t := range s.Serves {
|
|
sub = append(sub, seatSubject(s, "tool", t))
|
|
}
|
|
}
|
|
|
|
// 4. Seats it uses: publish only, and only the accepts half. A caller cannot subscribe a
|
|
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
|
// events and lie about outcomes (design 29 §2).
|
|
for _, s := range p.Uses {
|
|
for _, a := range s.Accepts {
|
|
pub = append(pub, seatSubject(s, "accept", a))
|
|
}
|
|
for _, t := range s.Serves {
|
|
pub = append(pub, seatSubject(s, "tool", t))
|
|
}
|
|
}
|
|
}
|
|
|
|
if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
|
|
// Its own reply space, and nothing wider.
|
|
sub = append(sub, p.inbox())
|
|
|
|
// Acking a JetStream delivery is a publish to that consumer's own ack address — a
|
|
// different subject from anything the consumer subscribes. Without it every message a
|
|
// module received would be redelivered forever, refused by the permission list it already
|
|
// has (design 25 §4). Scoped to this principal's own consumer name, so it can ack its own
|
|
// deliveries and no other's.
|
|
pub = append(pub, "$JS.ACK."+consumerName(p)+".>")
|
|
}
|
|
|
|
sort.Strings(pub)
|
|
sort.Strings(sub)
|
|
return Permissions{
|
|
Publish: pub,
|
|
Subscribe: sub,
|
|
// Only something that serves is ever answering. A pure consumer is granted nothing here.
|
|
AllowResponses: p.Kind == KindModule && (len(p.Serves) > 0 || len(p.Holds) > 0) ||
|
|
p.Kind == KindController,
|
|
}, nil
|
|
}
|
|
|
|
// seatSubject places a seat's verb under the kind of traffic it is.
|
|
//
|
|
// **The kind token is load-bearing, not decoration.** A stream is defined by a subject filter, so
|
|
// without it a stream over a seat or a module's namespace would capture that namespace's *tool*
|
|
// traffic too — and a tool call must never be persisted (design 25 §3: tools stay on core NATS).
|
|
// Found while defining the streams: the first draft of design 29 had one namespace per module
|
|
// with no kind, which reads well and cannot be filtered.
|
|
//
|
|
// A seat serving more than its current protocol version carries the version as a token
|
|
// (design 29 §8): the seat stays one role, and v1 and v2 run beside each other until nothing is
|
|
// bound to the old one.
|
|
func seatSubject(s Seat, kind, verb string) string {
|
|
return "mesh.seat." + s.Name + "." + kind + "." + verb
|
|
}
|
|
|
|
// consumerName is the durable consumer the controller derives for this principal. It is here
|
|
// rather than in the caller because the permission and the consumer must agree by construction —
|
|
// two places deriving the same name is how a module ends up unable to ack its own deliveries.
|
|
func consumerName(p Principal) string {
|
|
switch p.Kind {
|
|
case KindModule:
|
|
return "EVENTS." + p.Node + "_" + p.Module
|
|
case KindNode:
|
|
return "NODES." + p.Node
|
|
case KindController:
|
|
return "CONTROL.controller"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Server is everything the composed file needs that is not a principal.
|
|
type Server struct {
|
|
// ClientPort carries TLS itself; there is no plaintext port beside it, which is where this
|
|
// differs from the AMQP broker's 5671/5672 pair.
|
|
ClientPort int
|
|
MonitoringPort int
|
|
TLSCert string
|
|
TLSKey string
|
|
TLSCA string
|
|
// StoreDir is a host directory bind, not a named volume — issue 115 is resolved and converted
|
|
// four modules away from named volumes; the bus's own data is not the place to bring one back.
|
|
StoreDir string
|
|
}
|
|
|
|
// Compose renders the server's whole configuration. The order is stable and the output is
|
|
// deterministic, because the file's digest is what the module's entrypoint watches to decide
|
|
// whether to reload: a composer that reordered a map on each run would signal a reload every time
|
|
// the controller restarted, for a file that had not changed.
|
|
func Compose(s Server, 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("# Composed by the mesh controller. Do not edit: the next composition overwrites it.\n")
|
|
b.WriteString("# Accounts and permissions are derived from what each module declares and nothing\n")
|
|
b.WriteString("# else (novox/hq ADR 0043, design 29 §2).\n\n")
|
|
|
|
fmt.Fprintf(&b, "port: %d\n", s.ClientPort)
|
|
fmt.Fprintf(&b, "http: 127.0.0.1:%d\n\n", s.MonitoringPort)
|
|
|
|
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")
|
|
|
|
// 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.
|
|
b.WriteString("accounts {\n MESH {\n users = [\n")
|
|
for _, p := range sorted {
|
|
perms, err := PermissionsFor(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if p.PasswordHash == "" {
|
|
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
|
|
}
|
|
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
|
|
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
|
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
|
if perms.AllowResponses {
|
|
b.WriteString(" allow_responses: { max: 1, ttl: \"1m\" }\n")
|
|
}
|
|
b.WriteString(" } }\n")
|
|
}
|
|
b.WriteString(" ]\n }\n}\n")
|
|
return b.String(), nil
|
|
}
|
|
|
|
func quoted(values []string) string {
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
out := make([]string, len(values))
|
|
for i, v := range values {
|
|
out[i] = fmt.Sprintf("%q", v)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|