package catalogue import ( "fmt" "regexp" "sort" "strings" ) // Who a consumer is, said once by the mesh (novox/hq 04-ISSUES/023). // // **The provisioner used to invent this and nothing else could derive it.** It made a role called // `mesh__`, which is a reasonable name and is knowable nowhere else: not by the // control plane, not by the binding, and above all not by the consumer — which has to present it // in order to authenticate. The one identifier needed to connect was the one thing no part of the // mesh would say. // // So the mesh says it. It goes to the provider in the grant and to the consumer in its binding, // from **one derivation**, which is what makes the two ends agree by construction rather than by // two conventions that were the same on the day they were written. // // **It is still name-agnostic.** The mesh does not know what a role or an access key or a client // is; it says who is asking, and each provisioner makes that true in whatever its own system // calls an identity. What a provider does with it is the provider's business, as everything about // a provision is. // identityUnusable is every character that is not safe unquoted in the systems these names reach. // // Conservative on purpose: lower-case letters, digits and underscore reach a PostgreSQL role, a // MinIO access key, an LDAP uid and a Keycloak client without quoting or escaping in any of them. // A wider set would work in most and fail in one, discovered as a login that cannot be created. var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) // IdentityPrefix marks what the mesh made, so a provisioner can find its own work and leave // everything else alone. Withdrawal depends on it entirely. const IdentityPrefix = "mesh_" // 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 0049). 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. func ConsumerIdentity(node, module string) string { clean := func(s string) string { return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_") } return IdentityPrefix + clean(node) + "_" + clean(module) } // IdentityBound is the longest consumer identity one provision's backend keeps, and what keeps it // (novox/hq ADR 0049, refined by ADR 0225). Max zero means no bound: the provision keeps no name // derived from its consumer, or keeps one in something with no limit the mesh need respect. type IdentityBound struct { // Max is the longest identity that backend keeps, in characters; zero for none. Max int `json:"max,omitempty"` // In is what keeps it, in words a refusal can quote: "an S3 access key", "a PostgreSQL role". In string `json:"in,omitempty"` } // Bounded is whether this bound refuses anything. func (b IdentityBound) Bounded() bool { return b.Max > 0 } // DefaultIdentityLimit is the bound on a provision whose provider receives its consumers and does // not say how long a name it keeps: an S3 access key's 20 (novox/hq 04-ISSUES/010, 034), the // tightest backend the mesh has met. It was the bound on every provision until ADR 0225; it stays // the bound on any that has not said otherwise, because a provider that is told each consumer's // identity may create a name from it in a backend nobody has measured. const DefaultIdentityLimit = 20 // DefaultIdentityBound is DefaultIdentityLimit, said as a bound. var DefaultIdentityBound = IdentityBound{Max: DefaultIdentityLimit, In: "a backend that has not said its limit (the tightest known, an S3 access key's)"} // identityLimit is the bound CheckIdentity applies, for a caller that does not know which provision // the identity is for. const identityLimit = DefaultIdentityLimit // CheckIdentity refuses an identity that would not fit the tightest backend the mesh knows. A caller // that knows the provision uses CheckIdentityWithin and that provision's own bound (ADR 0225). // // **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 0049), or shorten the machine's name. func CheckIdentity(node, module string) error { return CheckIdentityWithin(node, module, IdentityBound{Max: identityLimit, In: "a backend (an S3 access key)"}) } // CheckIdentityWithin refuses an identity that would not fit one provision's bound, and accepts any // identity for a provision with none (novox/hq ADR 0225). func CheckIdentityWithin(node, module string, bound IdentityBound) error { if !bound.Bounded() { return nil } got := ConsumerIdentity(node, module) if len(got) <= bound.Max { return nil } return fmt.Errorf( "%s on %s is identified as %q, %d characters where %s keeps %d — "+ "give the module a shorter `slug` or shorten the machine's name", module, node, got, len(got), bound.In, bound.Max) } // Overflow is one consumer whose identity does not fit the provision it requires: left out of its // provider's grants and reported, never a reason to refuse the provider's machine (ADR 0225). type Overflow struct { // Provision is what was required, Provider the machine answering it. Provision string `json:"provision"` Provider string `json:"provider"` // Consumer is the machine, Module the module on it that required it. Consumer string `json:"consumer"` Module string `json:"module"` // Identity is the name the mesh derived, and Bound what it overflows. Identity string `json:"identity"` Bound IdentityBound `json:"bound"` } func (o Overflow) String() string { return fmt.Sprintf("%s on %s requires %s from %s and is identified as %q, %d characters where %s "+ "keeps %d — left out of %s's grants until the module's `slug` is shorter", o.Module, o.Consumer, o.Provision, o.Provider, o.Identity, len(o.Identity), o.Bound.In, o.Bound.Max, o.Provider) } // Overflowing is every requirement of this machine's modules whose identity overflows the bound of // the provision answering it. The same judgement the provider's composition makes before it grants // (grantsFor), made from the consumer's side so `status` can say it about every machine. func (r Resolution) Overflowing() []Overflow { slugs := map[string]string{} for _, m := range r.Modules { slugs[m.Module] = m.Slug } var out []Overflow seen := map[string]bool{} for _, n := range r.Needs { if n.ByRecord || !n.Identity.Bounded() { continue } source := IdentitySource(slugs[n.For], n.For) if CheckIdentityWithin(r.Node, source, n.Identity) == nil { continue } key := n.Name + "\x00" + n.From + "\x00" + n.For if seen[key] { continue // one line per requirement, however many local names it has } seen[key] = true out = append(out, Overflow{Provision: n.Name, Provider: n.From, Consumer: r.Node, Module: n.For, Identity: ConsumerIdentity(r.Node, source), Bound: n.Identity}) } sort.Slice(out, func(i, j int) bool { if out[i].Module != out[j].Module { return out[i].Module < out[j].Module } return out[i].Provision < out[j].Provision }) return out } // DefaultLongestMachine is the machine name the catalogue check judges identities on when it is not // told one: the longest name of the mesh this catalogue is written for, so a catalogue that passes // passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's; // a mesh that names a longer machine raises this in the same change (ADR 0225). const DefaultLongestMachine = 6 // IdentityProblems is every module whose identity would overflow a provision it wants, on a machine // whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the // pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A // provision no module in the shelf offers is not judged: its bound is not known here. func IdentityProblems(shelf Shelf, longestMachine int) []string { offeredBy := map[string][]string{} for _, name := range shelfOrder(shelf) { for _, o := range shelf[name].Offers() { offeredBy[o] = append(offeredBy[o], name) } } machine := strings.Repeat("n", longestMachine) var problems []string for _, name := range shelfOrder(shelf) { m := shelf[name] source := IdentitySource(m.Slug, m.Module) for _, want := range m.Wants() { // The tightest bound among the modules offering it: whichever one answers on a given // machine, the identity has to fit it. tightest, by := IdentityBound{}, "" for _, provider := range offeredBy[want] { if provider == name { continue // a module answering its own requirement is not its own consumer } b := shelf[provider].IdentityBoundOf(want) if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) { tightest, by = b, provider } } if CheckIdentityWithin(machine, source, tightest) == nil { continue } got := ConsumerIdentity(machine, source) problems = append(problems, fmt.Sprintf( "%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+ "on a machine with a %d-character name it is identified as %q, %d — give %s a "+ "`slug` of at most %d characters", name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name, tightest.Max-len(IdentityPrefix)-longestMachine-1)) } } return problems }