broker: a module account scopes its tool serve queues + mesh.rpc (ADR 0052) #5

Merged
jschoubben merged 8 commits from events/tool-account-scope into initialization 2026-09-05 01:06:52 +00:00
14 changed files with 572 additions and 25 deletions
+1
View File
@@ -135,6 +135,7 @@ func usage() {
module list what modules this mesh knows about
module moved <name> <commit> the source has a newer commit than the mesh built
module forget <name> remove one, unless a node is running it
module issue <name> --node <m> a broker account for a module, scoped to its emits and consumes
status [--json] what is wrong, what is quiet, and what is out of date
board [--listen ADDR] the same three questions, as a page that holds nothing
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
+89 -1
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"flag"
@@ -10,6 +12,7 @@ import (
"sort"
"strings"
"github.com/novox/mesh-control/internal/broker"
"github.com/novox/mesh-control/internal/catalogue"
"github.com/novox/mesh-control/internal/inventory"
"github.com/novox/mesh-control/internal/overlay"
@@ -194,8 +197,93 @@ func moduleCommand(ctx context.Context, args []string) error {
fmt.Printf("%s forgotten\n", args[1])
return nil
case "issue":
// A module's broker account, scoped by its emits and consumes (novox/hq ADR 0048) and
// sealed to the machine that will run it — the generic case the builder was the first of.
set := flag.NewFlagSet("module issue", flag.ContinueOnError)
forNode := set.String("node", "",
"the machine that will run it, so the credential is delivered instead of printed")
positionals, err := parseAround(set, args[1:])
if err != nil {
return err
}
if len(positionals) != 1 {
return errors.New("module issue <module> --node <machine>")
}
module := positionals[0]
if *forNode == "" {
return errors.New("module issue needs --node: a module's account is sealed to the " +
"machine that runs it, and the mesh cannot read it back to print")
}
shelf, err := inv.Catalogue(ctx)
if err != nil {
return err
}
m, ok := shelf[module]
if !ok {
return fmt.Errorf("this mesh knows no module %q; `module add` it first", module)
}
management, err := broker.ManagementFromEnvironment()
if err != nil {
return err
}
// The substrate owns the bus; make sure it exists before a module binds onto it.
if err := management.EnsureEventExchanges(ctx); err != nil {
return err
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return err
}
password := base64.RawURLEncoding.EncodeToString(secret)
account, err := management.CreateModuleAccount(ctx, *forNode, module, password, m.Emits, m.Consumes)
if err != nil {
return err
}
// A consumer's queue, with its dead-letter, is the substrate's to declare — its own account
// may not (ADR 0048). Made now, so it exists before the module binds onto it.
if len(m.Consumes) > 0 {
if err := management.EnsureModuleQueue(ctx, *forNode, module); err != nil {
return err
}
}
known, err := broker.FromEnvironment()
if err != nil {
return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err)
}
// The URL and what verifies the broker, together — a mesh's broker presents its own
// certificate, in no public trust store, so a URL alone fails at TLS (as `builder issue`).
held, err := json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
}{
URL: fmt.Sprintf("amqps://%s:%s@%s/", account, password, known.Address),
Fingerprint: known.Fingerprint,
// The node and module the account is for, so the runtime names its queue as the mesh
// scoped it (<node>.<module>.events) without a manifest having to interpolate a node.
Node: *forNode,
Module: module,
})
if err != nil {
return err
}
if err := inv.AcceptSecretForModule(ctx, *forNode, module, "broker", string(held)); err != nil {
return err
}
fmt.Printf("broker account %s created for %s, scoped to what it emits and consumes\n",
account, module)
fmt.Printf(" sealed to %s. It arrives with the next push — `push %s` to send it\n",
*forNode, *forNode)
return nil
default:
return fmt.Errorf("module has no %q; it has add, list, moved and forget", args[0])
return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0])
}
}
+17 -1
View File
@@ -520,9 +520,25 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
// working for ever after its consumer went away.
from = ""
}
// The consumer's identity slug, from its own manifest, carried on the grant so the provider
// derives the same login the consumer does (novox/hq ADR 0054). Refused here if it still would
// not fit the tightest backend — the mesh chose the name, so the mesh refuses it, with the
// remedy a short slug rather than a login a provider silently shortened.
slug := ""
for _, mm := range plan.Modules {
if mm.Module == s.ConsumerModule {
slug = mm.Slug
break
}
}
if from != "" {
if err := catalogue.CheckIdentity(s.Consumer, catalogue.IdentitySource(slug, s.ConsumerModule)); err != nil {
return nil, err
}
}
out = append(out, catalogue.Grant{
Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer],
From: from, Values: values, Sealed: s.ForProvider})
From: from, Values: values, Slug: slug, Sealed: s.ForProvider})
}
return out, nil
}
+124
View File
@@ -67,6 +67,126 @@ const ExchangeName = "mesh"
// constant that differed would be caught by the test that asserts they agree.
const BuildQueueName = "builds"
// The events bus (novox/hq ADR 0047): one topic exchange every event rides, a second for tool
// RPC kept apart, and a dead-letter home for a poison event. The substrate owns these — a module's
// account cannot declare them, only bind its own queue to the events one.
const (
EventsExchangeName = "mesh.events"
RPCExchangeName = "mesh.rpc"
DeadExchangeName = "mesh.events.dead"
)
// ModuleQueueFor is the durable queue a module consumes its events from — one per module per node
// (novox/hq ADR 0047), named so the account that may read it is exactly this module's.
func ModuleQueueFor(node, module string) string { return node + "." + module + ".events" }
// modulePermissions is a module's authority on the bus, derived from its manifest (novox/hq
// ADR 0048): what it consumes and what it emits, and nothing else. Pure, so the scope is tested as
// patterns without a broker — the way a builder's is.
//
// A note on the limit: the broker's write permission is per exchange, not per routing key (LavinMQ
// has no topic permissions), so an emitting module is granted the events exchange whole. ADR 0047's
// origin reservation — a module publishes only under `module.<self>.*` — is stamped by the sdk, not
// enforced here; that gap is the broker's, and is recorded rather than hidden. A pure consumer like
// the audit logger is unaffected: it is granted no write to the exchange at all.
func modulePermissions(node, module string, emits, consumes []string) (configure, write, read string) {
queue := regexp.QuoteMeta(ModuleQueueFor(node, module))
events := regexp.QuoteMeta(EventsExchangeName)
rpc := regexp.QuoteMeta(RPCExchangeName)
// A module serves each of its tools on its own queue, namespaced by the module (novox/hq
// ADR 0052) — serve.<module>.<tool> — so the account may declare, bind and read exactly its own,
// and no other module's.
serve := "serve\\." + regexp.QuoteMeta(module) + "\\..*"
// Declare its own events queue and its own tool serve queues.
configure = "^(" + queue + "|" + serve + ")$"
// Write to bind its queue and serve queues (binding is a write on the queue), and to the RPC
// exchange to publish replies (ADR 0052: replies ride mesh.rpc, never the default exchange, which
// would let it publish into any queue). To the events exchange only if it emits.
writes := []string{queue, serve, rpc}
if len(emits) > 0 {
writes = append(writes, events)
}
write = "^(" + strings.Join(writes, "|") + ")$"
// Read its own queue and serve queues to consume them, and the RPC exchange to bind its serve
// queues onto. The events exchange to bind onto only if it consumes.
reads := []string{queue, serve, rpc}
if len(consumes) > 0 {
reads = append(reads, events)
}
read = "^(" + strings.Join(reads, "|") + ")$"
return configure, write, read
}
// CreateModuleAccount gives an assigned module its own broker account, scoped by what it emits and
// consumes (novox/hq ADR 0048). The account name carries the node so the same module on two machines
// holds two accounts, each sealed to its own; the permissions carry the module so one module cannot
// read another's queue. Generic — the builder is one instance of this rule, not a separate kind.
func (m *Management) CreateModuleAccount(ctx context.Context, node, module, password string, emits, consumes []string) (string, error) {
if !safeName.MatchString(node) {
return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", node)
}
if !safeName.MatchString(module) {
return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", module)
}
account := node + "-" + module
if !safeName.MatchString(account) {
return "", fmt.Errorf("%q is not a usable broker account name", account)
}
if err := m.put(ctx, "/api/users/"+url.PathEscape(account),
map[string]string{"password": password, "tags": ""}); err != nil {
return "", fmt.Errorf("cannot create the broker account for %s on %s: %w", module, node, err)
}
configure, write, read := modulePermissions(node, module, emits, consumes)
if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(account), map[string]string{
"configure": configure, "write": write, "read": read,
}); err != nil {
return "", fmt.Errorf("cannot scope the broker account for %s on %s: %w", module, node, err)
}
return account, nil
}
// EnsureModuleQueue declares a consuming module's queue with its dead-letter exchange, idempotently.
// The substrate declares it because a scoped module account may not: the broker refuses a queue with
// a dead-letter exchange to a non-administrator (novox/hq ADR 0048), so a consumer passively checks
// the queue the mesh made rather than declaring its own.
func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string) error {
queue := ModuleQueueFor(node, module)
if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(queue), map[string]any{
"durable": true,
"arguments": map[string]any{"x-dead-letter-exchange": DeadExchangeName},
}); err != nil {
return fmt.Errorf("cannot declare the queue for %s on %s: %w", module, node, err)
}
return nil
}
// EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The
// substrate owns them (a module's account may not declare an exchange), and a dead-letter exchange
// with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison
// event for inspection, which is the whole reason the trail exists.
func (m *Management) EnsureEventExchanges(ctx context.Context) error {
for _, exchange := range []string{EventsExchangeName, RPCExchangeName, DeadExchangeName} {
if err := m.put(ctx, "/api/exchanges/%2f/"+url.PathEscape(exchange),
map[string]any{"type": "topic", "durable": true}); err != nil {
return fmt.Errorf("cannot declare the %s exchange: %w", exchange, err)
}
}
if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(DeadExchangeName),
map[string]any{"durable": true}); err != nil {
return fmt.Errorf("cannot declare the dead-letter queue: %w", err)
}
if err := m.post(ctx, "/api/bindings/%2f/e/"+url.PathEscape(DeadExchangeName)+
"/q/"+url.PathEscape(DeadExchangeName), map[string]string{"routing_key": "#"}); err != nil {
return fmt.Errorf("cannot bind the dead-letter queue: %w", err)
}
return nil
}
// CreateNodeAccount gives a node its own broker account, with the token's secret as the password.
//
// Scoped so a node can reach its own queue and the one exchange, and nothing else. The patterns
@@ -167,6 +287,10 @@ func (m *Management) put(ctx context.Context, path string, body any) error {
return m.do(ctx, http.MethodPut, path, body)
}
func (m *Management) post(ctx context.Context, path string, body any) error {
return m.do(ctx, http.MethodPost, path, body)
}
func (m *Management) get(ctx context.Context, path string) ([]byte, error) {
request, err := m.request(ctx, http.MethodGet, path, nil)
if err != nil {
+96
View File
@@ -0,0 +1,96 @@
package broker_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/novox/mesh-control/internal/broker"
)
// The audit logger consumes everything and emits nothing. Its account must let it declare and read
// its own queue and read the events exchange to bind onto — and must not reach another module's
// queue, nor grant any write to the events exchange (novox/hq ADR 0048).
func TestAConsumerReadsItsOwnQueueAndTheEventsExchangeAndNoOthers(t *testing.T) {
// Rebuilt through CreateModuleAccount's own path by asking for the same scope it would apply.
// The queue this module reads:
mine := broker.ModuleQueueFor("anchor", "audit-logger")
other := broker.ModuleQueueFor("anchor", "plex")
read := scope(t, "read", "anchor", "audit-logger", nil, []string{"#"})
if !read.MatchString(mine) {
t.Error("the audit logger may not read its own queue, so it consumes nothing")
}
if !read.MatchString(broker.EventsExchangeName) {
t.Error("the audit logger may not read the events exchange, so it cannot bind onto it")
}
if read.MatchString(other) {
t.Error("the audit logger may read another module's queue")
}
// It emits nothing, so it is granted no write to the events exchange — only its own queue, to bind.
write := scope(t, "write", "anchor", "audit-logger", nil, []string{"#"})
if write.MatchString(broker.EventsExchangeName) {
t.Error("a pure consumer was granted write to the events exchange")
}
if !write.MatchString(mine) {
t.Error("the audit logger may not write to its own queue, so it cannot bind it")
}
}
// An emitter is granted the events exchange to write; a consumer is not.
func TestAnEmitterMayWriteTheEventsExchangeAndAConsumerMayNot(t *testing.T) {
emitter := scope(t, "write", "anchor", "umami", []string{"module.umami.site.created"}, nil)
if !emitter.MatchString(broker.EventsExchangeName) {
t.Error("an emitting module may not write the events exchange, so it cannot emit")
}
}
// scope reconstructs one of the three permission patterns CreateModuleAccount would apply, by
// reading it back from a captured request against a stub management API.
func scope(t *testing.T, which, node, module string, emits, consumes []string) *regexp.Regexp {
t.Helper()
pat := capturePermission(t, which, node, module, emits, consumes)
re, err := regexp.Compile(pat)
if err != nil {
t.Fatalf("the %s pattern does not compile: %v", which, err)
}
return re
}
// capturePermission runs CreateModuleAccount against a stub management API and returns the pattern
// it set for `which` ("configure"/"write"/"read"). The scope is tested where it is applied, not
// reconstructed by the test — so a change to the mapping cannot pass a test that hard-codes the old
// one.
func capturePermission(t *testing.T, which, node, module string, emits, consumes []string) string {
t.Helper()
var captured map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/permissions/") {
_ = json.NewDecoder(r.Body).Decode(&captured)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
t.Setenv(broker.ManagementVar, server.URL)
m, err := broker.ManagementFromEnvironment()
if err != nil {
t.Fatalf("stub management not usable: %v", err)
}
if _, err := m.CreateModuleAccount(context.Background(), node, module, "pw", emits, consumes); err != nil {
t.Fatalf("CreateModuleAccount: %v", err)
}
if captured == nil {
t.Fatal("no permissions were set")
}
pattern, ok := captured[which]
if !ok {
t.Fatalf("no %s permission was set; got %v", which, captured)
}
return pattern
}
+1 -1
View File
@@ -57,7 +57,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
values := map[string]string{
"at": n.At,
"from": n.From,
"as": ConsumerIdentity(node, m.Module),
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
}
for key, value := range n.Serves {
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
+19 -4
View File
@@ -59,6 +59,10 @@ type Grant struct {
// Values are what that module contributed — the name it wants, and anything else the
// provision's own vocabulary defines.
Values map[string]any
// Slug is the consumer module's identity slug, if it declared one — carried on the grant so the
// provider side derives the same login the consumer does, even across nodes where the consumer's
// manifest is not in view (novox/hq ADR 0054). Empty means "use the module name".
Slug string
// At is where the consuming machine is on the private network, empty if it is not on one.
//
// Passed in with the grant because it is a fact about another machine, and resolution answers
@@ -138,8 +142,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
}
// Once, from every module's listens -- not per module. A module receiving only its own ports
// would write a rule set that closed every other module on the machine.
rules, err := r.Filtering(with.Generators, with.Ports)
// would write a rule set that closed every other module on the machine. Each module's per-node
// exposure settings override its listens' source first (novox/hq ADR 0051).
exposure := map[string]map[int]string{}
for _, m := range r.Modules {
e, err := Exposure(m, with.Settings[m.Module])
if err != nil {
return nil, err
}
if e != nil {
exposure[m.Module] = e
}
}
rules, err := r.Filtering(with.Generators, with.Ports, exposure)
if err != nil {
return nil, err
}
@@ -282,7 +297,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
}
found = here
}
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, m.Module))
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)))
if err != nil {
return nil, err
}
@@ -479,7 +494,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
}
out[g.Provision] = append(out[g.Provision], Contribution{
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
As: ConsumerIdentity(g.Consumer, g.From),
As: ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)),
Secret: grantPath(directories[g.Provision], g.Consumer, g.From),
})
}
+67 -3
View File
@@ -3,6 +3,7 @@ package catalogue
import (
"fmt"
"sort"
"strconv"
"strings"
)
@@ -31,7 +32,7 @@ type Rule struct {
// a consequence of what runs on it, not a second list kept in step by hand. Nothing else opens a
// port: **what is not declared is closed**, which is the property that makes the derivation worth
// having rather than merely tidy.
func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int) ([]Rule, error) {
func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int, exposure map[string]map[int]string) ([]Rule, error) {
// Keyed by what actually distinguishes an opening. Two modules wanting :443 from the mesh is
// one rule with two sources; one wanting it from the mesh and another from anywhere is two,
// and they are collapsed below -- deliberately, and only in the widening direction.
@@ -70,10 +71,17 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma
if at, known := ports[m.Module][l.Port]; known {
port = at
}
at := opening{port: port, protocol: l.At(), from: l.From}
// The source, with any per-node exposure setting applied. The override names the port
// the module declares, so it travels with that port to wherever the machine publishes
// it (novox/hq ADR 0051): the same module is internal on one node and public on another.
from := l.From
if override, set := exposure[m.Module][l.Port]; set {
from = override
}
at := opening{port: port, protocol: l.At(), from: from}
rule, seen := found[at]
if !seen {
rule = &Rule{Port: port, Protocol: l.At(), From: l.From}
rule = &Rule{Port: port, Protocol: l.At(), From: from}
found[at] = rule
}
rule.Because = append(rule.Because, m.Module)
@@ -100,6 +108,62 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma
return widest(out), nil
}
// ExposeSetting is the settings key that overrides a listen's source per node (novox/hq ADR 0051):
//
// {"expose": {"5432": "anywhere"}}
//
// makes the module's port 5432 open to the public on the node this is set for, whatever the
// manifest's default. It keys on the port the module declares — the mesh maps that to where the
// machine publishes, and the override travels with it.
const ExposeSetting = "expose"
// Exposure reads a module's per-node exposure overrides from its settings: declared-port → source.
//
// It refuses an override for a port the module does not listen on, or to a source that is not a
// real one — an exposure setting that reaches no port, or names a source nothing enforces, is the
// "reads as a restriction and is none" fault this whole mechanism exists to prevent (novox/hq
// ADR 0048/0050). A module with no `expose` setting yields nothing and keeps its manifest defaults.
func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
listened := make(map[int]bool, len(m.Listens))
for _, l := range m.Listens {
listened[l.Port] = true
}
out := map[int]string{}
for _, layer := range layers {
raw, ok := layer.Values[ExposeSetting]
if !ok {
continue
}
entries, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { port: source } map, and %q set it to something else",
m.Module, ExposeSetting, layer.From)
}
for portText, value := range entries {
port, err := strconv.Atoi(portText)
if err != nil {
return nil, fmt.Errorf("%s exposes %q, which is not a port", m.Module, portText)
}
if !listened[port] {
return nil, fmt.Errorf(
"%s exposes port %d, which it does not listen on — the setting reaches nothing",
m.Module, port)
}
source, ok := value.(string)
if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) {
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q",
m.Module, port, value, FromMesh, FromEverywhere, FromMachine)
}
out[port] = source
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// widest drops a rule that another already covers.
//
// A port open to anywhere is not additionally restricted by a second rule opening it to the mesh:
+46 -2
View File
@@ -369,7 +369,7 @@ func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T)
// mustFilter is the rule set, refusing to continue if it could not be computed.
func mustFilter(t *testing.T, r Resolution, computed map[string]Generator) []Rule {
t.Helper()
rules, err := r.Filtering(computed, nil)
rules, err := r.Filtering(computed, nil, nil)
if err != nil {
t.Fatalf("no rule set could be computed: %v", err)
}
@@ -440,7 +440,7 @@ func TestAComputedModulesOwnListensAreNotLost(t *testing.T) {
func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) {
_, err := Resolution{Node: "anchor",
Modules: []Manifest{{Module: "networking", Computed: "mesh-network"}},
}.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil)
}.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil, nil)
if err == nil {
t.Fatal("a machine whose open ports could not be computed was given a rule set anyway")
}
@@ -578,3 +578,47 @@ func named(r Resolution) []string {
}
return out
}
// A port's source is a per-node setting, not a manifest constant: the same module is internal on
// one machine and public on another (novox/hq ADR 0051). postgres listens from the mesh by default;
// a setting on one node exposes it to anywhere, and the rule set follows.
func TestExposureSettingOverridesAListensSource(t *testing.T) {
postgres := Manifest{Module: "postgres", Version: "1",
Listens: []Listening{{Port: 5432, From: FromMesh, Why: "the database"}}}
base, err := Resolution{Node: "ace", Modules: []Manifest{postgres}}.Filtering(nil, nil, nil)
if err != nil {
t.Fatalf("filtering: %v", err)
}
if len(base) != 1 || base[0].From != FromMesh {
t.Fatalf("without a setting, the default source is the mesh: %+v", base)
}
expose := map[string]map[int]string{"postgres": {5432: FromEverywhere}}
exposed, err := Resolution{Node: "novox", Modules: []Manifest{postgres}}.Filtering(nil, nil, expose)
if err != nil {
t.Fatalf("filtering: %v", err)
}
if len(exposed) != 1 || exposed[0].From != FromEverywhere {
t.Fatalf("the setting did not open the port to anywhere: %+v", exposed)
}
}
// Exposure refuses a setting that names a port the module does not listen on, or a source that is
// not a real one — a setting reaching nothing is worse than none (novox/hq ADR 0048/0051).
func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
postgres := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}}
layer := func(port, source string) []Layer {
return []Layer{{From: "node", Values: map[string]any{ExposeSetting: map[string]any{port: source}}}}
}
if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil {
t.Fatalf("a valid exposure was refused: %v", err)
}
if _, err := Exposure(postgres, layer("6379", FromEverywhere)); err == nil {
t.Error("a port the module does not listen on was accepted")
}
if _, err := Exposure(postgres, layer("5432", "everyone")); err == nil {
t.Error("a source that is not mesh/anywhere/machine was accepted")
}
}
+25 -12
View File
@@ -34,7 +34,18 @@ var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`)
// everything else alone. Withdrawal depends on it entirely.
const IdentityPrefix = "mesh_"
// ConsumerIdentity is what one module on one machine is called, wherever it authenticates.
// IdentitySource is the name the mesh derives a consumer's identity from: the module's slug when it
// has declared one, otherwise its name (novox/hq ADR 0054). A module with a name short enough to fit
// the tightest backend needs no slug; one whose name would overflow declares a short legible one.
func IdentitySource(slug, name string) string {
if slug != "" {
return slug
}
return name
}
// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. The
// `module` argument is the identity source — a slug or a name; see IdentitySource.
//
// A dot and a dash both become an underscore, so `home-server` and `home.server` would collide —
// which cannot happen, because a machine has one name and it is either.
@@ -45,24 +56,26 @@ func ConsumerIdentity(node, module string) string {
return IdentityPrefix + clean(node) + "_" + clean(module)
}
// identityLimit is the shortest identifier limit among the systems these names reach:
// PostgreSQL's NAMEDATALEN - 1.
const identityLimit = 63
// identityLimit is the shortest identifier limit among the systems these names reach: an S3 access
// key's 20 (novox/hq 04-ISSUES/010). PostgreSQL keeps 63 and MinIO 20, so 20 is the one that binds —
// the comment used to name PostgreSQL and was wrong. A name over it is refused, with the remedy a
// short slug (ADR 0054), not silently cut to fit.
const identityLimit = 20
// CheckIdentity refuses a name a provider would silently shorten.
// CheckIdentity refuses an identity that would not fit the tightest backend a consumer reaches.
//
// **Truncation is not an error in PostgreSQL** — a name past the limit is cut to fit and the
// statement succeeds. Two consumers agreeing for the first 63 bytes would become one login, which
// is 022 again at a length nobody would think to test. Refused here rather than in each
// provisioner, because the mesh chose the name and is the only thing that can choose another.
// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and
// the statement succeeds, so two consumers agreeing for the first N bytes would become one login
// (04-ISSUES/022) — and S3 refuses outright. Refused here, at the mesh, because the mesh chose the
// name and is the only thing that can choose another. The remedy is a first-class one: give the
// module a short `slug` (ADR 0054), or shorten the machine's name.
func CheckIdentity(node, module string) error {
got := ConsumerIdentity(node, module)
if len(got) <= identityLimit {
return nil
}
return fmt.Errorf(
"%s on %s would be identified as %q, which is %d characters and some providers keep %d — "+
"another consumer shortened to the same name would share its login. Shorten the "+
"machine's name or the module's",
"%s on %s is identified as %q, %d characters where a backend (an S3 access key) keeps %d — "+
"give the module a shorter `slug` or shorten the machine's name",
module, node, got, len(got), identityLimit)
}
+53
View File
@@ -0,0 +1,53 @@
package catalogue
import "testing"
// Each test names the decision it defends (novox/hq ADR 0017).
func TestASlugIsPreferredOverTheModuleName(t *testing.T) {
// A module that declared a slug is identified by it, so a long name can be made to fit the
// tightest backend without a hash (novox/hq ADR 0054).
if got := IdentitySource("kc", "keycloak"); got != "kc" {
t.Errorf("the slug was not preferred: %q", got)
}
if got := IdentitySource("", "redis"); got != "redis" {
t.Errorf("without a slug, the name should be used: %q", got)
}
if ConsumerIdentity("anchor", IdentitySource("bkt", "bucketuser")) != "mesh_anchor_bkt" {
t.Error("a slug did not shape the identity")
}
}
func TestCheckIdentityFitsTheTightestBackend(t *testing.T) {
// 20 is an S3 access key's limit (04-ISSUES/010), and the one that binds. mesh_anchor_keycloak
// is exactly 20 and allowed; the un-slugged bucketuser is 22 and refused, with the remedy a slug.
if err := CheckIdentity("anchor", "keycloak"); err != nil { // mesh_anchor_keycloak = 20
t.Errorf("a 20-character identity was refused: %v", err)
}
if err := CheckIdentity("anchor", "bucketuser"); err == nil { // mesh_anchor_bucketuser = 22
t.Error("an over-long identity was accepted")
}
// But with a slug it fits, and is accepted.
if err := CheckIdentity("anchor", IdentitySource("bkt", "bucketuser")); err != nil {
t.Errorf("a slugged identity that fits was refused: %v", err)
}
}
func TestTheRefusalNamesTheRemedy(t *testing.T) {
err := CheckIdentity("anchor", "bucketuser")
if err == nil {
t.Fatal("expected a refusal")
}
if !contains(err.Error(), "slug") {
t.Errorf("the refusal did not point at the slug as the remedy: %v", err)
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+25
View File
@@ -119,6 +119,13 @@ type Manifest struct {
Module string `json:"module"`
Version string `json:"version,omitempty"`
// Slug is a short identifier the mesh uses in place of the module name when it derives a
// consumer's login (novox/hq ADR 0054). Optional: a module with a short name needs none. It
// exists because `mesh_<node>_<module>` must fit the tightest backend a consumer reaches — an S3
// access key is 20 characters — and a long module name would overflow it. A person choosing
// `kc` for keycloak keeps the identity legible where a hash would not.
Slug string `json:"slug,omitempty"`
// Provides are the names other modules may require. A module always provides its own name;
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
Provides []Offer `json:"provides,omitempty"`
@@ -126,6 +133,17 @@ type Manifest struct {
// Requires are names that must be provided by something assigned to the same node.
Requires []string `json:"requires,omitempty"`
// Emits are the event types this module publishes onto the broker — dotted topic keys, e.g.
// "module.umami.site.created". Declared so the mesh knows the event graph; events are
// provisioning's lighter sibling — 1:many and broadcast, no credential (novox/hq ADR 0046).
Emits []string `json:"emits,omitempty"`
// Consumes are the event patterns this module subscribes to — topic patterns over module,
// mesh and node events alike, e.g. "node.*.joined" or "#" (the audit logger). The runtime
// wires the subscription; the module ships the handler. A Consumes for an event nothing on
// the mesh Emits is a dangling edge.
Consumes []string `json:"consumes,omitempty"`
// Claims are singular resources. Two modules claiming one thing within a scope cannot both
// be assigned there — which is how exclusivity is expressed, rather than as a list of rivals
// that every new module would force its predecessors to update.
@@ -456,6 +474,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, fmt.Sprintf(
"%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module))
}
// A slug is a short identifier the mesh derives a login from (novox/hq ADR 0054). The same
// charset as a name; its length is checked against a backend's limit at assignment, where the
// node it joins is known — a slug that is fine on one machine's short name can overflow another's.
if m.Slug != "" && !name.MatchString(m.Slug) {
problems = append(problems, fmt.Sprintf(
"%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug))
}
for _, offer := range m.Provides {
p := offer.Name
if !name.MatchString(p) {
+5
View File
@@ -171,6 +171,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
var unused []string
for _, layer := range layers {
for key := range layer.Values {
// `expose` is a real destination for a module that listens: it overrides a port's
// source (novox/hq ADR 0051), validated in Exposure, so it is not stray here.
if key == ExposeSetting && len(m.Listens) > 0 {
continue
}
unused = append(unused, fmt.Sprintf(
"%s sets %q, and %s has no file or contribution to merge it into",
layer.From, key, m.Module))
+4 -1
View File
@@ -54,7 +54,10 @@ func Make(consumerKey, providerKey string) (Sealed, error) {
return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be made")
}
value := make([]byte, 32)
// 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an
// S3 access key accepts (8–40), the tightest of the backends a minted password reaches — the same
// "fit the tightest backend" rule ADR 0054 sets for the login, on the secret. 240 bits is ample.
value := make([]byte, 30)
if _, err := rand.Read(value); err != nil {
return Sealed{}, err
}