The one global 20-character bound made every consumer pay an object
store's key length, even for provisions that keep no name, and a single
overflow refused the provider's whole declaration. An offer now states
its own bound (identity: {max, in} or false); unsaid, a provider told its
consumers keeps 20 and one told nothing keeps none. module check judges
every identity on the longest machine name before merge, and a provider
leaves an overflowing consumer out of its grants and composes, with the
consumer named by push, plan and status (ADR 0225).
221 lines
10 KiB
Go
221 lines
10 KiB
Go
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_<node>_<module>`, 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
|
|
}
|