Files
mesh-controller/internal/catalogue/identity.go
T
jochen 6d620f77c3 Bound a consumer's identity by the provision it requires (hq issue 263)
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).
2026-10-06 02:16:20 +02:00

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
}