From c9dd07f919a27868dba0c77b4d97467c92b82610 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 11:10:17 +0200 Subject: [PATCH] Never make a credential only a person can give, and give it once per provider (hq issue 164, ADR 0337) A Servarr app's API key, an indexer's key and a downloader's password are values the provider's software holds; the pair path made a random value for every consumer of them, sealed it to both ends and reported success, and the provider refused it. An offer may now say `"credential": {"issued-by": "outside"}`: SecretFor never makes a value for it, the plan reads such a pair as given or not given and sends the consumer no credential file until a person gives it, without refusing its machine; remakeShared and SharedSecretFor never make a provider's own secret issued outside either. `secret accept ` seals one value to every consumer that binds the provision from that provider, the provider and the operator, under one generation; the per-pair form is refused for such a provision, and rotate names the accept. A consumer may wait for a provision's credential: the controller excuses the wait only when the store bears it out, and its needs-operator condition names the one accept line. Adds the replay of issue 164 (TestReplay164), written with only what the controller had before. --- cmd/mesh-controller/accept_outside.go | 145 +++++++++++ cmd/mesh-controller/module_health.go | 2 +- cmd/mesh-controller/operator_wait.go | 153 +++++++++-- cmd/mesh-controller/operator_wait_test.go | 4 +- cmd/mesh-controller/outside_accept_test.go | 83 ++++++ cmd/mesh-controller/outside_wait_test.go | 113 +++++++++ cmd/mesh-controller/plan.go | 22 +- cmd/mesh-controller/replay164_test.go | 74 ++++++ cmd/mesh-controller/secret.go | 32 +++ internal/catalogue/issued_credential_test.go | 4 +- internal/catalogue/issued_outside_test.go | 46 ++++ internal/catalogue/manifest.go | 51 +++- internal/inventory/health.go | 8 +- internal/inventory/outside.go | 252 +++++++++++++++++++ internal/inventory/outside_test.go | 195 ++++++++++++++ internal/inventory/secrets.go | 39 +++ internal/link/protocol.go | 5 +- testdata/store-tests | 5 + 18 files changed, 1207 insertions(+), 26 deletions(-) create mode 100644 cmd/mesh-controller/accept_outside.go create mode 100644 cmd/mesh-controller/outside_accept_test.go create mode 100644 cmd/mesh-controller/outside_wait_test.go create mode 100644 cmd/mesh-controller/replay164_test.go create mode 100644 internal/catalogue/issued_outside_test.go create mode 100644 internal/inventory/outside.go create mode 100644 internal/inventory/outside_test.go diff --git a/cmd/mesh-controller/accept_outside.go b/cmd/mesh-controller/accept_outside.go new file mode 100644 index 00000000..a4b8ae96 --- /dev/null +++ b/cmd/mesh-controller/accept_outside.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164, issue 165): +// given once for its provider, at the controller's terminal, and sealed in one act to every consumer that binds the +// provision from that provider now. The value is never read back, printed or kept. + +// outsideOffer is the provider module's own name when `secret accept ` names a provision that +// module offers with a credential issued outside the mesh, and "" when it names anything else (an own secret, or a +// pair with --provider). +func outsideOffer(ctx context.Context, module, name, provider string) (string, error) { + if provider != "" { + return "", nil + } + open, err := openStores(ctx) + if err != nil { + return "", err + } + defer open.Close() + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + return "", err + } + if m, known := shelf[module]; known && m.IssuedOutsideOf(name) { + return module, nil + } + return "", nil +} + +// offeredOutsideBy is a module that offers the provision with a credential issued outside the mesh, or "". +func offeredOutsideBy(ctx context.Context, provision string) (string, error) { + open, err := openStores(ctx) + if err != nil { + return "", err + } + defer open.Close() + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + return "", err + } + module, _ := catalogue.OffersIssuedOutside(shelf, provision) + return module, nil +} + +// consumersFrom is every consumer that binds a provision from one provider now, found by resolving every machine as +// its next send would, and the machines that could not be resolved, with why. +func consumersFrom(ctx context.Context, open *stores, provider, providerModule, provision string) ( + []inventory.PairEnd, []string, error) { + nodes, err := open.inventory.Nodes(ctx) + if err != nil { + return nil, nil, err + } + var out []inventory.PairEnd + var unresolved []string + for _, n := range nodes { + plan, _, err := planFor(ctx, open, n.Name) + if err != nil { + unresolved = append(unresolved, fmt.Sprintf("%s: %s", n.Name, oneLine(err.Error()))) + continue + } + out = append(out, bindsFrom(plan, n.Name, provider, providerModule, provision)...) + } + return out, unresolved, nil +} + +// bindsFrom is every consumer on one machine whose resolution answers the provision from that provider. Pure. +func bindsFrom(plan catalogue.Resolution, machine, provider, providerModule, provision string) []inventory.PairEnd { + var out []inventory.PairEnd + for _, need := range plan.Needs { + if need.ByRecord || need.Name != provision || need.From != provider { + continue + } + if need.Module != "" && need.Module != providerModule { + continue + } + out = append(out, inventory.PairEnd{Consumer: machine, ConsumerModule: need.For, Local: need.Local}) + } + sort.Slice(out, func(i, j int) bool { + return out[i].ConsumerModule+"\x00"+out[i].Local < out[j].ConsumerModule+"\x00"+out[j].Local + }) + return out +} + +// acceptForProvision gives a provision's credential issued outside the mesh, once for its provider (ADR 0337 §3). +func acceptForProvision(ctx context.Context, provider, providerModule, provision, value string) error { + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + consumers, unresolved, err := consumersFrom(ctx, open, provider, providerModule, provision) + if err != nil { + return err + } + accepted, err := open.inventory.AcceptSecretForProvision(ctx, provision, provider, providerModule, value, consumers) + value = "" + if err != nil { + return err + } + fmt.Print(acceptedSaid(provider, providerModule, provision, accepted, unresolved)) + if len(accepted.Sealed) == 0 { + return nil + } + if err := sendTo(ctx, open, accepted.Machines); err != nil { + return fmt.Errorf("%s is sealed and could not be sent: %w; `push %s` sends it", provision, err, + strings.Join(accepted.Machines, "`, `push ")) + } + fmt.Printf(" sent %s; each consumer starts again on it\n", strings.Join(accepted.Machines, ", ")) + return nil +} + +// acceptedSaid is what one accept for a provider did, in words that never carry the value. Pure. +func acceptedSaid(provider, providerModule, provision string, a inventory.ProvisionAccepted, unresolved []string) string { + var b strings.Builder + if len(a.Sealed) == 0 { + fmt.Fprintf(&b, "%s from %s on %s: no consumer binds it now, so nothing was sealed; a consumer that binds "+ + "later waits for it, and this line given again seals it to every consumer then\n", provision, + providerModule, provider) + } else { + var names []string + for _, c := range a.Sealed { + names = append(names, fmt.Sprintf("%s on %s%s", c.ConsumerModule, c.Consumer, asLocal(c.Local))) + } + fmt.Fprintf(&b, "%s from %s on %s is given, sealed to %s and to %s\n", provision, providerModule, provider, + strings.Join(names, ", "), provider) + fmt.Fprintf(&b, " the mesh cannot read it back, never makes one of its own for it, and will not rotate it\n") + } + for _, c := range a.Unsealable { + fmt.Fprintf(&b, " not sealed to %s on %s: no sealing key on that machine; give it again once it has one\n", + c.ConsumerModule, c.Consumer) + } + for _, u := range unresolved { + fmt.Fprintf(&b, " not known whether a consumer on %s binds it, which did not resolve; give it again once it does\n", u) + } + return b.String() +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 41c027e3..1975c6b7 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -75,7 +75,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts, Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root} for _, w := range r.Waits { - kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What}) + kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, Provision: w.Provision, What: w.What}) } resources = append(resources, kept) } diff --git a/cmd/mesh-controller/operator_wait.go b/cmd/mesh-controller/operator_wait.go index 0357a4ce..48e8bae4 100644 --- a/cmd/mesh-controller/operator_wait.go +++ b/cmd/mesh-controller/operator_wait.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "slices" "sort" "strings" "time" @@ -44,6 +45,28 @@ func needsOperatorKey(module, node string) string { type operatorWaitFacts struct { manifests map[string]catalogue.Manifest given map[string]map[string]time.Time + // outside is, per provision some module offers with a credential issued outside the mesh, that provider module + // and the machines it is assigned to (novox/hq ADR 0337); pairsGiven, per "@", the provisions + // it holds a value a person gave for, sealed to the keys both ends have now. Absent is not known: no wait for a + // provision of it is excused. + outside map[string]outsideProvider + pairsGiven map[string]map[string]bool +} + +// outsideProvider is the module offering a provision whose credential only a person gives, and where it runs. +type outsideProvider struct { + Module string + Machines []string +} + +// acceptLine is the one line that gives an outside provision's credential for every consumer of its provider: on +// the provider's machine when it runs on one, named as when on several or none. +func (p outsideProvider) acceptLine(provision string) string { + machine := "" + if len(p.Machines) == 1 { + machine = p.Machines[0] + } + return inventory.AcceptLine(machine, p.Module, provision) } // checkWait is nil when a wait is excused, and otherwise why not, in words. Pure. @@ -52,9 +75,18 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er if !known { return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module) } + named := 0 + for _, n := range []string{w.Secret, w.Setting, w.Provision} { + if n != "" { + named++ + } + } switch { - case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "": - return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w)) + case named != 1: + return fmt.Errorf("says it waits, naming %s, where a wait names one secret, one setting or one provision", + waitNames(w)) + case w.Provision != "": + return checkProvisionWait(module, machine, w, m, f) case w.Setting != "": if _, declared := m.Settings[w.Setting]; !declared { return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module) @@ -80,20 +112,46 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er return nil } +// checkProvisionWait excuses a wait for a provision's credential only when the store bears it out (novox/hq ADR 0337 +// §6): the module requires the provision, a provider offers it with a credential issued outside the mesh, and the +// module on this machine holds no value a person gave for it under the keys both ends have now. +func checkProvisionWait(module, machine string, w inventory.Wait, m catalogue.Manifest, f operatorWaitFacts) error { + if !slices.Contains(m.Requires, w.Provision) { + return fmt.Errorf("says it waits for the provision %s, which %s does not require", w.Provision, module) + } + if _, outside := f.outside[w.Provision]; !outside { + return fmt.Errorf("says it waits for the provision %s, whose credential the mesh gives itself: only a "+ + "credential issued outside the mesh waits for the operator", w.Provision) + } + given, readable := f.pairsGiven[module+"@"+machine] + if !readable { + return fmt.Errorf("says it waits for the provision %s, and what was given on %s could not be read", w.Provision, machine) + } + if given[w.Provision] { + return fmt.Errorf("says it waits for the provision %s, whose credential was given", w.Provision) + } + return nil +} + // waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own. const waitRefusedPrefix = "says it waits" // waitNames is what a wait names, as "the secret x" or "the setting y". func waitNames(w inventory.Wait) string { - switch { - case w.Secret != "" && w.Setting != "": - return "the secret " + w.Secret + " and the setting " + w.Setting - case w.Secret != "": - return "the secret " + w.Secret - case w.Setting != "": - return "the setting " + w.Setting + var names []string + if w.Secret != "" { + names = append(names, "the secret "+w.Secret) } - return "nothing" + if w.Setting != "" { + names = append(names, "the setting "+w.Setting) + } + if w.Provision != "" { + names = append(names, "the provision "+w.Provision) + } + if len(names) == 0 { + return "nothing" + } + return strings.Join(names, " and ") } // checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is @@ -107,11 +165,17 @@ func checkWaiting(machine string, rs []inventory.ResourceHealth, f operatorWaitF if len(r.Waits) == 0 { why = fmt.Errorf("says it waits, and names nothing it waits for") } + waits := make([]inventory.Wait, 0, len(r.Waits)) for _, w := range r.Waits { if why == nil { why = checkWait(r.Module, machine, w, f) } + if p, outside := f.outside[w.Provision]; outside && w.Provision != "" { + w.Act = p.acceptLine(w.Provision) + } + waits = append(waits, w) } + r.Waits = waits if why != nil { r.State, r.Reason = link.StateUnhealthy, why.Error() } @@ -150,6 +214,10 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string { if w.Secret != "" { part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret) } + if w.Provision != "" && w.Act != "" { + // One line for the provider, which seals it to every consumer of it (ADR 0337 §3, §7). + part += fmt.Sprintf(", given for every consumer with `%s`", w.Act) + } parts = append(parts, part+")") } return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; ")) @@ -172,7 +240,7 @@ func needsOperatorObservation(module, node string, waits []inventory.Wait, rs [] func needsOperatorWords(module, node string, waits []inventory.Wait) words { var acts []string seen := map[string]bool{} - secret := false + secret, provision := false, false for _, w := range waits { var act string switch { @@ -180,6 +248,9 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words { act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true case w.Setting != "": act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node) + case w.Provision != "": + // ADR 0337 §7: "give of on "; the line is in the summary. + act, provision = "give "+w.What, true } if act != "" && !seen[act] { seen[act] = true @@ -197,6 +268,10 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words { explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " + "is sealed to the machine." } + if provision { + explanation += " A key only you can give is given once for the app that issued it, and reaches every " + + "module that uses it." + } explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given." return words{ Headline: fmt.Sprintf("%s waits for you on %s", module, node), @@ -217,6 +292,12 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string if f.given == nil { f.given = map[string]map[string]time.Time{} } + if f.outside == nil { + f.outside = map[string]outsideProvider{} + } + if f.pairsGiven == nil { + f.pairsGiven = map[string]map[string]bool{} + } if inv == nil { return } @@ -238,15 +319,59 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string } } } - if _, read := f.given[module+"@"+machine]; read { + if _, read := f.given[module+"@"+machine]; !read { + if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil { + f.given[module+"@"+machine] = given + } + } + if _, read := f.pairsGiven[module+"@"+machine]; !read { + if given, err := inv.GivenPairsOf(ctx, machine, module); err == nil { + f.pairsGiven[module+"@"+machine] = given + } + } + // The provisions it requires whose credential only a person gives, and where their provider runs (ADR 0337). + m, known := f.manifests[module] + if !known { continue } - if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil { - f.given[module+"@"+machine] = given + for _, provision := range m.Requires { + if _, read := f.outside[provision]; read { + continue + } + if shelf == nil { + var err error + if shelf, err = inv.Catalogue(ctx); err != nil { + shelf = map[string]catalogue.Manifest{} + } + } + provider, outside := catalogue.OffersIssuedOutside(shelf, provision) + if !outside { + continue + } + f.outside[provision] = outsideProvider{Module: provider, Machines: machinesRunning(ctx, inv, provider)} } } } +// machinesRunning is every machine a module is assigned to, by name; a read that fails is left out. +func machinesRunning(ctx context.Context, inv *inventory.Inventory, module string) []string { + nodes, err := inv.Nodes(ctx) + if err != nil { + // Only the summary's accept line reads it, which then names the provider's machine "". + // empty-on-error: no wait is excused or refused by it + return nil + } + var out []string + for _, n := range nodes { + assigned, err := inv.Assigned(ctx, n.Name) + if err == nil && slices.Contains(assigned, module) { + out = append(out, n.Name) + } + } + sort.Strings(out) + return out +} + // waitingModules is every module with a waiting resource in a statement. func waitingModules(rs []inventory.ResourceHealth) []string { seen := map[string]bool{} diff --git a/cmd/mesh-controller/operator_wait_test.go b/cmd/mesh-controller/operator_wait_test.go index 6443a3f7..ff98a88b 100644 --- a/cmd/mesh-controller/operator_wait_test.go +++ b/cmd/mesh-controller/operator_wait_test.go @@ -55,8 +55,8 @@ func TestAWaitIsExcusedOnlyWhenItChecksOut(t *testing.T) { {"a secret the mesh makes", inventory.Wait{Part: "p", Secret: "made", What: "w"}, factsGiven(nil), "mesh makes itself"}, {"the bus account", inventory.Wait{Part: "p", Secret: "broker", What: "w"}, factsGiven(nil), "mesh makes itself"}, {"a setting not declared", inventory.Wait{Part: "p", Setting: "logins", What: "w"}, factsGiven(nil), "does not declare"}, - {"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret or one setting"}, - {"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret or one setting"}, + {"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"}, + {"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"}, {"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"}, {"what was given cannot be read", passwordWait, operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"}, diff --git a/cmd/mesh-controller/outside_accept_test.go b/cmd/mesh-controller/outside_accept_test.go new file mode 100644 index 00000000..b3baff33 --- /dev/null +++ b/cmd/mesh-controller/outside_accept_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// One accept for a provider reaches every consumer that binds the provision from it, and each consumer's send +// places it; the per-pair form naming a consumer is refused for such a provision, naming the provider's form; a +// value the mesh made before the rule is not placed (novox/hq ADR 0337 §2, §3, §4, issue 164, issue 165). Needs the +// store (MESH_TEST_POSTGRES), and is named in testdata/store-tests. +func TestOneAcceptForAProviderIsPlacedForEveryConsumer(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + // Before the rule: tv's offer said nothing of its credential, and the send made one per pair. + register(t, open, catalogue.Manifest{Module: "tv", Version: "1", + Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh}}}) + for _, m := range []string{"requests", "indexer"} { + register(t, open, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"}, + Secrets: map[string]string{"tv-api": "/var/lib/mesh/" + m + "/tv-api.secret"}}) + } + for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}, {"laptop", "indexer"}} { + if _, err := assign(ctx, open, a[0], a[1]); err != nil { + t.Fatal(err) + } + } + placed := func(module string) bool { + t.Helper() + plan, settings, err := planFor(ctx, open, "laptop") + if err != nil { + t.Fatalf("laptop was refused: %v", err) + } + declared, err := declarationFor(ctx, open, "laptop", plan, settings) + if err != nil { + t.Fatalf("laptop did not compose: %v", err) + } + for _, r := range declared.Resources { + if r["path"] == "/var/lib/mesh/"+module+"/tv-api.secret" && r["sealed"] != nil { + return true + } + } + return false + } + + if !placed("requests") || !placed("indexer") { + t.Fatal("before the rule, the send made and placed a credential per pair") + } + // The offer now says only a person gives it: the values the mesh made are not placed. + register(t, open, catalogue.Manifest{Module: "tv", Version: "2", + Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh, + Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}}) + if placed("requests") || placed("indexer") { + t.Fatal("a credential nobody gave was placed") + } + + // The per-pair form is refused, naming the provider's form, and reads nothing. + file := filepath.Join(t.TempDir(), "key") + if err := os.WriteFile(file, []byte("the app's key\n"), 0o600); err != nil { + t.Fatal(err) + } + err := secretCommand(ctx, []string{"accept", "laptop", "requests", "tv-api", "--provider", "anchor", "--from", file}) + if err == nil || !strings.Contains(err.Error(), "secret accept anchor tv tv-api") { + t.Fatalf("the per-pair form was not refused naming the provider's: %v", err) + } + + consumers, unresolved, err := consumersFrom(ctx, open, "anchor", "tv", "tv-api") + if err != nil || len(unresolved) != 0 { + t.Fatalf("the consumers of tv on anchor could not be found: %v %v", err, unresolved) + } + if len(consumers) != 2 { + t.Fatalf("the consumers of tv on anchor are %+v; want requests and indexer on laptop", consumers) + } + if _, err := open.inventory.AcceptSecretForProvision(ctx, "tv-api", "anchor", "tv", "the app's key", consumers); err != nil { + t.Fatal(err) + } + if !placed("requests") || !placed("indexer") { + t.Fatal("the value given once for the provider was not placed for every consumer") + } +} diff --git a/cmd/mesh-controller/outside_wait_test.go b/cmd/mesh-controller/outside_wait_test.go new file mode 100644 index 00000000..1c7a76b8 --- /dev/null +++ b/cmd/mesh-controller/outside_wait_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// A consumer may wait for a provision's credential only a person gives (novox/hq ADR 0337 §5–§7, issue 164). + +var requestsManifest = catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api", "db"}, + Secrets: map[string]string{"tv-api": "/s/tv-api", "db": "/s/db"}} + +var tvKeyWait = inventory.Wait{Part: "the tv library", Provision: "tv-api", What: "the API key of tv"} + +func outsideFacts(given map[string]bool) operatorWaitFacts { + return operatorWaitFacts{manifests: map[string]catalogue.Manifest{"requests": requestsManifest}, + outside: map[string]outsideProvider{"tv-api": {Module: "tv", Machines: []string{"media"}}}, + pairsGiven: map[string]map[string]bool{"requests@laptop": given}} +} + +// §6: excused only when the module requires the provision, its provider says it is issued outside, and no value a +// person gave is held for the pair under the keys both ends have now. +func TestAWaitForAProvisionIsExcusedOnlyWhenTheStoreBearsItOut(t *testing.T) { + for _, c := range []struct { + name string + w inventory.Wait + f operatorWaitFacts + says string + }{ + {"required, issued outside, not given", tvKeyWait, outsideFacts(nil), ""}, + {"given", tvKeyWait, outsideFacts(map[string]bool{"tv-api": true}), "was given"}, + {"not required", inventory.Wait{Part: "p", Provision: "music-api", What: "w"}, outsideFacts(nil), "does not require"}, + {"a credential the mesh gives itself", inventory.Wait{Part: "p", Provision: "db", What: "w"}, outsideFacts(nil), + "mesh gives itself"}, + {"what was given cannot be read", tvKeyWait, operatorWaitFacts{ + manifests: map[string]catalogue.Manifest{"requests": requestsManifest}, + outside: map[string]outsideProvider{"tv-api": {Module: "tv"}}}, "could not be read"}, + {"a provision and a secret", inventory.Wait{Part: "p", Provision: "tv-api", Secret: "x", What: "w"}, + outsideFacts(nil), "one secret, one setting or one provision"}, + } { + err := checkWait("requests", "laptop", c.w, c.f) + switch { + case c.says == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)): + t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says) + } + } +} + +// §7: an excused wait is the consumer's needs-operator condition, in plain words, its summary naming the one line +// that gives the credential for every consumer of the provider; the wait as kept is not changed. +func TestAWaitForAProvisionNamesTheOneAcceptForItsProvider(t *testing.T) { + r := inventory.ResourceHealth{Module: "requests", Resource: "requests.app", Kind: "container", Target: "requests", + State: link.StateWaiting, Check: "tool", Waits: []inventory.Wait{tvKeyWait}} + kept := []inventory.ResourceHealth{r} + got := checkWaiting("laptop", kept, outsideFacts(nil)) + if got[0].State != link.StateWaiting { + t.Fatalf("an excused wait for a provision was judged: %+v", got[0]) + } + if kept[0].Waits[0].Act != "" { + t.Fatal("checking the wait changed the statement as kept") + } + waits, waiting := operatorWait("requests", got) + if !waiting { + t.Fatal("the module does not read as waiting for the operator") + } + o := needsOperatorObservation("requests", "laptop", waits, got) + if !strings.Contains(o.Summary, "secret accept media tv tv-api") { + t.Fatalf("the summary does not name the accept for the provider: %q", o.Summary) + } + if !strings.HasPrefix(o.Needs, "give the API key of tv") { + t.Fatalf("the needs do not name the act: %q", o.Needs) + } + if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, + Resolved: o.Resolved, Needs: o.Needs, Actions: o.Actions}, "laptop"); !ok { + t.Fatalf("the condition is not in plain words: %s", why) + } + // A provider on several machines, or none: the line names its machine as a place to fill. + if line := (outsideProvider{Module: "tv", Machines: []string{"a", "b"}}).acceptLine("tv-api"); line != + "secret accept tv tv-api" { + t.Fatalf("a provider on two machines reads %q", line) + } +} + +// §3: the consumers one accept reaches are those whose resolution answers the provision from that provider. +func TestTheConsumersOfAProviderAreThoseItAnswers(t *testing.T) { + plan := catalogue.Resolution{Needs: []catalogue.Needed{ + {Name: "tv-api", From: "media", Module: "tv", For: "requests"}, + {Name: "tv-api", From: "media", Module: "tv", For: "indexer", Local: "main"}, + {Name: "tv-api", From: "other", Module: "tv", For: "elsewhere"}, + {Name: "tv-api", From: "media", Module: "another-tv", For: "not-this-module"}, + {Name: "db", From: "media", Module: "tv", For: "requests"}, + {Name: "tv-api", ByRecord: true, For: "by-record"}, + }} + got := bindsFrom(plan, "laptop", "media", "tv", "tv-api") + if len(got) != 2 || got[0].ConsumerModule != "indexer" || got[0].Local != "main" || got[1].ConsumerModule != "requests" { + t.Fatalf("the consumers from tv on media are %+v", got) + } + said := acceptedSaid("media", "tv", "tv-api", inventory.ProvisionAccepted{ + Sealed: []inventory.PairEnd{{Consumer: "laptop", ConsumerModule: "requests"}}, + Unsealable: []inventory.PairEnd{{Consumer: "new", ConsumerModule: "indexer"}}}, []string{"down: refused"}) + for _, want := range []string{"sealed to requests on laptop", "no sealing key", "down: refused"} { + if !strings.Contains(said, want) { + t.Errorf("what the accept says lacks %q:\n%s", want, said) + } + } +} diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index ce0147ca..952fe892 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -182,12 +182,21 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso } var secret inventory.Secret var err error - if n.SharedOwn != "" { + if outsideModule, outside := issuedOutsideBy(shelf, n); outside { + // Only a person can give it (novox/hq ADR 0337, issue 164): read as given, never made. + secret, err = inv.GivenPairSecret(ctx, n.Name, nodeName, n.For, n.From, outsideModule, n.Local) + } else if n.SharedOwn != "" { // The provider's one credential, sealed to this consumer too (novox/hq ADR 0158). secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn) } else { secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local) } + if inventory.IsNotGiven(err) { + // **Not given, and the rest of the consumer runs** (ADR 0337 §4): no credential file, so the module can + // tell it was not given and says it waits for it; the machine is not refused for what only a person can + // give, and nothing the mesh made stands in for it. + continue + } if err != nil { // Said rather than skipped. A machine that resolves cleanly and receives no // credential is one that will fail to authenticate at some later, less obvious @@ -1742,6 +1751,17 @@ func busUserList(ctx context.Context, inv *inventory.Inventory, // providerModuleOf is which module answers a need on the providing node: the one in this node's // own set when the provider is here, else the one the catalogue says offers it. +// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the +// mesh (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so. +func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) { + if n.Module != "" { + if m, known := shelf[n.Module]; known { + return n.Module, m.IssuedOutsideOf(n.Name) + } + } + return catalogue.OffersIssuedOutside(shelf, n.Name) +} + func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string { for _, m := range resolved.Modules { if _, shared := m.SharedCredentialOf(n.Name); shared { diff --git a/cmd/mesh-controller/replay164_test.go b/cmd/mesh-controller/replay164_test.go new file mode 100644 index 00000000..34175bda --- /dev/null +++ b/cmd/mesh-controller/replay164_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// TestReplay164 replays novox/hq issue 164 (found provisioning the media machine's modules, re-checked +// 2026-10-08): a Servarr app's API key, an indexer's key and a downloader's password are values the provider's +// software holds and only a person can give, and for a consumer of such a provision the send made a random pair +// credential, sealed it to both ends and placed it; the provider refused it, and a consumer retrying it could be +// banned by the provider. The outcome asserted (ADR 0337): for a provision whose offer says its credential is +// issued outside the mesh, the send makes no value and places none, the consumer's machine still composes, and a +// value a person gave for the pair is placed. Written with only what the controller had before its fix — +// register, assign, planFor, declarationFor, AcceptSecretForPair and the inventory's secret reads — so it is laid +// over the commit before. Needs the store (MESH_TEST_POSTGRES), and is named in testdata/store-tests. +func TestReplay164(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + const secretFile = "/var/lib/mesh/requests/tv-api.secret" + register(t, open, catalogue.Manifest{Module: "tv", Version: "1", + Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh, + Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}, + Serves: map[string]map[string]any{"tv-api": {"port": 8989}}}) + register(t, open, catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api"}, + Secrets: map[string]string{"tv-api": secretFile}}) + for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}} { + if _, err := assign(ctx, open, a[0], a[1]); err != nil { + t.Fatal(err) + } + } + sent := func() []map[string]any { + t.Helper() + plan, settings, err := planFor(ctx, open, "laptop") + if err != nil { + t.Fatalf("the consumer's machine was refused for a credential only a person gives: %v", err) + } + declared, err := declarationFor(ctx, open, "laptop", plan, settings) + if err != nil { + t.Fatalf("the send to laptop did not compose: %v", err) + } + return declared.Resources + } + placed := func(resources []map[string]any) map[string]any { + for _, r := range resources { + if r["path"] == secretFile && r["sealed"] != nil { + return r + } + } + return nil + } + + if got := placed(sent()); got != nil { + t.Errorf("the send placed a credential for tv-api (%v), which only a person can give and nobody gave", got["id"]) + } + held, err := open.inventory.SecretsOf(ctx, "laptop", "requests") + if err != nil { + t.Fatal(err) + } + for _, s := range held { + if s.Name == "tv-api" { + t.Fatalf("the mesh holds a value for tv-api that nobody gave (origin %q)", s.Origin) + } + } + + // Given by a person for the pair, it is placed, as given. + if err := open.inventory.AcceptSecretForPair(ctx, "tv-api", "laptop", "requests", "anchor", "", "the app's key"); err != nil { + t.Fatal(err) + } + if placed(sent()) == nil { + t.Fatal("the value a person gave for tv-api was not placed") + } +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 3b15167c..373dbd7b 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -79,6 +79,37 @@ func secretCommand(ctx context.Context, args []string) error { return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+ "through a verb (this line came through %q): nothing was read or sealed", verb) } + // **A provision's credential issued outside the mesh is given once, for its provider** (novox/hq ADR 0337 §3, + // issue 165): `secret accept `, sealed to every consumer of + // that provider; the per-pair form naming a consumer is refused for it, naming this one. + outsideAt, err := outsideOffer(ctx, module, name, *provider) + if err != nil { + return err + } + if outsideAt == "" && *provider != "" { + if offered, err := offeredOutsideBy(ctx, name); err != nil { + return err + } else if offered != "" { + return fmt.Errorf("%s is issued outside the mesh and given once for its provider, which seals it to "+ + "every consumer of it (ADR 0337): `secret accept %s %s %s`; nothing was read or sealed", + name, *provider, offered, name) + } + } + if outsideAt != "" { + if *desk != "" || *local != "" { + return errors.New("a provision's credential issued outside the mesh is given at the controller's " + + "terminal, for every consumer of its provider; it takes neither --at-desk nor --local") + } + value, err := valueFor(node, module, name, *from) + if err != nil { + return err + } + value = asSupplied(value) + if value == "" { + return errors.New("there is nothing to seal") + } + return acceptForProvision(ctx, node, module, name, value) + } if *desk != "" { if *from != "" || *provider != "" { return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider") @@ -173,6 +204,7 @@ const secretUsage = "secret rotate [--why [--cause "secret ask [--at ]\n" + "secret issued \n" + "secret accept [--from | --at-desk ] [--provider [--local ]]\n" + + "secret accept [--from ] (a credential issued outside the mesh, for every consumer)\n" + "secret recover --key [--out ] [--from-export ] [--provider ]\n" + "secret export [--out ]" diff --git a/internal/catalogue/issued_credential_test.go b/internal/catalogue/issued_credential_test.go index 221e67ae..88b235d0 100644 --- a/internal/catalogue/issued_credential_test.go +++ b/internal/catalogue/issued_credential_test.go @@ -25,13 +25,13 @@ func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { } for want, raw := range map[string]string{ - "the one issuer an offer may name": `{"module":"d","version":"1", + "the issuers an offer may name": `{"module":"d","version":"1", "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, "names no tool that answers it": `{"module":"d","version":"1", "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, "provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`, - "the one issuer an offer may name ": `{"module":"d","version":"1", + "the issuers an offer may name ": `{"module":"d","version":"1", "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, } { want = strings.TrimSpace(want) diff --git a/internal/catalogue/issued_outside_test.go b/internal/catalogue/issued_outside_test.go new file mode 100644 index 00000000..54e5d293 --- /dev/null +++ b/internal/catalogue/issued_outside_test.go @@ -0,0 +1,46 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164): +// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside +// either. An offer that says `software` keeps ADR 0305's shape. +func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"tv","version":"1", + "provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}], + "serves":{"tv-api":{"port":8989}}}`)) + if err != nil { + t.Fatal(err) + } + if !m.IssuedOutsideOf("tv-api") { + t.Fatal("the offer's credential issued outside was not read") + } + if m.IssuedOutsideOf("other") { + t.Fatal("a provision the module does not offer reads as issued outside") + } + if _, issued := m.IssuedCredentialOf("tv-api"); issued { + t.Fatal("a credential issued outside was read as issued by the software") + } + if _, shared := m.SharedCredentialOf("tv-api"); shared { + t.Fatal("a credential issued outside was read as an own secret shared") + } + if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" { + t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered) + } + + for want, raw := range map[string]string{ + "names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}}, + "provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`, + "names the tool": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`, + "the issuers an offer may name": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index a8829328..25a200ec 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -269,7 +269,8 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool { func (o Offer) MachineReach() bool { return o.Reach == ReachMachine } // OfferCredential names which of the provider's own secrets a provision's consumers receive — or, with -// IssuedBy, that the provider's software issues the provision's one credential itself (novox/hq ADR 0305, +// IssuedBy "outside", that only a person can give the provision's credential (novox/hq ADR 0337, issue 164) — or, +// with IssuedBy "software", that the provider's software issues the provision's one credential itself (novox/hq ADR 0305, // issue 526): a media server's sign-in token from its vendor, which the mesh can neither make nor rotate. // Tool is then the provider's own tool that answers that value, sealed to a key made for the controller's // one call, and to the controller alone. @@ -293,6 +294,34 @@ func (m Manifest) IssuedCredentialOf(provision string) (string, bool) { return "", false } +// IssuedOutsideOf is whether this module's offer of a provision says only a person outside the mesh can give its +// credential (novox/hq ADR 0337): the mesh never makes a value for it, and a consumer holds one only once a person +// gave it with `secret accept`. +func (m Manifest) IssuedOutsideOf(provision string) bool { + for _, o := range m.Provides { + if o.Name == provision && o.Credential != nil && o.Credential.IssuedBy == IssuedOutside { + return true + } + } + return false +} + +// OffersIssuedOutside is whether any module of a catalogue offers the provision with a credential issued outside +// the mesh (novox/hq ADR 0337), and the first such module by name. +func OffersIssuedOutside(shelf map[string]Manifest, provision string) (string, bool) { + var names []string + for name, m := range shelf { + if m.IssuedOutsideOf(provision) { + names = append(names, name) + } + } + if len(names) == 0 { + return "", false + } + sort.Strings(names) + return names[0], true +} + // SharedCredentialOf is the own secret an offer of this module names as the provision's credential, // and whether it names one. func (m Manifest) SharedCredentialOf(provision string) (string, bool) { @@ -1647,14 +1676,28 @@ func ParseManifest(raw []byte) (Manifest, error) { "makes is %d", m.Module, p, id.Max, shortest)) } } - if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") { + if c := offer.Credential; c != nil && c.IssuedBy == IssuedOutside { + // Only a person can give it (novox/hq ADR 0337 §1, issue 164): the software holds a value the mesh can + // neither make nor fetch, so the offer names nothing that would answer it — no own secret, no tool. + switch { + case c.Own != "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential issued outside the mesh and names its own secret %q too; a "+ + "person gives the value with `secret accept`, so the offer names no own secret (ADR 0337)", + m.Module, p, c.Own)) + case c.Tool != "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential issued outside the mesh and names the tool %q; only a "+ + "person gives it, so the offer names no tool (ADR 0337)", m.Module, p, c.Tool)) + } + } else if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") { // The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own // secret, which would be a second copy for the mesh to keep in step with the software's. switch { case c.IssuedBy != IssuedBySoftware: problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential issued by %q; the one issuer an offer may name is %q (ADR 0305)", - m.Module, p, c.IssuedBy, IssuedBySoftware)) + "%s provides %q with a credential issued by %q; the issuers an offer may name are %q and %q "+ + "(ADR 0305, ADR 0337)", m.Module, p, c.IssuedBy, IssuedBySoftware, IssuedOutside)) case c.Own != "": problems = append(problems, fmt.Sprintf( "%s provides %q with a credential its software issues and names its own secret %q too; the "+ diff --git a/internal/inventory/health.go b/internal/inventory/health.go index 1ee5a829..60df9e01 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -44,7 +44,13 @@ type Wait struct { Part string `json:"part"` Secret string `json:"secret,omitempty"` Setting string `json:"setting,omitempty"` - What string `json:"what"` + // Provision is a provision the module requires whose credential only a person can give and nobody gave yet + // (novox/hq ADR 0337 §5): a wait names exactly one of Secret, Setting or Provision. + Provision string `json:"provision,omitempty"` + What string `json:"what"` + // Act is the line that gives a provision's credential, filled by the controller when it excuses the wait + // (ADR 0337 §7); never said by a machine. + Act string `json:"act,omitempty"` } // NodeHealth is a machine's newest statement, as kept. diff --git a/internal/inventory/outside.go b/internal/inventory/outside.go new file mode 100644 index 00000000..e45a7b17 --- /dev/null +++ b/internal/inventory/outside.go @@ -0,0 +1,252 @@ +package inventory + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/jackc/pgx/v5" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/secrets" +) + +// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164): a Servarr +// app's API key, an indexer's key, a downloader's password. The provider's software holds the value; the mesh can +// neither make one that would work nor fetch it. So the mesh never makes one (§2), a person gives it once per +// provider and it is sealed to every consumer of that provider (§3), and until then a consumer holds no credential +// for it and is told so (§4), and the rest of it runs. + +// NotGivenError is a pair credential only a person can give, which no person gave for this pair under the keys its +// two ends have now: the consumer receives no credential for it, and waits (ADR 0337 §4, §8). It is not a fault of +// the machine, so the plan does not refuse the machine for it. +type NotGivenError struct { + Provision, Consumer, ConsumerModule, Provider, ProviderModule, Local string + // Own is set when the credential is the provider's own secret issued outside (ADR 0158, ADR 0228), and names it. + Own string + // Why is why no value is held, in words that never carry one. + Why string +} + +func (e *NotGivenError) Error() string { + if e.Own != "" { + return fmt.Sprintf("%s on %s holds no %s from %s: %s; give it with `secret accept %s %s %s`", + e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, e.Provider, e.ProviderModule, e.Own) + } + return fmt.Sprintf("%s on %s holds no %s from %s: %s; it is issued outside the mesh and given with `%s`", + e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, acceptLine(e.Provider, e.ProviderModule, e.Provision)) +} + +// IsNotGiven is whether an error is a credential only a person gives, not given yet. +func IsNotGiven(err error) bool { + var ng *NotGivenError + return errors.As(err, &ng) +} + +// acceptLine is the one line that gives a provision's credential issued outside, for every consumer of a provider. +func acceptLine(provider, providerModule, provision string) string { + if providerModule == "" { + providerModule = "" + } + return fmt.Sprintf("secret accept %s %s %s", provider, providerModule, provision) +} + +// AcceptLine is acceptLine, for the controller's words. +func AcceptLine(provider, providerModule, provision string) string { + return acceptLine(provider, providerModule, provision) +} + +// issuedOutside is whether a module in the catalogue offers the provision with a credential issued outside the +// mesh, and which. +func (i *Inventory) issuedOutside(ctx context.Context, provision string) (string, bool, error) { + shelf, err := i.Catalogue(ctx) + if err != nil { + return "", false, err + } + module, outside := catalogue.OffersIssuedOutside(shelf, provision) + return module, outside, nil +} + +// ownIssuedOutside is whether a provider's own secret says only an outside party issues it (ADR 0228). +func (i *Inventory) ownIssuedOutside(ctx context.Context, providerModule, own string) (string, bool, error) { + if providerModule == "" { + return own, false, nil + } + m, err := i.declared(ctx, providerModule) + if err != nil { + return own, false, err + } + return own, m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside, nil +} + +// GivenPairSecret is the credential of a pair whose provision is issued outside the mesh, as a person gave it, and +// never one the mesh makes (ADR 0337 §2, §4). It writes nothing. A value is answered only when a person gave it +// (`accepted`) and it is sealed to the keys both ends have now; otherwise a NotGivenError says why — never given, or +// given under a key one end no longer has (§8), or a value the mesh made before the rule, which is not given and +// never delivered again (§2). +func (i *Inventory) GivenPairSecret(ctx context.Context, name, consumer, consumerModule, provider, providerModule, + local string) (Secret, error) { + consumerKey, err := i.SealingKeyOf(ctx, consumer) + if err != nil { + return Secret{}, err + } + providerKey, err := i.SealingKeyOf(ctx, provider) + if err != nil { + return Secret{}, err + } + consumerNode, err := i.NodeByName(ctx, consumer) + if err != nil { + return Secret{}, err + } + providerNode, err := i.NodeByName(ctx, provider) + if err != nil { + return Secret{}, err + } + notGiven := func(why string) (Secret, error) { + return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule, + Provider: provider, ProviderModule: providerModule, Local: local, Why: why} + } + var held Secret + err = i.store.Pool().QueryRow(ctx, + `select for_consumer, for_provider, consumer_key, provider_key, origin from secret + where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, + name, consumerNode.ID, consumerModule, providerNode.ID, local). + Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin) + switch { + case errors.Is(err, pgx.ErrNoRows): + return notGiven("nobody gave it") + case err != nil: + return Secret{}, err + case held.Origin != OriginAccepted: + return notGiven("the value held was made by the mesh, which " + providerModule + "'s software would refuse") + case consumerKey == "" || providerKey == "" || held.ConsumerKey != consumerKey || held.ProviderKey != providerKey: + return notGiven("it was given under a sealing key one end no longer has") + } + held.Name, held.Consumer, held.Provider = name, consumer, provider + held.ConsumerModule, held.Local = consumerModule, local + return held, nil +} + +// PairEnd is one consumer of a provision from one provider: the machine, the module there and its local name. +type PairEnd struct { + Consumer, ConsumerModule, Local string +} + +// ProvisionAccepted is what one accept for a provider sealed (ADR 0337 §3): the consumers it reached, those it could +// not (no sealing key on their machine), and the machines to send. +type ProvisionAccepted struct { + Sealed, Unsealable []PairEnd + Machines []string +} + +// AcceptSecretForProvision seals one value a person gave for a provision issued outside the mesh to the provider's +// machine, to every consumer named, and to the operator, in one act under one generation, each row `accepted` +// (ADR 0337 §3, issue 165); the plaintext is not kept. The consumers are every one that binds the provision from +// this provider now, which the caller finds by resolving every machine. A consumer whose machine has no sealing key +// is not sealed to, and said. +func (i *Inventory) AcceptSecretForProvision(ctx context.Context, provision, provider, providerModule, value string, + consumers []PairEnd) (ProvisionAccepted, error) { + m, err := i.declared(ctx, providerModule) + if err != nil { + return ProvisionAccepted{}, err + } + if !m.IssuedOutsideOf(provision) { + return ProvisionAccepted{}, fmt.Errorf("%s does not offer %q with a credential issued outside the mesh "+ + "(\"credential\": {\"issued-by\": \"outside\"}, ADR 0337), so its consumers' credentials are not given "+ + "once for the provider", providerModule, provision) + } + if value == "" { + return ProvisionAccepted{}, errors.New("there is nothing to seal") + } + providerNode, err := i.NodeByName(ctx, provider) + if err != nil { + return ProvisionAccepted{}, err + } + providerKey, err := i.SealingKeyOf(ctx, provider) + if err != nil { + return ProvisionAccepted{}, err + } + if providerKey == "" { + return ProvisionAccepted{}, fmt.Errorf("%s has no sealing key, so nothing can be sealed to it; a node joins to get one", provider) + } + forOperator, operatorKey, err := i.operatorSeal(ctx, value) + if err != nil { + return ProvisionAccepted{}, err + } + generation := secrets.Stamp() + var out ProvisionAccepted + machines := map[string]bool{provider: true} + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return ProvisionAccepted{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + for _, c := range consumers { + consumerNode, err := i.NodeByName(ctx, c.Consumer) + if err != nil { + return ProvisionAccepted{}, err + } + consumerKey, err := i.SealingKeyOf(ctx, c.Consumer) + if err != nil { + return ProvisionAccepted{}, err + } + if consumerKey == "" { + out.Unsealable = append(out.Unsealable, c) + continue + } + sealed, err := secrets.Accept(value, consumerKey, providerKey) + if err != nil { + return ProvisionAccepted{}, err + } + if _, err := tx.Exec(ctx, + `insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider, + consumer_key, provider_key, operator_sealed, operator_key, origin, local, generation) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + on conflict (name, local, consumer, consumer_module, provider) do update set + for_consumer = excluded.for_consumer, for_provider = excluded.for_provider, + consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, + created_at = now(), origin = excluded.origin, generation = excluded.generation, + operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`, + provision, consumerNode.ID, c.ConsumerModule, providerNode.ID, + sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey, + forOperator, operatorKey, OriginAccepted, c.Local, generation); err != nil { + return ProvisionAccepted{}, err + } + out.Sealed = append(out.Sealed, c) + machines[c.Consumer] = true + } + if err := tx.Commit(ctx); err != nil { + return ProvisionAccepted{}, err + } + for n := range machines { + out.Machines = append(out.Machines, n) + } + sort.Strings(out.Machines) + return out, nil +} + +// GivenPairsOf is every provision a module on a machine holds a value a person gave for, sealed to the keys both +// ends have now: what a wait for a provision's credential is checked against (ADR 0337 §6). +func (i *Inventory) GivenPairsOf(ctx context.Context, machine, module string) (map[string]bool, error) { + rows, err := i.store.Pool().Query(ctx, + `select s.name + from secret s + join node c on c.id = s.consumer + join node p on p.id = s.provider + where c.name = $1 and s.consumer_module = $2 and s.origin = 'accepted' + and s.consumer_key = c.sealing_key and s.provider_key = p.sealing_key`, machine, module) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out[name] = true + } + return out, rows.Err() +} diff --git a/internal/inventory/outside_test.go b/internal/inventory/outside_test.go new file mode 100644 index 00000000..b45ad0fd --- /dev/null +++ b/internal/inventory/outside_test.go @@ -0,0 +1,195 @@ +package inventory + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// outsideProvision registers a provider offering tv-api with a credential issued outside the mesh (novox/hq ADR +// 0337 §1) and two consumers requiring it, on twoNodesWithKeys' machines. +func outsideProvision(t *testing.T, inv *Inventory) { + t.Helper() + ctx := t.Context() + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "tv", Version: "1", + Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh, + Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}}, Source{}); err != nil { + t.Fatal(err) + } + for _, m := range []string{"requests", "indexer"} { + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"}, + Secrets: map[string]string{"tv-api": "/run/tv-api.secret"}}, Source{}); err != nil { + t.Fatal(err) + } + } +} + +// pairRows is how many pair rows the store holds for tv-api. +func pairRows(t *testing.T, inv *Inventory) int { + t.Helper() + var n int + if err := inv.store.Pool().QueryRow(t.Context(), `select count(*) from secret where name = 'tv-api'`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// A credential only a person can give is never made (ADR 0337 §2, issue 164): not for a pair with no row, which is +// what a newly bound consumer has, and not after a key change; a value the mesh made before the rule is not given. +func TestACredentialIssuedOutsideIsNeverMade(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + outsideProvision(t, inv) + + _, err := inv.SecretFor(ctx, "tv-api", "consumer", "requests", "provider", "") + if !IsNotGiven(err) { + t.Fatalf("SecretFor made or refused a credential only a person gives, rather than saying it was not given: %v", err) + } + if !strings.Contains(err.Error(), "secret accept provider tv tv-api") { + t.Fatalf("not given does not name the one line that gives it: %v", err) + } + if n := pairRows(t, inv); n != 0 { + t.Fatalf("the store holds %d rows for tv-api, which nobody gave", n) + } + if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) { + t.Fatalf("a pair nobody gave read as given: %v", err) + } + if n := pairRows(t, inv); n != 0 { + t.Fatalf("reading the pair wrote %d rows", n) + } + + // A value the mesh made before the rule (the minted value of issue 164) is not given. + if _, err := inv.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider, + for_consumer, for_provider, consumer_key, provider_key, local) + select 'tv-api', c.id, 'indexer', p.id, 'x', 'y', c.sealing_key, p.sealing_key, '' + from node c, node p where c.name = 'consumer' and p.name = 'provider'`); err != nil { + t.Fatal(err) + } + if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "indexer", "provider", "tv", ""); !IsNotGiven(err) || + !strings.Contains(err.Error(), "made by the mesh") { + t.Fatalf("a value the mesh made before the rule read as given: %v", err) + } + + // Given, then a key changes: not given, and nothing is made in its place (ADR 0337 §8). + if _, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key", + []PairEnd{{Consumer: "consumer", ConsumerModule: "requests"}}); err != nil { + t.Fatal(err) + } + if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); err != nil { + t.Fatalf("the value given does not read as given: %v", err) + } + cn, _ := inv.NodeByName(ctx, "consumer") + key, _ := aSealingKey(t) + if err := inv.RecordSealingKey(ctx, cn.ID, key); err != nil { + t.Fatal(err) + } + if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) || + !strings.Contains(err.Error(), "sealing key") { + t.Fatalf("a value given under a key the consumer no longer has read as given: %v", err) + } + var origin string + if err := inv.store.Pool().QueryRow(ctx, `select origin from secret where name = 'tv-api' and consumer_module = 'requests'`). + Scan(&origin); err != nil || origin != OriginAccepted { + t.Fatalf("the given row was replaced (origin %q, %v)", origin, err) + } + + // Rotate refuses it, naming the accept (ADR 0337 §9). + err = inv.RotateSecret(ctx, "tv-api", "consumer", "requests", "provider", "") + if err == nil || !strings.Contains(err.Error(), "secret accept provider tv tv-api") { + t.Fatalf("rotate of a credential issued outside was not refused naming the accept: %v", err) + } +} + +// One accept for a provider is sealed to every consumer named, to the provider and to the operator, under one +// generation, each row accepted (ADR 0337 §3, issue 165); a consumer with no sealing key is said, not sealed to. +func TestOneAcceptForAProviderIsSealedToEveryConsumer(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + outsideProvision(t, inv) + operatorKey, operatorOpens := aSealingKey(t) + if _, err := inv.SetOperatorKey(ctx, operatorKey); err != nil { + t.Fatal(err) + } + consumerKey, consumerOpens := aSealingKey(t) + cn, _ := inv.NodeByName(ctx, "consumer") + if err := inv.RecordSealingKey(ctx, cn.ID, consumerKey); err != nil { + t.Fatal(err) + } + if _, err := inv.AddNode(ctx, "keyless"); err != nil { + t.Fatal(err) + } + + accepted, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key", []PairEnd{ + {Consumer: "consumer", ConsumerModule: "requests"}, {Consumer: "consumer", ConsumerModule: "indexer"}, + {Consumer: "keyless", ConsumerModule: "requests"}}) + if err != nil { + t.Fatal(err) + } + if len(accepted.Sealed) != 2 || len(accepted.Unsealable) != 1 || accepted.Unsealable[0].Consumer != "keyless" { + t.Fatalf("the accept did not seal to both consumers with keys and say the keyless one: %+v", accepted) + } + if strings.Join(accepted.Machines, ",") != "consumer,provider" { + t.Fatalf("the machines to send are the provider's and every consumer's sealed to: %v", accepted.Machines) + } + rows, err := inv.store.Pool().Query(ctx, `select consumer_module, origin, coalesce(generation, ''), for_consumer, + coalesce(operator_sealed, '') from secret where name = 'tv-api'`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + generations := map[string]bool{} + seen := 0 + for rows.Next() { + var module, origin, generation, forConsumer, forOperator string + if err := rows.Scan(&module, &origin, &generation, &forConsumer, &forOperator); err != nil { + t.Fatal(err) + } + seen++ + if origin != OriginAccepted || generation == "" { + t.Fatalf("%s's row is not accepted under a generation (origin %q)", module, origin) + } + generations[generation] = true + if opened, ok := consumerOpens(forConsumer); !ok || string(opened) != "the app's key" { + t.Fatalf("%s's copy does not open to the value given", module) + } + if opened, ok := operatorOpens(forOperator); !ok || string(opened) != "the app's key" { + t.Fatalf("%s's operator copy does not open to the value given", module) + } + } + if seen != 2 || len(generations) != 1 { + t.Fatalf("%d rows under %d generations; want 2 under one", seen, len(generations)) + } + given, err := inv.GivenPairsOf(ctx, "consumer", "indexer") + if err != nil || !given["tv-api"] { + t.Fatalf("the provision given does not read as given for indexer (%v)", err) + } + + // Only an offer that says so is given this way. + if _, err := inv.AcceptSecretForProvision(ctx, "secret", "provider", "gitea", "x", nil); err == nil { + t.Fatal("a provision not issued outside was given once for its provider") + } +} + +// The provider's own secret issued outside the mesh, named as a provision's shared credential (ADR 0158, ADR 0228), +// is never made for a consumer that binds: the consumer is told it was not given (ADR 0337 §2, §4). +func TestASharedOwnSecretIssuedOutsideIsNeverMade(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "downloader", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password", Taken: catalogue.TakenAtStart, + IssuedBy: catalogue.IssuedOutside}}, + Provides: []catalogue.Offer{{Name: "downloader-api", Scope: catalogue.ScopeMesh, + Credential: &catalogue.OfferCredential{Own: "password"}}}}, Source{}); err != nil { + t.Fatal(err) + } + _, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "gitea", "provider", "downloader", "", "password") + if !IsNotGiven(err) { + t.Fatalf("a shared own secret issued outside was made or refused rather than not given: %v", err) + } + var n int + if err := inv.store.Pool().QueryRow(ctx, `select count(*) from module_secret where module = 'downloader'`).Scan(&n); err != nil || n != 0 { + t.Fatalf("the mesh made %d values of an own secret issued outside (%v)", n, err) + } + if err := inv.remakeShared(ctx, nil, "", "downloader", "password", "", nil, "", "", ""); err == nil || + !strings.Contains(err.Error(), "issued outside") { + t.Fatalf("remakeShared made a value of an own secret issued outside: %v", err) + } +} diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index c51480a1..0d283381 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -104,6 +104,21 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local)) } + // **Never made where only a person can give it** (novox/hq ADR 0337 §2, issue 164): a provision whose offer + // says `issued-by: outside` gets no value of the mesh's, which its provider's software would refuse. Asked + // here, on the one path that makes a value, so no caller can have one made by not knowing; the plan reads + // such a pair with GivenPairSecret, which never makes one and delivers only a value a person gave. + why := "nobody gave it" + if err == nil { + why = "the value held was sealed to a key one end no longer has" + } + if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil { + return Secret{}, oerr + } else if outside { + return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule, + Provider: provider, ProviderModule: providerModule, Local: local, Why: why} + } + // And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that // makes a vault-provided secret recoverable, and nothing the mesh can open. operator, err := i.OperatorKey(ctx) @@ -239,6 +254,16 @@ func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerMo `select origin from secret where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin) + if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil { + return oerr + } else if outside { + // Only a person can give it, so only a person can give its next value (novox/hq ADR 0337 §9, ADR 0092 §7): + // deleting the row would leave the consumer waiting for nothing new. + return fmt.Errorf( + "%s is issued outside the mesh: %s's software holds it and the mesh cannot make its replacement. "+ + "Give the new value with `%s`, which seals it to every consumer of %s on %s", + name, providerModule, acceptLine(provider, providerModule, name), providerModule, provider) + } if err == nil && origin == OriginAccepted { return fmt.Errorf( "%s's %q credential from %s was accepted from a person, and the mesh cannot make "+ @@ -886,6 +911,15 @@ func (i *Inventory) SharedSecretFor(ctx context.Context, provision, consumer, co held.ConsumerModule, held.Local = consumerModule, local return held, nil } + if own, outside, err := i.ownIssuedOutside(ctx, providerModule, own); err != nil { + return Secret{}, err + } else if outside && (ownOrigin == nil || *ownOrigin != OriginAccepted) { + // The provider's own secret only an outside party issues, which nobody gave (ADR 0228, issue 401): the + // mesh makes no value for it here either, and the consumer is told it was not given (ADR 0337 §4). + return Secret{}, &NotGivenError{Provision: provision, Consumer: consumer, ConsumerModule: consumerModule, + Provider: provider, ProviderModule: providerModule, Local: local, Own: own, + Why: "nobody gave " + providerModule + "'s own secret " + own + ", which is issued outside the mesh"} + } if ownOrigin != nil && *ownOrigin == OriginAccepted { return Secret{}, fmt.Errorf( "%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+ @@ -912,6 +946,11 @@ func (i *Inventory) remakeShared(ctx context.Context, providerID any, providerKe if len(provisions) == 0 { return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own) } + if m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside { + // Never made (novox/hq ADR 0228, ADR 0337 §2): only a person can give it, with `secret accept`. + return fmt.Errorf("%s's own secret %q is issued outside the mesh, so the mesh makes no value for it; a "+ + "person gives it with `secret accept %s %s`", providerModule, own, providerModule, own) + } operator, err := i.OperatorKey(ctx) if err != nil { return err diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 63349ece..9ce9d87c 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -577,7 +577,10 @@ type Wait struct { Part string `json:"part"` Secret string `json:"secret,omitempty"` Setting string `json:"setting,omitempty"` - What string `json:"what"` + // Provision is a provision the module requires whose credential only a person can give and nobody gave yet + // (novox/hq ADR 0337 §5): a wait names exactly one of Secret, Setting or Provision. + Provision string `json:"provision,omitempty"` + What string `json:"what"` } // ResourceHealth is one long-running resource's state. diff --git a/testdata/store-tests b/testdata/store-tests index a03fd9ee..bec8145d 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -46,3 +46,8 @@ ./cmd/mesh-controller TestReplay239AModuleNameIsNotTakenOverByAnotherRepository ./cmd/mesh-controller TestAWalkThatMovedNothingSaysWhichModulesMatchedWhatRuns ./cmd/mesh-controller TestReplay614ANewSDKPlansTheToolchainAndThenItsBundles +./internal/inventory TestACredentialIssuedOutsideIsNeverMade +./internal/inventory TestOneAcceptForAProviderIsSealedToEveryConsumer +./internal/inventory TestASharedOwnSecretIssuedOutsideIsNeverMade +./cmd/mesh-controller TestOneAcceptForAProviderIsPlacedForEveryConsumer +./cmd/mesh-controller TestReplay164 -- 2.54.0