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
|
|
}
|