From 6d620f77c3f3f967be953ff760823c6a51b7b8d3 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 02:16:20 +0200 Subject: [PATCH] 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). --- cmd/mesh-controller/build.go | 4 + cmd/mesh-controller/check.go | 22 ++- cmd/mesh-controller/identity_bound_test.go | 150 ++++++++++++++++ cmd/mesh-controller/modules.go | 15 +- cmd/mesh-controller/plan.go | 66 +++++-- cmd/mesh-controller/readable.go | 4 + cmd/mesh-controller/sendable.go | 3 + cmd/mesh-controller/status.go | 19 ++- internal/catalogue/catalogue_check_test.go | 38 +++++ internal/catalogue/consumer_into_serves.go | 24 ++- internal/catalogue/declaration.go | 4 + internal/catalogue/identity.go | 157 ++++++++++++++++- internal/catalogue/identity_bound_test.go | 190 +++++++++++++++++++++ internal/catalogue/manifest.go | 116 ++++++++++++- internal/catalogue/resolve.go | 13 +- 15 files changed, 784 insertions(+), 41 deletions(-) create mode 100644 cmd/mesh-controller/identity_bound_test.go create mode 100644 internal/catalogue/identity_bound_test.go diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index e68b09b..10460a6 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -679,6 +679,10 @@ type answers struct { // failing is every consumer a provider says it keeps failing (novox/hq ADR 0224): a provider's // journal was the only place that said so for a day (04-ISSUES/179). failing []inventory.ProviderStanding + // overflowing is every module whose identity overflows the bound of a provision it requires + // (novox/hq ADR 0225): its provider leaves it out of the grants and composes everything else, so + // this is the one place it is said across the mesh. Not well while there is any. + overflowing []catalogue.Overflow } // heldBy is every artifact this mesh has built, for a build that may need one as its base. diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go index dbd9225..fe1ed88 100644 --- a/cmd/mesh-controller/check.go +++ b/cmd/mesh-controller/check.go @@ -25,7 +25,17 @@ import ( // mesh seat is judged fully only at registration. A seat another module declares is unknown unless // that module's manifest is passed too. Both are printed as a note, not as a problem — a check that // refused what it could not see would teach people to ignore it. +// +// **And every identity against every bound it meets** (novox/hq ADR 0225, issue 263): each module's +// identity, on a machine whose name is `longestMachine` characters, against the bound of every +// provision it wants that a manifest given here offers. An overflow is refused in the pull request +// that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the +// provider's machine when a real machine's name first meets the module's. func moduleCheck(paths []string, out io.Writer) error { + return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out) +} + +func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { if len(paths) == 0 { return errors.New("module check ... — one file per module; pass every " + "manifest of a repository together so the rules between them are checked too") @@ -74,6 +84,15 @@ func moduleCheck(paths []string, out io.Writer) error { } failed += len(problems) + // Between the manifests too: an identity against the bounds of the provisions it wants, which + // only the provider's manifest states. + identities := catalogue.IdentityProblems(shelf, longestMachine) + sort.Strings(identities) + for _, p := range identities { + fmt.Fprintln(out, p) + } + failed += len(identities) + var names []string for name := range shelf { names = append(names, name) @@ -109,7 +128,8 @@ func moduleCheck(paths []string, out io.Writer) error { } fmt.Fprintf(out, "%d manifest(s) checked. Judged against the seats this binary carries; a claim on "+ "one of the mesh's own seats is judged fully at registration, and a seat declared by a "+ - "module not given here reads as unknown\n", len(paths)) + "module not given here reads as unknown. Identities judged on a %d-character machine name, "+ + "against the bounds of the providers given here\n", len(paths), longestMachine) return nil } diff --git a/cmd/mesh-controller/identity_bound_test.go b/cmd/mesh-controller/identity_bound_test.go new file mode 100644 index 0000000..6f97e93 --- /dev/null +++ b/cmd/mesh-controller/identity_bound_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// novox/hq ADR 0225, issue 263: a consumer's identity is bounded by the provision it requires, an +// overflow is refused before merge by `module check`, and a provider's machine is never refused for +// one consumer's identity. + +// `module check` refuses the pull request that introduces an overflow, naming the module. +func TestModuleCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + p := filepath.Join(dir, name+".json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p + } + objects := write("objects", `{"module":"objects","version":"1", + "provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}], + "receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`) + resolver := write("resolver", `{"module":"resolver","version":"1", + "provides":[{"name":"wildcard-resolution","scope":"mesh","identity":false}]}`) + album := write("photoalbum", `{"module":"photoalbum","version":"1","requires":["s3-bucket"]}`) + nm := write("networkmanager", `{"module":"networkmanager","version":"1","requires":["wildcard-resolution"]}`) + + var out bytes.Buffer + if err := moduleCheckFor([]string{resolver, nm}, 6, &out); err != nil { + t.Fatalf("a long name requiring a keyless provision was refused (issue 263): %v\n%s", err, out.String()) + } + out.Reset() + err := moduleCheckFor([]string{objects, album, resolver, nm}, 6, &out) + if err == nil { + t.Fatalf("an identity overflowing an S3 access key passed:\n%s", out.String()) + } + if !strings.Contains(out.String(), "photoalbum wants s3-bucket") || + !strings.Contains(out.String(), "`slug` of at most 8 characters") || + strings.Contains(out.String(), "networkmanager wants") { + t.Fatalf("the refusal does not name the one overflowing module and its remedy:\n%s", out.String()) + } +} + +// Tonight's case, through the commands: networkmanager on a six-character machine requires the +// resolver provision, and a second consumer there overflows an object store's access key. The +// provider's machine still composes; the overflowing consumer is left out of its grants and named, +// by push and by `status`, and the keyless consumer is granted with its long name. +func TestAnOverflowingConsumerNeverRefusesItsProvidersMachine(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, catalogue.Manifest{Module: "objects", Version: "1", + Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, + Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}) + register(t, open, catalogue.Manifest{Module: "resolver", Version: "1", + Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}}) + register(t, open, catalogue.Manifest{Module: "networkmanager", Version: "1", + Requires: []string{"wildcard-resolution"}}) + register(t, open, catalogue.Manifest{Module: "photoalbum", Version: "1", Requires: []string{"s3-bucket"}}) + register(t, open, catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}}) + for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "resolver"}, + {"laptop", "networkmanager"}, {"laptop", "photoalbum"}, {"laptop", "files"}} { + if _, err := assign(ctx, open, a[0], a[1]); err != nil { + t.Fatalf("assign %s %s: %v", a[0], a[1], err) + } + } + + // The consumer's machine resolves, and says which of its modules no provider will grant. + consumer, _, err := planFor(ctx, open, "laptop") + if err != nil { + t.Fatal(err) + } + over := consumer.Overflowing() + if len(over) != 1 || over[0].Module != "photoalbum" || over[0].Provision != "s3-bucket" { + t.Fatalf("the consumer's side does not name exactly photoalbum: %+v", over) + } + + // The provider's machine composes. Under ADR 0049's one bound this was a refusal naming + // networkmanager, and no push to the provider could go through. + plan, settings, err := planFor(ctx, open, "anchor") + if err != nil { + t.Fatal(err) + } + declared, err := declarationFor(ctx, open, "anchor", plan, settings) + if err != nil { + t.Fatalf("one consumer's identity refused its provider's whole machine: %v", err) + } + if len(declared.withheld) != 1 || declared.withheld[0].Identity != "mesh_laptop_photoalbum" { + t.Fatalf("the overflowing consumer is not the one withheld: %+v", declared.withheld) + } + grants, _, err := grantsFor(ctx, open, "anchor") + if err != nil { + t.Fatal(err) + } + var keyless bool + for _, g := range grants { + keyless = keyless || g.Provision == "wildcard-resolution" && g.From == "networkmanager" + } + if !keyless { + t.Fatalf("networkmanager, 26 characters, is not granted the keyless resolver provision: %+v", grants) + } + var granted []string + for _, c := range declared.Received["objects"]["s3-bucket"] { + granted = append(granted, c.From) + } + if strings.Join(granted, ",") != "files" { + t.Fatalf("the object store grants %v; files and only files fit", granted) + } + said := printed(t, func() error { reportLeftOut("anchor", declared); return nil }) + if !strings.Contains(said, `photoalbum on laptop requires s3-bucket from anchor`) || + !strings.Contains(said, "left out of anchor's grants") { + t.Fatalf("the push does not say whom it leaves out:\n%s", said) + } + + // And `status` names it, and does not call the mesh well while it stands. + asked, err := theThreeQuestions(ctx, open) + if err != nil { + t.Fatal(err) + } + if len(asked.overflowing) != 1 || asked.overflowing[0].Module != "photoalbum" { + t.Fatalf("status does not carry the overflow: %+v", asked.overflowing) + } + if asked.well() { + t.Fatal("a mesh with a consumer left out of its grants reads as well") + } + shown := printed(t, func() error { return printStatus(asked) }) + if !strings.Contains(shown, "identified too long for a provision they require") || + !strings.Contains(shown, "mesh_laptop_photoalbum") { + t.Fatalf("status does not say it:\n%s", shown) + } + body, err := statusAsJSON(asked) + if err != nil { + t.Fatal(err) + } + var doc struct { + Overflowing []catalogue.Overflow `json:"overflowing"` + } + if err := json.Unmarshal(body, &doc); err != nil || len(doc.Overflowing) != 1 || + doc.Overflowing[0].Bound.Max != 20 { + t.Fatalf("the document does not carry it: %v\n%s", err, body) + } +} diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 6767884..b6fbd8c 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -59,8 +59,19 @@ func moduleCommand(ctx context.Context, args []string) error { // `check` needs no mesh, and must not: it is what somebody runs in their own repository before // there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it. if args[0] == "check" { + set := flag.NewFlagSet("module check", flag.ContinueOnError) + // The longest machine name an identity must fit on (novox/hq ADR 0225): a mesh passes its own. + longest := set.Int("longest-machine-name", catalogue.DefaultLongestMachine, + "judge each module's identity on a machine name this many characters long") + given, err := parseAround(set, args[1:]) + if err != nil { + return err + } + if *longest < 1 { + return errors.New("--longest-machine-name is a length, at least 1") + } var paths []string - for _, a := range args[1:] { + for _, a := range given { if info, err := os.Stat(a); err == nil && info.IsDir() { under, err := manifestsUnder(a) if err != nil { @@ -71,7 +82,7 @@ func moduleCommand(ctx context.Context, args []string) error { } paths = append(paths, a) } - return moduleCheck(paths, os.Stdout) + return moduleCheckFor(paths, *longest, os.Stdout) } open, err := openStores(ctx) if err != nil { diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index f8f1580..29d9056 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -399,7 +399,7 @@ func declarationWith(ctx context.Context, open *stores, node string, } out := sendable{Resources: composed.Resources, Adoption: adoption, Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, - LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut} + LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld} // And which build of each module it carries, for the send to record (novox/hq issue 259, ADR // 0221). Read only on the send path: a question about what would be sent records nothing. if choosing == Allocating { @@ -464,6 +464,11 @@ func reportLeftOut(node string, declared sendable) { "what the machine holds for it is kept and its containers are untouched. %s\n", node, m, declared.leftOutWhy[m]) } + // And whom it serves nothing, because their identity overflows what the provision keeps (ADR + // 0225): the machine is sent everything else, and the consumer is named. + for _, o := range declared.withheld { + fmt.Printf("%s: %s\n", node, o) + } } // renderingFor is everything a node's declaration is composed with, and the node's record. @@ -471,7 +476,7 @@ func renderingFor(ctx context.Context, open *stores, node string, plan catalogue.Resolution, settings catalogue.SettingsBy, gens map[string]catalogue.Generator, choosing Choosing) (catalogue.Rendering, inventory.Node, error) { inv := open.inventory - grants, err := grantsFor(ctx, open, node) + grants, withheld, err := grantsFor(ctx, open, node) if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } @@ -794,7 +799,7 @@ func renderingFor(ctx context.Context, open *stores, node string, Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation, Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built, - BusUsers: busUsers, + BusUsers: busUsers, Withheld: withheld, }, record, nil } @@ -930,28 +935,34 @@ func certificateFor(ctx context.Context, open *stores, node string) (string, str // The mirror of what a consumer is given, and the half that makes the credential real: a password // nothing was told to create is a password that authenticates nowhere. Sealed to this node, so // the mesh hands over something it cannot itself use. -func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Grant, error) { +// +// **One consumer's identity never refuses the provider's machine** (novox/hq ADR 0225, issue 263). +// A consumer whose identity overflows the provision's bound is left out of the grants and returned +// beside them, for push, plan and `status` to say; every other consumer is granted and the provider's +// declaration composes. Refusing here once made a whole machine unpushable for one module elsewhere. +func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Grant, []catalogue.Overflow, error) { inv := open.inventory issued, err := inv.SecretsFrom(ctx, node) if err != nil { - return nil, err + return nil, nil, err } // Where each consumer is, so a provider that must reach back to one does not have to know how // the mesh names machines. shelf, err := inv.Catalogue(ctx) if err != nil { - return nil, err + return nil, nil, err } onNetwork, err := whereEveryoneIs(ctx, inv, shelf) if err != nil { - return nil, err + return nil, nil, err } // What each consumer actually asked for, taken from that machine's own resolution rather than // from a record beside it. A provider told to create a password and not what to create it for // can do nothing with it, and the name a consumer wants is the consumer's to say. out := make([]catalogue.Grant, 0, len(issued)) + var withheld []catalogue.Overflow for _, s := range issued { plan, settings, err := planFor(ctx, open, s.Consumer) switch { @@ -964,11 +975,11 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran // The mesh could not be asked what they wanted, which is not the same as their wanting // nothing — and withholding a grant on that reading takes a consumer's access away // (novox/hq 04-ISSUES/152). - return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Name, err) + return nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Name, err) } values, asks, err := plan.ContributionsFrom(s.Name, s.ConsumerModule, settings) if err != nil { - return nil, err + return nil, nil, err } // A port in there is the consumer's software port until this. The consumer is on another // machine, so the assignment that moved it is that machine's — fetched here rather than @@ -976,7 +987,7 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran // this case (novox/hq 04-ISSUES/038, the cross-node half). published, err := portsOn(ctx, inv, s.Consumer, s.ConsumerModule) if err != nil { - return nil, err + return nil, nil, err } values = catalogue.AtPublishedPort(values, s.ConsumerModule, published) from := s.ConsumerModule @@ -987,9 +998,10 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran 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 0049). 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. + // derives the same login the consumer does (novox/hq ADR 0049). Judged against the bound of + // this provision, as the consumer's resolution states it from the provider's offer (ADR + // 0225): a consumer it would not fit is left out of the grants and said, rather than a login a + // provider silently shortened — and rather than this whole machine refused for it. slug := "" for _, mm := range plan.Modules { if mm.Module == s.ConsumerModule { @@ -998,15 +1010,32 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran } } if from != "" { - if err := catalogue.CheckIdentity(s.Consumer, catalogue.IdentitySource(slug, s.ConsumerModule)); err != nil { - return nil, err + bound := boundOfGrant(plan, s, node) + source := catalogue.IdentitySource(slug, s.ConsumerModule) + if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil { + withheld = append(withheld, catalogue.Overflow{Provision: s.Name, Provider: node, + Consumer: s.Consumer, Module: s.ConsumerModule, + Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound}) + continue } } out = append(out, catalogue.Grant{ Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer], From: from, Values: values, Slug: slug, Sealed: s.ForProvider, Local: s.Local}) } - return out, nil + return out, withheld, nil +} + +// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this +// grant answers. A requirement not found there is held to the tightest bound the mesh knows rather +// than to none: what the provider keeps of it is not known here. +func boundOfGrant(consumer catalogue.Resolution, s inventory.Secret, provider string) catalogue.IdentityBound { + for _, n := range consumer.Needs { + if n.Name == s.Name && n.For == s.ConsumerModule && n.From == provider { + return n.Identity + } + } + return catalogue.DefaultIdentityBound } // listensLines is what a person is told about what this module would open, and why — the same @@ -1082,6 +1111,11 @@ func planCommand(ctx context.Context, args []string) error { left := plan.LeftOut(settings, record.Adopted) reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left}) } + // And which of its modules no provider will grant, because the identity overflows the bound of + // what it requires (novox/hq ADR 0225) — said on the machine the remedy is for. + for _, o := range plan.Overflowing() { + fmt.Printf("%s: %s\n", args[0], o) + } // And a setting that reaches nothing — refused where it is stored, and said here for one // stored before its definition moved from under it. for _, m := range plan.Modules { diff --git a/cmd/mesh-controller/readable.go b/cmd/mesh-controller/readable.go index 6b58a72..6db9d54 100644 --- a/cmd/mesh-controller/readable.go +++ b/cmd/mesh-controller/readable.go @@ -83,6 +83,9 @@ type meshStatus struct { // document without this called the mesh well while the identity provider refused every consumer // for a day (04-ISSUES/179). Failing []inventory.ProviderStanding `json:"failing,omitempty"` + // Overflowing is every module whose identity overflows the bound of a provision it requires, and + // so is left out of its provider's grants (novox/hq ADR 0225). Absent when every identity fits. + Overflowing []catalogue.Overflow `json:"overflowing,omitempty"` } // machineFiltered is one rule set on a converged machine that the mesh did not write and that @@ -216,6 +219,7 @@ func statusAsJSON(asked answers) ([]byte, error) { } out.Unheld = asked.unheld out.Failing = asked.failing + out.Overflowing = asked.overflowing for name := range asked.refused { out.Unresolved = append(out.Unresolved, machineUnresolved{ Node: name, Problem: asked.refused[name]}) diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index d9ff4f1..d4dc0de 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -43,6 +43,9 @@ type sendable struct { LeftOut []string // leftOutWhy is why each was, for push and plan to say; never on the wire. leftOutWhy map[string]string + // withheld is every consumer this machine's grants leave out, because its identity overflows the + // provision's bound (novox/hq ADR 0225); for push and plan to say, never on the wire. + withheld []catalogue.Overflow // Builds is the build of each module this declaration carries — module to the commit its build // was made from — recorded with the send and never on the wire (novox/hq issue 259, ADR 0221). // Composed only on the send path; nil records that it is not known. diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index 34af279..7291043 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -302,6 +302,19 @@ func printStatus(asked answers) error { fmt.Printf("\n `assign ` meets it; reported until every machine has its holders, then refused\n\n") } + if len(asked.overflowing) > 0 { + // **Reported, and the provider still pushed** (novox/hq ADR 0225, issue 263). Each is left + // out of its provider's grants, so the module holds a login nothing created; the provider's + // machine is sent everything else rather than refused for one consumer elsewhere. + fmt.Printf("%d module(s) identified too long for a provision they require, and not granted it:\n", + len(asked.overflowing)) + for _, o := range asked.overflowing { + fmt.Printf(" %-12s %-20s %-22s %q is %d, %s keeps %d\n", o.Consumer, o.Module, o.Provision, + o.Identity, len(o.Identity), o.Bound.In, o.Bound.Max) + } + fmt.Printf("\n a shorter `slug` in the module's definition fits it; `module check` refuses one before merge\n\n") + } + if adopted := adoptedNodes(nodes); len(adopted) > 0 { // Said, because nothing forces the flip: a node left adopted is visible here rather than // read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well". @@ -419,6 +432,10 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) { return answers{}, err } out.unheld = append(out.unheld, plan.Unheld...) + // And which of its modules a provider leaves out of its grants, for an identity too long + // for what the provision keeps (novox/hq ADR 0225) — judged from the consumer's own + // resolution, as the provider's composition judges it. + out.overflowing = append(out.overflowing, plan.Overflowing()...) } // And every consumer a provider says it keeps failing (novox/hq ADR 0224). Read from what the // providers announced: nothing else in the mesh knows whether a provision is being made. @@ -538,7 +555,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven func (a answers) well() bool { return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 && len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 && - len(a.filtered) == 0 && len(a.unheld) == 0 && len(a.failing) == 0 + len(a.filtered) == 0 && len(a.unheld) == 0 && len(a.failing) == 0 && len(a.overflowing) == 0 } // hostSplit is which machines report which host version, for every version more than one machine diff --git a/internal/catalogue/catalogue_check_test.go b/internal/catalogue/catalogue_check_test.go index 06f578d..998763b 100644 --- a/internal/catalogue/catalogue_check_test.go +++ b/internal/catalogue/catalogue_check_test.go @@ -119,3 +119,41 @@ func TestEveryCatalogueStoreSaysHowItIsBackedUp(t *testing.T) { t.Fatal("no module in the catalogue provides a store, so this proved nothing") } } + +// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every +// module's identity, on the longest machine name, fits the bound of every provision it wants. An +// overflow fails here, in the pull request that introduces it, rather than on the provider's machine +// the first time a real machine's name meets the module's (issue 263). +func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + shelf := Shelf{} + for _, p := range found { + raw, err := os.ReadFile(p) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + shelf[m.Module] = m + } + for _, p := range IdentityProblems(shelf, DefaultLongestMachine) { + t.Error(p) + } + // The night it was found: the resolver provision bounds nothing, and the object store still 20. + if dns, ok := shelf["dnsmasq"]; ok { + if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() { + t.Errorf("the resolver provision bounds its consumers' identities: %+v", b) + } + } + if store, ok := shelf["minio"]; ok { + if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 { + t.Errorf("the object store's access key is not bounded at 20: %+v", b) + } + } +} diff --git a/internal/catalogue/consumer_into_serves.go b/internal/catalogue/consumer_into_serves.go index 897411d..43263b3 100644 --- a/internal/catalogue/consumer_into_serves.go +++ b/internal/catalogue/consumer_into_serves.go @@ -114,7 +114,9 @@ func consumerInto(value, as string) (string, error) { // asDNSLabel writes a minted identity as a DNS label. // // The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and -// already short enough for the tightest backend they reach (CheckIdentity, twenty characters). So +// already inside the bound of the provision serving them: a provider that serves one as a DNS label +// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without +// saying is held to twenty (IdentityBoundOf). So // this is the separator and nothing else — no lower-casing of what is already lower case, no // truncation to a limit the identity is already inside, no padding of a name that is already long // enough. Each of those would be the mesh guessing at a rule it has not been given. @@ -141,11 +143,31 @@ func CheckServes(m Manifest) []string { problems = append(problems, fmt.Sprintf( "%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err)) } + // A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own + // bound has to keep the identity inside one (ADR 0225). + if strings.Contains(text, "${consumer:as:dns}") { + if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit { + problems = append(problems, fmt.Sprintf( + "%s serves %s's consumers their identity as a DNS label in %q, and bounds "+ + "that identity at %s: a label keeps %d — state an `identity` of at most %d", + m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit)) + } + } } } return problems } +// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4). +const dnsLabelLimit = 63 + +func boundWords(b IdentityBound) string { + if !b.Bounded() { + return "nothing" + } + return fmt.Sprintf("%d", b.Max) +} + func sortedServes(serves map[string]map[string]any) []string { out := make([]string, 0, len(serves)) for k := range serves { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 5fb5a19..5e50b6a 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -170,6 +170,10 @@ type Rendering struct { // rather than resolved, because who consumes a node is a fact about the rest of the mesh and // resolution answers questions about one machine. Grants []Grant + // Withheld is every consumer left out of Grants because its identity overflows the provision's + // bound (novox/hq ADR 0225). Composed into nothing; carried so the machine's declaration can say + // whom it does not serve, and why, beside what it does. + Withheld []Overflow // Ports is where this machine puts what each module needs reachable, by module and by the // port the software itself uses (novox/hq ADR 0038). diff --git a/internal/catalogue/identity.go b/internal/catalogue/identity.go index 05960ce..0400735 100644 --- a/internal/catalogue/identity.go +++ b/internal/catalogue/identity.go @@ -3,6 +3,7 @@ package catalogue import ( "fmt" "regexp" + "sort" "strings" ) @@ -56,13 +57,36 @@ func ConsumerIdentity(node, module string) string { return IdentityPrefix + clean(node) + "_" + clean(module) } -// 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 0049), not silently cut to fit. -const identityLimit = 20 +// 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"` +} -// CheckIdentity refuses an identity that would not fit the tightest backend a consumer reaches. +// 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 @@ -70,12 +94,127 @@ const identityLimit = 20 // 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) <= identityLimit { + if len(got) <= bound.Max { return nil } return fmt.Errorf( - "%s on %s is identified as %q, %d characters where a backend (an S3 access key) keeps %d — "+ + "%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), identityLimit) + 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 } diff --git a/internal/catalogue/identity_bound_test.go b/internal/catalogue/identity_bound_test.go new file mode 100644 index 0000000..f7f27c0 --- /dev/null +++ b/internal/catalogue/identity_bound_test.go @@ -0,0 +1,190 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// Each test names the decision it defends: novox/hq ADR 0225, which refines ADR 0049 after issue 263. + +// The provision a module requires sets the bound on its identity, not the tightest backend anywhere. +func TestABoundIsTheProvisionsOwn(t *testing.T) { + store := Manifest{Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}} + database := Manifest{Module: "db", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 63, In: "a PostgreSQL role"}}}, + Receives: map[string]string{"postgres-database": "/var/lib/mesh/db/mesh.json"}} + if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 || b.In != "an S3 access key" { + t.Errorf("an object store's stated bound was not taken: %+v", b) + } + if b := database.IdentityBoundOf("postgres-database"); b.Max != 63 { + t.Errorf("a database's stated bound was not taken: %+v", b) + } + // mesh_workstation_keycloak is 25: refused by the object store, accepted by the database. + if CheckIdentityWithin("workstation", "keycloak", store.IdentityBoundOf("s3-bucket")) == nil { + t.Error("a 25-character identity fit a 20-character access key") + } + if err := CheckIdentityWithin("workstation", "keycloak", database.IdentityBoundOf("postgres-database")); err != nil { + t.Errorf("a database consumer paid the object store's limit: %v", err) + } +} + +// What an offer leaves unsaid follows from whether its provider can keep a name at all. +func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) { + // Told nothing about its consumers — no receives, nothing served from their identity: no bound. + // The resolver provision is exactly this, and its consumers paid an object store's limit (263). + resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}} + if b := resolver.IdentityBoundOf("wildcard-resolution"); b.Bounded() { + t.Errorf("a provision that is told nothing of its consumers bounds them: %+v", b) + } + // Told each consumer and silent about its backend: the old global bound, not none. + told := Manifest{Module: "told", Provides: []Offer{{Name: "thing", Scope: ScopeMesh}}, + Receives: map[string]string{"thing": "/var/lib/mesh/told/mesh.json"}} + if b := told.IdentityBoundOf("thing"); b.Max != DefaultIdentityLimit { + t.Errorf("a provider told its consumers and silent about its backend is not held to %d: %+v", + DefaultIdentityLimit, b) + } + // Serving a value built from the identity is being told it, too. + serving := Manifest{Module: "serving", Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"bucket": {"name": "b-${consumer:as:dns}"}}} + if b := serving.IdentityBoundOf("bucket"); b.Max != DefaultIdentityLimit { + t.Errorf("a provider deriving a name from its consumers is not bounded: %+v", b) + } + // And `false` says it outright, even for a provider that receives. + routes := Manifest{Module: "routes", Provides: []Offer{{Name: "route", Scope: ScopeMesh, + Identity: &OfferIdentity{None: true}}}, + Receives: map[string]string{"route": "/var/lib/mesh/routes/mesh.json"}} + if b := routes.IdentityBoundOf("route"); b.Bounded() { + t.Errorf("`identity: false` still bounds: %+v", b) + } +} + +// The field reads as written and writes back the same, and refuses what says nothing. +func TestAnOffersIdentityIsParsedStrictly(t *testing.T) { + for _, raw := range []string{ + `{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`, + `{"name":"wildcard-resolution","scope":"mesh","identity":false}`, + `{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`, + } { + var o Offer + if err := json.Unmarshal([]byte(raw), &o); err != nil { + t.Fatalf("%s: %v", raw, err) + } + back, err := json.Marshal(o) + if err != nil || string(back) != raw { + t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err) + } + } + for _, raw := range []string{ + `{"name":"x","identity":true}`, + `{"name":"x","identity":{"max":20,"in":"y","most":3}}`, + } { + var o Offer + if err := json.Unmarshal([]byte(raw), &o); err == nil { + t.Errorf("accepted %s", raw) + } + } + bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{ + {Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}}, + {Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}}, + }} + raw, err := json.Marshal(bad) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") || + !strings.Contains(err.Error(), "the shortest the mesh makes") { + t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err) + } +} + +// Refused before merge: the catalogue check judges each module's identity, on the longest machine +// name, against the bound of every provision it wants — and names the module and the slug to set. +func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) { + shelf := Shelf{ + "objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}, + "photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}}, + "files": {Module: "files", Requires: []string{"s3-bucket"}}, + } + problems := IdentityProblems(shelf, 6) + if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") || + !strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) || + !strings.Contains(problems[0], "`slug` of at most 8 characters") { + t.Fatalf("one overflow, named with its remedy, was expected: %q", problems) + } + // A longer machine name refuses more: the check is about the mesh's machines, not one. + if got := IdentityProblems(shelf, 10); len(got) != 2 { + t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got) + } + // And a slug is the remedy it names. + album := shelf["photoalbum"] + album.Slug = "album" + shelf["photoalbum"] = album + if got := IdentityProblems(shelf, 6); len(got) != 0 { + t.Fatalf("a slug that fits is still refused: %q", got) + } +} + +// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a +// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's +// whole machine with it; the resolver keeps no name, so it is not refused at all. +func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) { + shelf := Shelf{ + "resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}}, + "networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}}, + } + if CheckIdentity("laptop", "networkmanager") == nil { + t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound") + } + if got := IdentityProblems(shelf, 6); len(got) != 0 { + t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got) + } + r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]}, + Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager", + Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}} + if got := r.Overflowing(); len(got) != 0 { + t.Fatalf("a keyless requirement is reported as overflowing: %+v", got) + } +} + +// The consumer's side of the same judgement the provider's composition makes: what `status` says. +func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) { + bound := IdentityBound{Max: 20, In: "an S3 access key"} + r := Resolution{Node: "laptop", + Modules: []Manifest{{Module: "photoalbum"}, {Module: "files"}, {Module: "gallery", Slug: "gal"}}, + Needs: []Needed{ + {Name: "s3-bucket", From: "anchor", For: "photoalbum", Identity: bound}, + {Name: "s3-bucket", From: "anchor", For: "photoalbum", Local: "second", Identity: bound}, + {Name: "s3-bucket", From: "anchor", For: "files", Identity: bound}, + {Name: "s3-bucket", From: "anchor", For: "gallery", Identity: bound}, + {Name: "licence", From: "records", For: "photoalbum", ByRecord: true, Identity: bound}, + }} + got := r.Overflowing() + if len(got) != 1 || got[0].Module != "photoalbum" || got[0].Identity != "mesh_laptop_photoalbum" || + got[0].Provider != "anchor" { + t.Fatalf("one overflow, once, was expected: %+v", got) + } + if said := got[0].String(); !strings.Contains(said, "an S3 access key keeps 20") || + !strings.Contains(said, "slug") { + t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said) + } +} + +// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one. +func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) { + serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}} + wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 255, In: "a client id"}}}} + if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") { + t.Fatalf("a 255-character bound on a DNS label passed: %q", got) + } + unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}} + if got := CheckServes(unsaid); len(got) != 0 { + t.Fatalf("the default bound (20) on a DNS label was refused: %q", got) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 20c1874..23ca502 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -8,6 +8,7 @@ package catalogue import ( "bytes" "encoding/json" + "errors" "fmt" "regexp" "sort" @@ -153,6 +154,84 @@ type Offer struct { // a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked // is the misassignment research 026 found. Unmet, the requirement is refused naming who could. Reach string `json:"reach,omitempty"` + // Identity is the longest consumer identity this provision's backend keeps (novox/hq ADR 0225): + // `{"max": 63, "in": "a PostgreSQL role"}`, or `false` for a provision that keeps no name derived + // from its consumer. Unsaid, the mesh assumes the tightest backend it knows when the provider is + // told its consumers, and no bound when it is not — see IdentityBoundOf. + Identity *OfferIdentity `json:"identity,omitempty"` +} + +// OfferIdentity is what an offer says about the names its backend keeps for its consumers. +type OfferIdentity struct { + // None is set by `"identity": false`: the provision keeps no name derived from its consumer. + None bool + // Max is the longest identity kept, in characters; zero with In set means no limit worth stating. + Max int + // In is what keeps it, for a refusal to quote. + In string +} + +// UnmarshalJSON accepts `false` or `{"max": N, "in": "..."}`. +func (i *OfferIdentity) UnmarshalJSON(raw []byte) error { + var flag bool + if err := json.Unmarshal(raw, &flag); err == nil { + if flag { + return errors.New("an offer's identity is false (it keeps no name) or {max, in}; true says nothing") + } + *i = OfferIdentity{None: true} + return nil + } + var full struct { + Max int `json:"max,omitempty"` + In string `json:"in"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&full); err != nil { + return fmt.Errorf("an offer's identity is false or {max, in}: %w", err) + } + *i = OfferIdentity{Max: full.Max, In: full.In} + return nil +} + +// MarshalJSON writes it back in the form it was written. +func (i OfferIdentity) MarshalJSON() ([]byte, error) { + if i.None { + return []byte("false"), nil + } + return json.Marshal(struct { + Max int `json:"max,omitempty"` + In string `json:"in"` + }{i.Max, i.In}) +} + +// IdentityBoundOf is the bound this module's offer of a provision puts on its consumers' identities +// (novox/hq ADR 0225). +// +// **What an offer says, it gets.** Where it says nothing, the bound follows from whether the +// provider can keep a name at all: a provider that receives the provision, or serves its consumers +// a value built from their identity, is told who each consumer is and may create a name from it in +// a backend nobody measured — so it keeps the old global bound, DefaultIdentityBound. One that does +// neither is told nothing about its consumers and keeps nothing of them: no bound. The resolver +// provision is that case, and its consumers paid an object store's limit until this (issue 263). +func (m Manifest) IdentityBoundOf(provision string) IdentityBound { + for _, o := range m.Provides { + if o.Name != provision || o.Identity == nil { + continue + } + if o.Identity.None { + return IdentityBound{} + } + return IdentityBound{Max: o.Identity.Max, In: o.Identity.In} + } + if _, receives := m.Receives[provision]; receives { + return DefaultIdentityBound + } + if served, err := json.Marshal(m.Serves[provision]); err == nil && + bytes.Contains(served, []byte("${consumer:as")) { + return DefaultIdentityBound + } + return IdentityBound{} } // MachineReach is whether a provision is usable only on its provider's own machine. @@ -206,20 +285,21 @@ func (o *Offer) UnmarshalJSON(raw []byte) error { Scope string `json:"scope,omitempty"` Credential *OfferCredential `json:"credential,omitempty"` Reach string `json:"reach,omitempty"` + Identity *OfferIdentity `json:"identity,omitempty"` } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&full); err != nil { - return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach}: %w", err) + return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach, identity}: %w", err) } - o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach + o.Name, o.Scope, o.Credential, o.Reach, o.Identity = full.Name, full.Scope, full.Credential, full.Reach, full.Identity return nil } // MarshalJSON writes back the short form when there is nothing else to say, so a manifest that // went through the mesh comes out looking like the one that went in. func (o Offer) MarshalJSON() ([]byte, error) { - if o.Scope == "" && o.Credential == nil && o.Reach == "" { + if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil { return json.Marshal(o.Name) } return json.Marshal(struct { @@ -227,7 +307,8 @@ func (o Offer) MarshalJSON() ([]byte, error) { Scope string `json:"scope,omitempty"` Credential *OfferCredential `json:"credential,omitempty"` Reach string `json:"reach,omitempty"` - }{o.Name, o.Scope, o.Credential, o.Reach}) + Identity *OfferIdentity `json:"identity,omitempty"` + }{o.Name, o.Scope, o.Credential, o.Reach, o.Identity}) } // Manifest is everything a module says about itself. @@ -237,9 +318,10 @@ type Manifest struct { // Slug is a short identifier the mesh uses in place of the module name when it derives a // consumer's login (novox/hq ADR 0049). 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. + // exists because `mesh__` must fit the bound of every provision the module + // requires — an S3 access key's 20 characters is the tightest — and a long module name would + // overflow it (ADR 0225: the bound is the provision's own, and a keyless one has none). 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; @@ -1265,8 +1347,10 @@ func ParseManifest(raw []byte) (Manifest, error) { "%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 0049). 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. + // charset as a name; its length is judged against the bound of each provision it wants on the + // longest machine name by the catalogue check (IdentityProblems, ADR 0225), and against the + // machine it is on when its provider grants it — 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)) @@ -1303,6 +1387,20 @@ func ParseManifest(raw []byte) (Manifest, error) { if !name.MatchString(p) { problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p)) } + // A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it + // leaves room for the shortest identity the mesh makes, or it would refuse every consumer. + if id := offer.Identity; id != nil && !id.None { + if strings.TrimSpace(id.In) == "" { + problems = append(problems, fmt.Sprintf( + "%s bounds the identities of %s's consumers without saying what keeps them: `in`", + m.Module, p)) + } + if shortest := len(IdentityPrefix) + 3; id.Max < 0 || id.Max > 0 && id.Max < shortest { + problems = append(problems, fmt.Sprintf( + "%s bounds %s's consumers' identities at %d characters, and the shortest the mesh "+ + "makes is %d", m.Module, p, id.Max, shortest)) + } + } if offer.Credential != nil { own, declared := m.OwnSecrets[offer.Credential.Own] switch { diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index b0972a7..a5bae27 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -194,6 +194,11 @@ type Needed struct { // state, not a consumer missing its key. Set by the plan, which is the only layer that knows a // licence's manager; empty for every consumer. Manager bool + // Identity is the longest consumer identity the answering provision keeps (novox/hq ADR 0225), + // from the provider's own offer: what the mesh judges this consumer's identity against, on the + // consumer's side for `status` and on the provider's before it grants. No bound for a provision + // answered by a record, which keeps no name of anybody's. + Identity IdentityBound } // Refusal is why a set of assignments cannot become a declaration. @@ -403,7 +408,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world needs = append(needs, Needed{ Name: want, From: node.Name, At: at, Serves: servedByOne(by, want), For: because[want], - SharedOwn: sharedByOne(by, want)}) + SharedOwn: sharedByOne(by, want), Identity: by.IdentityBoundOf(want)}) } else if served := servedByOne(by, want); len(served) > 0 { // Answered here with no credential to mint, but the provider serves facts the // consumer cannot guess — a port, a model name — and so still needs a binding. @@ -447,11 +452,15 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world return } shared := "" + // A provider whose definition is not in hand is held to the tightest bound the + // mesh knows, not to none: what it keeps is not known here (ADR 0225). + bound := DefaultIdentityBound if pm, known := catalogue[p.Module]; known { shared, _ = pm.SharedCredentialOf(want) + bound = pm.IdentityBoundOf(want) } needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, - Serves: p.Serves, For: because[want], SharedOwn: shared}) + Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound}) } switch { case world.Unchecked: