diff --git a/cmd/mesh-control/plan.go b/cmd/mesh-control/plan.go index 56fe989..bc58ff3 100644 --- a/cmd/mesh-control/plan.go +++ b/cmd/mesh-control/plan.go @@ -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 } diff --git a/internal/catalogue/bound_into_files.go b/internal/catalogue/bound_into_files.go index 7bcd674..614d1da 100644 --- a/internal/catalogue/bound_into_files.go +++ b/internal/catalogue/bound_into_files.go @@ -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, diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 3ca4de7..558fc50 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -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 @@ -293,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 } @@ -490,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), }) } diff --git a/internal/catalogue/identity.go b/internal/catalogue/identity.go index dd93ac8..502ee6b 100644 --- a/internal/catalogue/identity.go +++ b/internal/catalogue/identity.go @@ -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) } diff --git a/internal/catalogue/identity_test.go b/internal/catalogue/identity_test.go new file mode 100644 index 0000000..07e7cef --- /dev/null +++ b/internal/catalogue/identity_test.go @@ -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 +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 94abd4e..4912d2a 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -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__` 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"` @@ -467,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) { diff --git a/internal/secrets/seal.go b/internal/secrets/seal.go index 422c94f..1759880 100644 --- a/internal/secrets/seal.go +++ b/internal/secrets/seal.go @@ -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 }