From db86dd52c877605955646bff46a0401b952b22b3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 01:07:16 +0200 Subject: [PATCH 1/3] Take a provider's software-issued credential from the provider, sealed (hq issue 526, ADR 0305) A token a media server got from its vendor reached its consumers only as a value the mesh minted, which the server refused, or as a copy typed by hand. An offer may now say its software issues the credential; the new issued verb carries no value and asks the provider's tool, in the controller's own name, for the value sealed to one call, then seals it to every pair that does not hold it yet. --- cmd/mesh-controller/adoption.go | 3 + cmd/mesh-controller/handacts.go | 3 + cmd/mesh-controller/issued.go | 266 +++++++++++++++++++ cmd/mesh-controller/issued_test.go | 218 +++++++++++++++ cmd/mesh-controller/rotate.go | 9 + cmd/mesh-controller/seatverbs.go | 17 +- cmd/mesh-controller/secret.go | 4 + internal/catalogue/issued_credential_test.go | 42 +++ internal/catalogue/manifest.go | 43 ++- internal/catalogue/verbs.go | 9 + internal/inventory/issued.go | 155 +++++++++++ internal/inventory/issued_test.go | 124 +++++++++ internal/link/retirement.go | 20 +- module.json | 1 + testdata/store-tests | 1 + 15 files changed, 910 insertions(+), 5 deletions(-) create mode 100644 cmd/mesh-controller/issued.go create mode 100644 cmd/mesh-controller/issued_test.go create mode 100644 internal/catalogue/issued_credential_test.go create mode 100644 internal/inventory/issued.go create mode 100644 internal/inventory/issued_test.go diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index be79bfb7..eb93cdee 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -408,6 +408,9 @@ func comparisonOf(module string, c comparison, opts takeOptions) (preview string switch { case sec.Origin == inventory.OriginAccepted: fmt.Fprintf(&b, " %s %s: accepted from a person, carried in as it is\n", what, name) + case sec.Origin == inventory.OriginIssued: + // Taken from the provider's software (novox/hq ADR 0305): the value the running service uses. + fmt.Fprintf(&b, " %s %s: issued by the provider's software, carried in as it is\n", what, name) case opts.Mint[sec.Name]: fmt.Fprintf(&b, " %s %s: minted by the mesh; the service takes the new value, as --mint said\n", what, name) case foundData: diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index fdfa987e..147a6dca 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -84,6 +84,9 @@ var handActVerbs = []handActVerb{ "deliberate test, never a repair", DecidedFor: []string{causeDrill}}, // A drill: something broken on purpose to see the mesh raise and clear it. A person's test, never a // repair, however often it is run. + // A credential a provider's software issued, taken from the provider and kept for its consumers (novox/hq ADR + // 0305): the mesh working as decided, however often the software changes it. + {Verb: handActIssued, Decision: "a credential the provider's software issued, kept for its consumers (ADR 0305)"}, {Verb: "hand-act drill", Decision: "a drill is a person's deliberate test of the mesh, never a repair"}, // A person's decisions by design. {Verb: "retire approve", Decision: "nothing is retired past its bound without a person (ADR 0230)"}, diff --git a/cmd/mesh-controller/issued.go b/cmd/mesh-controller/issued.go new file mode 100644 index 00000000..1f7d6538 --- /dev/null +++ b/cmd/mesh-controller/issued.go @@ -0,0 +1,266 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" +) + +// `secret issued `: the credential a provider's software issued, taken from the provider and kept as +// the provision's one credential for every consumer (novox/hq ADR 0305, issue 526). +// +// **The value comes from the provider, never from whoever asks.** A module served by its machine's runtime calls +// under the runtime's one bus account, which carries that machine's agents' calls too, so a value handed to a +// verb could be anybody's. This line carries none: it asks the provider's own tool, on the provider's machine, in +// the controller's own name — the subject that names its caller, which the bus lets the controller alone publish +// (issue 365) — with a sealing key made for this one call. The tool answers only the controller, the value sealed +// to that key; the controller opens it, seals it to every holder and forgets it, as the desk's value is (ADR 0277 +// §23). Asking again fetches the same value again, and a value every holder already holds is not sealed anew. + +// handActIssued is the verb a change of an issued value is recorded under: the mesh working as decided. +const handActIssued = "secret issued" + +// issuedAsk is how long the provider's tool is given to answer. +const issuedAsk = 20 * time.Second + +// issuedProvider is one assignment of a module whose offer says its software issues the provision's credential. +type issuedProvider struct { + Node, Module, Tool string +} + +// issuedTaker is the line's work, with what it reaches handed in: the store, the bus and the sends. +type issuedTaker struct { + // providers are the assignments to ask, and whether any module offers the provision so at all. + providers func(provision string) ([]issuedProvider, bool, error) + ask func(p issuedProvider, args map[string]any) (link.Answer, error) + keep func(provision, provider, value string) (inventory.IssuedKept, error) + record func(act link.HandAct) error + send func(machines []string) error +} + +// take asks every provider of the provision for its software's value and keeps each; it answers what it did, in +// words that never carry the value or its fingerprint. +func (t issuedTaker) take(provision string) (string, error) { + providers, offered, err := t.providers(provision) + if err != nil { + return "", err + } + if !offered { + return "", fmt.Errorf("no module offers %q with a credential its software issues (\"credential\": "+ + "{\"issued-by\": \"software\", \"tool\": …}, ADR 0305), so there is nothing to take from a provider; "+ + "nothing was asked", provision) + } + if len(providers) == 0 { + return fmt.Sprintf("no consumer holds %s from a provider whose software issues it yet, so there is nothing "+ + "to seal; a consumer that binds holds a value the mesh made until the provider next asks", provision), nil + } + var said []string + var failed []string + for _, p := range providers { + words, err := t.takeFrom(provision, p) + if err != nil { + failed = append(failed, fmt.Sprintf("%s on %s: %v", p.Module, p.Node, err)) + continue + } + said = append(said, words) + } + out := strings.Join(said, "\n") + if len(failed) > 0 { + return out, fmt.Errorf("%s was not taken from every provider:\n %s", provision, strings.Join(failed, "\n ")) + } + return out, nil +} + +// takeFrom is one provider: asked, opened, kept, recorded and sent when it changed. +func (t issuedTaker) takeFrom(provision string, p issuedProvider) (string, error) { + public, private, err := secrets.Keypair() + if err != nil { + return "", fmt.Errorf("no key could be made to take the value: %w", err) + } + answer, err := t.ask(p, map[string]any{"provision": provision, "seal_to": public}) + if err != nil { + return "", fmt.Errorf("its tool %s could not be asked: %w", p.Tool, err) + } + if answer.Error != "" { + return "", fmt.Errorf("its tool %s refused: %s", p.Tool, answer.Error) + } + var given struct { + Sealed string `json:"sealed"` + } + if err := unmarshalAnswer(answer, &given); err != nil || given.Sealed == "" { + return "", fmt.Errorf("its tool %s answered no sealed value", p.Tool) + } + opened, err := secrets.Open(private, given.Sealed) + if err != nil { + // Never the value, never what failed to open: only that it was not sealed to this call. + return "", fmt.Errorf("its tool %s answered a value not sealed to this call; nothing was taken", p.Tool) + } + value := strings.TrimSpace(string(opened)) + for i := range opened { + opened[i] = 0 + } + if value == "" { + return "", fmt.Errorf("its tool %s answered an empty value; nothing was taken", p.Tool) + } + kept, err := t.keep(provision, p.Node, value) + value = "" + if err != nil { + return "", err + } + holders := make([]string, 0, len(kept.Holders)) + for _, h := range kept.Holders { + holders = append(holders, fmt.Sprintf("%s on %s%s", h.ConsumerModule, h.Consumer, asLocal(h.Local))) + } + words := "" + if len(kept.Unsealable) > 0 { + var names []string + for _, h := range kept.Unsealable { + names = append(names, fmt.Sprintf("%s on %s", h.ConsumerModule, h.Consumer)) + } + words = fmt.Sprintf("\n not sealed to %s: no sealing key on that machine", strings.Join(names, ", ")) + } + if !kept.Changed { + return fmt.Sprintf("%s from %s on %s: every holder already holds the value its software issued (%s); "+ + "nothing was sent%s", provision, p.Module, p.Node, strings.Join(holders, ", "), words), nil + } + sealedTo := make([]string, 0, len(kept.Sealed)) + for _, h := range kept.Sealed { + sealedTo = append(sealedTo, fmt.Sprintf("%s on %s%s", h.ConsumerModule, h.Consumer, asLocal(h.Local))) + } + recorded := "" + if err := t.record(link.HandAct{Verb: handActIssued, Args: []string{provision}, + Why: fmt.Sprintf("%s on %s gave the credential its software issued for %s; sealed to %s", p.Module, p.Node, + provision, strings.Join(sealedTo, ", ")), + Cause: "issued-by-software"}); err != nil { + recorded = fmt.Sprintf("\n this was NOT recorded in the hand-act log, and is done anyway: %v", err) + } + if err := t.send(kept.Machines); err != nil { + return "", fmt.Errorf("%s from %s on %s is sealed to %s and could not be sent: %w; `push --behind` sends it", + provision, p.Module, p.Node, strings.Join(sealedTo, ", "), err) + } + return fmt.Sprintf("%s from %s on %s: the value its software issued is sealed to %s; sent %s, and each of "+ + "them starts again on it%s%s", provision, p.Module, p.Node, strings.Join(sealedTo, ", "), + strings.Join(kept.Machines, ", "), words, recorded), nil +} + +// issuedProvidersOf are the assignments to ask for a provision: every machine providing it to a pair the mesh +// holds, and on it the assigned module whose offer says its software issues the credential (ADR 0305 §1). +func issuedProvidersOf(ctx context.Context, inv *inventory.Inventory, provision string) ([]issuedProvider, bool, error) { + shelf, err := inv.Catalogue(ctx) + if err != nil { + return nil, false, err + } + if !offersIssued(shelf, provision) { + return nil, false, nil + } + holders, err := inv.HoldersOf(ctx, provision, "") + if err != nil { + return nil, true, err + } + nodes := map[string]bool{} + for _, h := range holders { + nodes[h.Provider] = true + } + names := make([]string, 0, len(nodes)) + for n := range nodes { + names = append(names, n) + } + sort.Strings(names) + var out []issuedProvider + for _, node := range names { + assigned, err := inv.Assigned(ctx, node) + if err != nil { + return nil, true, err + } + out = append(out, issuedOn(shelf, node, assigned, provision)...) + } + return out, true, nil +} + +// offersIssued is whether any module offers the provision with a credential its software issues. +func offersIssued(shelf map[string]catalogue.Manifest, provision string) bool { + for _, m := range shelf { + if _, issued := m.IssuedCredentialOf(provision); issued { + return true + } + } + return false +} + +// issuedOn are the modules assigned to one machine whose offer says their software issues the provision's +// credential, each with the tool that answers it. +func issuedOn(shelf map[string]catalogue.Manifest, node string, assigned []string, provision string) []issuedProvider { + var out []issuedProvider + for _, module := range assigned { + if tool, issued := shelf[module].IssuedCredentialOf(provision); issued { + out = append(out, issuedProvider{Node: node, Module: module, Tool: tool}) + } + } + return out +} + +// issuedRefusesRotation refuses `rotate` of a provision its provider's software issues (ADR 0305 §8): a value the +// mesh made would replace one the software holds, and every consumer would hold a dead credential until the +// provider next asks. +func issuedRefusesRotation(shelf map[string]catalogue.Manifest, provision string) error { + if !offersIssued(shelf, provision) { + return nil + } + return fmt.Errorf("%s's credential is issued by its provider's software, which the mesh cannot make or "+ + "rotate (ADR 0305): change it in that software, and `secret issued %s` takes the value it holds now. "+ + "Nothing was rotated", provision, provision) +} + +// secretIssued is `secret issued `, on this controller's stores and bus. +func secretIssued(ctx context.Context, args []string) error { + if len(args) != 1 || args[0] == "" || strings.HasPrefix(args[0], "-") { + return errors.New("secret issued ") + } + provision := args[0] + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + t := issuedTaker{ + providers: func(provision string) ([]issuedProvider, bool, error) { + return issuedProvidersOf(ctx, open.inventory, provision) + }, + ask: func(p issuedProvider, args map[string]any) (link.Answer, error) { + var answer link.Answer + err := onTheBus(func(conn *nats.Conn) error { + var err error + answer, err = link.AskModuleToolOnAs(ctx, conn, broker.ControllerName, p.Module, p.Tool, p.Node, + args, issuedAsk) + return err + }) + return answer, err + }, + keep: func(provision, provider, value string) (inventory.IssuedKept, error) { + return open.inventory.KeepIssuedSecret(ctx, provision, provider, value) + }, + record: func(act link.HandAct) error { + return onTheBus(func(conn *nats.Conn) error { + _, err := link.RecordHandAct(ctx, conn, act) + return err + }) + }, + send: func(machines []string) error { return sendTo(ctx, open, machines) }, + } + words, err := t.take(provision) + if words != "" { + fmt.Println(words) + } + return err +} diff --git a/cmd/mesh-controller/issued_test.go b/cmd/mesh-controller/issued_test.go new file mode 100644 index 00000000..36fc63ae --- /dev/null +++ b/cmd/mesh-controller/issued_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" +) + +// The `issued` verb runs `secret issued ` and nothing else, and that line is the one `secret` line +// beside rotate and the desk's that a verb may run (novox/hq ADR 0305 §3, §9, issue 526). +func TestTheIssuedVerbRunsTheOneLineThatCarriesNoValue(t *testing.T) { + argv, err := argvFor("issued", map[string]any{"provision": "plex-api"}) + if err != nil { + t.Fatal(err) + } + if strings.Join(argv, " ") != "secret issued plex-api" { + t.Fatalf("the verb composed %q", argv) + } + if _, err := argvFor("issued", map[string]any{}); err == nil { + t.Fatal("the verb ran with no provision") + } + if _, err := argvFor("issued", map[string]any{"provision": "plex-api", "secret": "a value"}); err == nil { + t.Fatal("the verb took a value beside the provision") + } + for _, line := range [][]string{ + {"secret", "issued", "plex-api", "--from", "/tmp/x"}, + {"secret", "issued", "--from"}, + {"secret", "issued"}, + {"secret", "accept", "ace", "kometa", "plex-api", "--provider", "ace"}, + {"secret", "recover", "ace", "kometa", "plex-api"}, + } { + if err := terminalOnly(line); err == nil { + t.Errorf("%q ran through a verb", line) + } + } + if err := terminalOnly([]string{"secret", "issued", "plex-api"}); err != nil { + t.Fatalf("the issued line was refused through its verb: %v", err) + } + found := false + for _, v := range catalogue.ControllerVerbs { + if v.Name == "issued" { + found = true + } + } + if !found { + t.Fatal("the controller's seat does not declare the issued verb") + } +} + +// A provider's tool answering the value sealed to the call's key: what a provider does (ADR 0305 §2). +func issuedSealed(t *testing.T, value string) func(issuedProvider, map[string]any) (link.Answer, error) { + return func(_ issuedProvider, args map[string]any) (link.Answer, error) { + key, _ := args["seal_to"].(string) + if key == "" { + t.Fatal("the provider was asked without a key to seal to") + } + if _, carried := args["value"]; carried { + t.Fatal("the ask carried a value") + } + sealed, err := secrets.Seal(key, []byte(value)) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]string{"sealed": sealed}) + return link.Answer{Result: raw}, nil + } +} + +type issuedRecorder struct { + kept []string + acts []link.HandAct + sent [][]string + again bool // the value is held by every holder already +} + +func (r *issuedRecorder) taker(t *testing.T, ask func(issuedProvider, map[string]any) (link.Answer, error)) issuedTaker { + return issuedTaker{ + providers: func(provision string) ([]issuedProvider, bool, error) { + return []issuedProvider{{Node: "ace", Module: "plex", Tool: "plex_issued_credential"}}, true, nil + }, + ask: ask, + keep: func(provision, provider, value string) (inventory.IssuedKept, error) { + r.kept = append(r.kept, value) + holders := []inventory.Holder{{Provision: provision, Consumer: "ace", ConsumerModule: "kometa", Provider: provider}, + {Provision: provision, Consumer: "ace", ConsumerModule: "tautulli", Provider: provider}} + if r.again { + return inventory.IssuedKept{Holders: holders}, nil + } + return inventory.IssuedKept{Changed: true, Holders: holders, Sealed: holders, Machines: []string{"ace"}}, nil + }, + record: func(act link.HandAct) error { r.acts = append(r.acts, act); return nil }, + send: func(machines []string) error { r.sent = append(r.sent, machines); return nil }, + } +} + +// The value the provider's tool sealed to the call is kept, recorded and sent, and the answer never says it. +func TestAnIssuedValueIsTakenSealedFromTheProviderAndSent(t *testing.T) { + var r issuedRecorder + words, err := r.taker(t, issuedSealed(t, "the-servers-own-token")).take("plex-api") + if err != nil { + t.Fatal(err) + } + if len(r.kept) != 1 || r.kept[0] != "the-servers-own-token" { + t.Fatalf("the provider's value was not kept: %d kept", len(r.kept)) + } + if len(r.sent) != 1 || strings.Join(r.sent[0], ",") != "ace" { + t.Fatalf("the holders' machines were not sent: %v", r.sent) + } + if len(r.acts) != 1 || r.acts[0].Verb != handActIssued { + t.Fatalf("the change was not recorded in the hand-act log: %+v", r.acts) + } + for _, said := range []string{words, r.acts[0].Why, strings.Join(r.acts[0].Args, " ")} { + if strings.Contains(said, "the-servers-own-token") || strings.Contains(said, inventory.IssuedStamp("the-servers-own-token")) { + t.Fatalf("the value or its fingerprint was said: %q", said) + } + } + if !strings.Contains(words, "kometa on ace") || !strings.Contains(words, "tautulli on ace") { + t.Fatalf("the answer does not name the holders: %q", words) + } +} + +// The same value again writes nothing, records nothing and sends nobody (ADR 0305 §5). +func TestAnIssuedValueEveryHolderHoldsSendsNobody(t *testing.T) { + r := issuedRecorder{again: true} + words, err := r.taker(t, issuedSealed(t, "the-servers-own-token")).take("plex-api") + if err != nil { + t.Fatal(err) + } + if len(r.sent) != 0 || len(r.acts) != 0 { + t.Fatalf("an unchanged value was sent or recorded: %v %v", r.sent, r.acts) + } + if !strings.Contains(words, "nothing was sent") { + t.Fatalf("the answer does not say nothing was sent: %q", words) + } +} + +// A value not sealed to the call's key is not taken, nor is a refusal, nor an empty answer (ADR 0305 §2). +func TestOnlyAValueSealedToTheCallIsTaken(t *testing.T) { + other, _, _ := secrets.Keypair() + elsewhere, _ := secrets.Seal(other, []byte("somebody's value")) + raw, _ := json.Marshal(map[string]string{"sealed": elsewhere}) + plain, _ := json.Marshal(map[string]string{"value": "the-servers-own-token"}) + for name, ask := range map[string]func(issuedProvider, map[string]any) (link.Answer, error){ + "sealed to another key": func(issuedProvider, map[string]any) (link.Answer, error) { return link.Answer{Result: raw}, nil }, + "answered in the clear": func(issuedProvider, map[string]any) (link.Answer, error) { return link.Answer{Result: plain}, nil }, + "refused": func(issuedProvider, map[string]any) (link.Answer, error) { + return link.Answer{Error: "the controller alone"}, nil + }, + "not reached": func(issuedProvider, map[string]any) (link.Answer, error) { + return link.Answer{}, errors.New("503 no responders") + }, + "sealed empty": issuedSealed(t, " "), + } { + var r issuedRecorder + if _, err := r.taker(t, ask).take("plex-api"); err == nil { + t.Errorf("%s: taken", name) + } + if len(r.kept) != 0 || len(r.sent) != 0 { + t.Errorf("%s: something was kept or sent", name) + } + } +} + +// A provision no offer says its software issues is refused before anything is asked. +func TestAProvisionNoOfferSaysIsIssuedIsRefused(t *testing.T) { + asked := false + taker := issuedTaker{ + providers: func(string) ([]issuedProvider, bool, error) { return nil, false, nil }, + ask: func(issuedProvider, map[string]any) (link.Answer, error) { + asked = true + return link.Answer{}, nil + }, + } + if _, err := taker.take("postgres-database"); err == nil || !strings.Contains(err.Error(), "ADR 0305") { + t.Fatalf("expected a refusal naming the record, got %v", err) + } + if asked { + t.Fatal("a provider was asked for a provision no offer says its software issues") + } +} + +// Which assignments are asked: the modules on the providing machine whose offer says so, with their tool. +func TestTheProvidersAskedAreTheOffersThatSaySo(t *testing.T) { + shelf := map[string]catalogue.Manifest{ + "plex": {Module: "plex", Provides: []catalogue.Offer{{Name: "plex-api", Credential: &catalogue.OfferCredential{ + IssuedBy: catalogue.IssuedBySoftware, Tool: "plex_issued_credential"}}}}, + "jellyfin": {Module: "jellyfin", Provides: []catalogue.Offer{{Name: "plex-api"}}}, + } + got := issuedOn(shelf, "ace", []string{"jellyfin", "plex", "kometa"}, "plex-api") + if len(got) != 1 || got[0] != (issuedProvider{Node: "ace", Module: "plex", Tool: "plex_issued_credential"}) { + t.Fatalf("the providers asked: %+v", got) + } + if err := issuedRefusesRotation(shelf, "plex-api"); err == nil || !strings.Contains(err.Error(), "secret issued plex-api") { + t.Fatalf("rotate of an issued provision was not refused with the way to take it: %v", err) + } + if err := issuedRefusesRotation(shelf, "postgres-database"); err != nil { + t.Fatalf("rotate of an ordinary provision was refused: %v", err) + } +} + +// A change of an issued value is the mesh working as decided, never a repair (ADR 0305 §7). +func TestAnIssuedValueIsADecisionInTheHandActLog(t *testing.T) { + for _, v := range handActVerbs { + if v.Verb == handActIssued { + if v.Decision == "" { + t.Fatal("secret issued counts as a repair") + } + return + } + } + t.Fatal("secret issued is not in the hand-act table") +} diff --git a/cmd/mesh-controller/rotate.go b/cmd/mesh-controller/rotate.go index 06d7a88a..0bb31a93 100644 --- a/cmd/mesh-controller/rotate.go +++ b/cmd/mesh-controller/rotate.go @@ -55,6 +55,15 @@ func rotateCommand(ctx context.Context, args []string) error { defer open.Close() inv := open.inventory + // A credential the provider's software issued is not the mesh's to remake (novox/hq ADR 0305 §8). + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + if err := issuedRefusesRotation(shelf, provision); err != nil { + return err + } + holders, err := inv.HoldersOf(ctx, provision, *only) if err != nil { return err diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index ac0030d0..5c6c30ab 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -777,6 +777,13 @@ func (a *verbArguments) commandLine() ([]string, error) { return nil, err } return []string{"secret", "ask", str("node"), str("module"), str("secret"), "--at", str("at")}, nil + case "issued": + // The credential a provider's software issued, taken from the provider (novox/hq ADR 0305): no value in + // the arguments, the provision alone. + if err := need("provision"); err != nil { + return nil, err + } + return []string{"secret", "issued", str("provision")}, nil case "secret-ask": // A module's own secret asked for, typed by the operator at the desk (novox/hq ADR 0277): never a value // in the arguments. The desk is the module's machine unless at names another. @@ -1583,6 +1590,14 @@ func givenAtTheDesk(argv []string) bool { return argv[5] == "--at" && argv[6] != "" && !strings.HasPrefix(argv[6], "-") } +// takenFromTheProvider is exactly the line the `issued` verb composes, and nothing beside it: `secret issued +// ` (novox/hq ADR 0305 §9). It carries no value — the value comes from the provider's own tool, sealed to +// the call — and answers none. +func takenFromTheProvider(argv []string) bool { + return len(argv) == 3 && argv[0] == "secret" && argv[1] == "issued" && argv[2] != "" && + !strings.HasPrefix(argv[2], "-") +} + // terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal // alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and // `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself @@ -1599,7 +1614,7 @@ func terminalOnly(argv []string) error { // Of a secret's commands only rotation, which seals the new value to the machine that uses it, and the // `give` verb's own line: an own secret typed by the operator into the desk's hidden prompt, sealed to this // call and then to the module's machine, so no value travels in the verb or its answer (hq ADR 0259 §10). - if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") && !givenAtTheDesk(argv) { + if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") && !givenAtTheDesk(argv) && !takenFromTheProvider(argv) { return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+ "recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+ "0266). Nothing was done", strings.Join(argv[1:], " ")) diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 37c3cc3c..3b15167c 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -41,6 +41,9 @@ func secretCommand(ctx context.Context, args []string) error { case "accept": case "ask": return secretAsk(ctx, args[1:]) + case "issued": + // The credential a provider's software issued, taken from the provider (novox/hq ADR 0305). + return secretIssued(ctx, args[1:]) case "rotate": return secretRotate(ctx, args[1:]) case "recover": @@ -168,6 +171,7 @@ func secretAsk(ctx context.Context, args []string) error { const secretUsage = "secret rotate [--why [--cause ]]\n" + "secret ask [--at ]\n" + + "secret issued \n" + "secret accept [--from | --at-desk ] [--provider [--local ]]\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 new file mode 100644 index 00000000..221e67ae --- /dev/null +++ b/internal/catalogue/issued_credential_test.go @@ -0,0 +1,42 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer +// names the tool that answers it and no own secret, and every other shape is refused at parse. +func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"media","version":"1", + "provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}], + "serves":{"media-api":{"port":32400}}}`)) + if err != nil { + t.Fatal(err) + } + if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" { + t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued) + } + if _, shared := m.SharedCredentialOf("media-api"); shared { + t.Fatal("an issued credential was read as an own secret shared") + } + if _, issued := m.IssuedCredentialOf("other"); issued { + t.Fatal("a provision the module does not offer reads as issued") + } + + for want, raw := range map[string]string{ + "the one issuer 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", + "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, + } { + want = strings.TrimSpace(want) + 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 e344cb94..999b5ebd 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -268,9 +268,29 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool { // MachineReach is whether a provision is usable only on its provider's own machine. func (o Offer) MachineReach() bool { return o.Reach == ReachMachine } -// OfferCredential names which of the provider's own secrets a provision's consumers receive. +// 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, +// 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. type OfferCredential struct { - Own string `json:"own"` + Own string `json:"own,omitempty"` + IssuedBy string `json:"issued-by,omitempty"` + Tool string `json:"tool,omitempty"` +} + +// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305). +const IssuedBySoftware = "software" + +// IssuedCredentialOf is the tool through which this module answers the credential its software issued for a +// provision, and whether its offer says the software issues it (novox/hq ADR 0305). +func (m Manifest) IssuedCredentialOf(provision string) (string, bool) { + for _, o := range m.Provides { + if o.Name == provision && o.Credential != nil && o.Credential.IssuedBy == IssuedBySoftware && o.Credential.Tool != "" { + return o.Credential.Tool, true + } + } + return "", false } // SharedCredentialOf is the own secret an offer of this module names as the provision's credential, @@ -1550,7 +1570,24 @@ func ParseManifest(raw []byte) (Manifest, error) { "makes is %d", m.Module, p, id.Max, shortest)) } } - if offer.Credential != nil { + 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)) + 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 "+ + "software's value is the one, so the offer names no own secret (ADR 0305)", m.Module, p, c.Own)) + case strings.TrimSpace(c.Tool) == "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential its software issues and names no tool that answers it: "+ + "\"tool\": \"\" (ADR 0305)", m.Module, p)) + } + } else if offer.Credential != nil { own, declared := m.OwnSecrets[offer.Credential.Own] switch { case offer.Credential.Own == "": diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 40bba5f7..013f5c77 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -249,6 +249,15 @@ var ControllerVerbs = []Verb{ "secret": "the own secret's name in the module's definition", "at": "the machine the operator sits at, where the prompt opens", }, []string{"node", "module", "secret", "at"})}, + {Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " + + "server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " + + "0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " + + "value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " + + "machines when it changed. Carries no value and answers none; a value every holder already holds is not " + + "sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.", + Input: schema(map[string]string{ + "provision": "the provision whose provider's software issues its credential, e.g. plex-api", + }, []string{"provision"})}, {Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " + "that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " + "naming the module, the secret and who asked; the operator types the value there, never on a channel " + diff --git a/internal/inventory/issued.go b/internal/inventory/issued.go new file mode 100644 index 00000000..a4ec4cd1 --- /dev/null +++ b/internal/inventory/issued.go @@ -0,0 +1,155 @@ +package inventory + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/novox/mesh-controller/internal/secrets" +) + +// A credential a provider's software issued, kept as the provision's one credential (novox/hq ADR 0305, issue +// 526). +// +// **The software made it, so the mesh carries it.** A media server's sign-in token comes from its vendor; the mesh +// can neither make nor rotate it. The controller fetches it from the provider's own tool, sealed to a key made for +// that one call, and hands the value here, which seals it to every consumer that holds the provision from that +// provider — in the pair credential's shape, so a consumer's definition does not know where it came from (ADR 0158 +// §5) — and to the operator, and forgets it. +// +// **Asked often, written seldom.** The provider asks hourly and on every start. The mesh keeps no plaintext, so it +// cannot compare values; it stamps every row with the value's fingerprint instead, and a value whose stamp every +// row already carries is not sealed again — sealing anew would change every consumer's declaration and restart +// each of them for nothing. + +// OriginIssued is a pair credential the provider's software issued and the provider gave (novox/hq ADR 0305). +const OriginIssued = "issued" + +// IssuedStamp is the generation a value issued by a provider's software is kept under: a fingerprint of the value, +// so the same value fetched again is known as the same without being kept. +func IssuedStamp(value string) string { + sum := sha256.Sum256([]byte(value)) + return "issued:sha256:" + hex.EncodeToString(sum[:8]) +} + +// IssuedKept is what keeping an issued value did: whether it was sealed anew, the pairs that hold it, and the +// machines a send must reach — the provider's and every holder's — when it was. +type IssuedKept struct { + Changed bool + // Holders are every pair that holds the provision from the provider; Sealed those sealed anew by this act. + Holders []Holder + Sealed []Holder + Machines []string + // Unsealable are the holders whose machine has no sealing key: left as they are, said by name. + Unsealable []Holder +} + +// KeepIssuedSecret seals a value a provider's software issued to every pair of provision from provider that does +// not hold it yet, under one stamp; a pair that does is left as it is, and when every pair does nothing is written +// (novox/hq ADR 0305 §4, §5). +func (i *Inventory) KeepIssuedSecret(ctx context.Context, provision, provider, value string) (IssuedKept, error) { + if strings.TrimSpace(value) == "" { + return IssuedKept{}, fmt.Errorf("there is nothing to seal") + } + providerNode, err := i.NodeByName(ctx, provider) + if err != nil { + return IssuedKept{}, err + } + providerKey, err := i.SealingKeyOf(ctx, provider) + if err != nil { + return IssuedKept{}, err + } + if providerKey == "" { + return IssuedKept{}, fmt.Errorf("%s has no sealing key, so %s cannot be sealed to it", provider, provision) + } + stamp := IssuedStamp(value) + + type row struct { + holder Holder + consumerID any + generation, heldC, heldP string + consumerKey string + } + rows, err := i.store.Pool().Query(ctx, + `select s.consumer, c.name, s.consumer_module, s.local, coalesce(s.generation, ''), + coalesce(s.consumer_key, ''), coalesce(s.provider_key, ''), coalesce(c.sealing_key, '') + from secret s join node c on c.id = s.consumer + where s.provider = $1 and s.name = $2 + order by c.name, s.consumer_module, s.local`, providerNode.ID, provision) + if err != nil { + return IssuedKept{}, err + } + var held []row + for rows.Next() { + var r row + if err := rows.Scan(&r.consumerID, &r.holder.Consumer, &r.holder.ConsumerModule, &r.holder.Local, + &r.generation, &r.heldC, &r.heldP, &r.consumerKey); err != nil { + rows.Close() + return IssuedKept{}, err + } + r.holder.Provision, r.holder.Provider = provision, provider + held = append(held, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return IssuedKept{}, err + } + + var kept IssuedKept + var stale []row + for _, r := range held { + if r.consumerKey == "" { + kept.Unsealable = append(kept.Unsealable, r.holder) + continue + } + kept.Holders = append(kept.Holders, r.holder) + // Only a pair that does not hold this value, sealed to the keys its ends have now, is sealed anew: a + // holder that already does is left byte for byte as it is, so it is not restarted for another's sake. + if r.generation != stamp || r.heldC != r.consumerKey || r.heldP != providerKey { + stale = append(stale, r) + } + } + if len(stale) == 0 { + return kept, nil + } + + forOperator, operatorKey, err := i.operatorSeal(ctx, value) + if err != nil { + return IssuedKept{}, err + } + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return IssuedKept{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + machines := map[string]bool{provider: true} + for _, r := range stale { + sealed, err := secrets.Accept(value, r.consumerKey, providerKey) + if err != nil { + return IssuedKept{}, err + } + if _, err := tx.Exec(ctx, + `update secret set for_consumer = $6, for_provider = $7, consumer_key = $8, provider_key = $9, + operator_sealed = $10, operator_key = $11, origin = $12, generation = $13, created_at = now() + where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, + provision, r.consumerID, r.holder.ConsumerModule, providerNode.ID, r.holder.Local, + sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey, + forOperator, operatorKey, OriginIssued, stamp); err != nil { + return IssuedKept{}, err + } + machines[r.holder.Consumer] = true + kept.Sealed = append(kept.Sealed, r.holder) + } + if err := tx.Commit(ctx); err != nil { + return IssuedKept{}, err + } + kept.Changed = true + for m := range machines { + kept.Machines = append(kept.Machines, m) + } + sort.Strings(kept.Machines) + return kept, nil +} diff --git a/internal/inventory/issued_test.go b/internal/inventory/issued_test.go new file mode 100644 index 00000000..977eb188 --- /dev/null +++ b/internal/inventory/issued_test.go @@ -0,0 +1,124 @@ +package inventory + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// A value a provider's software issued is sealed to every pair that holds the provision from that provider, under +// one stamp of the value, and the same value given again writes nothing and sends nobody (novox/hq ADR 0305 §4, +// §5, issue 526). +func TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + consumerKey, consumerOpens := aSealingKey(t) + cn, _ := inv.NodeByName(ctx, "consumer") + if err := inv.RecordSealingKey(ctx, cn.ID, consumerKey); err != nil { + t.Fatal(err) + } + for _, m := range []string{"kometa", "tautulli"} { + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"plex-api"}, + Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil { + t.Fatal(err) + } + // What the mesh made before the provider gave its value: a random value per pair (issue 164). + if _, err := inv.SecretFor(ctx, "plex-api", "consumer", m, "provider", ""); err != nil { + t.Fatal(err) + } + } + + kept, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if !kept.Changed || len(kept.Holders) != 2 { + t.Fatalf("the value was not sealed to both holders: %+v", kept) + } + if len(kept.Machines) != 2 || kept.Machines[0] != "consumer" || kept.Machines[1] != "provider" { + t.Fatalf("the machines to send are the provider's and every holder's: %v", kept.Machines) + } + for _, m := range []string{"kometa", "tautulli"} { + got, err := inv.SecretFor(ctx, "plex-api", "consumer", m, "provider", "") + if err != nil { + t.Fatal(err) + } + if got.Origin != OriginIssued { + t.Fatalf("%s's pair says it came from %q, not the provider's software", m, got.Origin) + } + opened, ok := consumerOpens(got.ForConsumer) + if !ok || string(opened) != "the-servers-own-token" { + t.Fatalf("%s's copy is not the provider's value", m) + } + var generation string + pn, _ := inv.NodeByName(ctx, "provider") + if err := inv.store.Pool().QueryRow(ctx, `select generation from secret where name = 'plex-api' and consumer = $1 + and consumer_module = $2 and provider = $3`, cn.ID, m, pn.ID).Scan(&generation); err != nil { + t.Fatal(err) + } + if generation != IssuedStamp("the-servers-own-token") { + t.Fatalf("%s's pair is not stamped with the value's fingerprint: %q", m, generation) + } + } + before, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "") + + // A consumer that binds later gets a pair of its own, and no holder's value is remade for it: the offer names + // no own secret, so ADR 0158 §6's remaking on a bind does not reach it (ADR 0305 §6). + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "overseerr", Version: "1", Requires: []string{"plex-api"}, + Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil { + t.Fatal(err) + } + late, err := inv.SecretFor(ctx, "plex-api", "consumer", "overseerr", "provider", "") + if err != nil { + t.Fatal(err) + } + if late.Origin == OriginIssued { + t.Fatal("a consumer that bound after the ask holds the issued value without an ask") + } + if held, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); held.ForConsumer != before.ForConsumer { + t.Fatal("a later bind remade a holder's issued value") + } + + // The next ask seals the provider's value to the late consumer alone; the holders that had it are not touched. + again, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if !again.Changed || len(again.Sealed) != 1 || again.Sealed[0].ConsumerModule != "overseerr" { + t.Fatalf("the late consumer was not sealed to, or not alone: %+v", again) + } + if held, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); held.ForConsumer != before.ForConsumer { + t.Fatal("sealing the late consumer changed what a holder already held") + } + if got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "overseerr", "provider", ""); got.Origin != OriginIssued { + t.Fatal("the late consumer does not hold the issued value after the ask") + } + + // And once every holder holds it, the same value writes nothing and sends nobody. + still, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if still.Changed || len(still.Machines) != 0 { + t.Fatalf("the same value was sealed again, which restarts every consumer for nothing: %+v", still) + } + after, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "") + if after.ForConsumer != before.ForConsumer { + t.Fatal("the same value given again changed what the consumer holds") + } + + changed, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "a-token-plex-changed") + if err != nil { + t.Fatal(err) + } + if !changed.Changed { + t.Fatal("a changed value was not sealed") + } + got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "tautulli", "provider", "") + if opened, ok := consumerOpens(got.ForConsumer); !ok || string(opened) != "a-token-plex-changed" { + t.Fatal("the changed value did not reach the holder") + } + + if _, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", " "); err == nil { + t.Fatal("an empty value was kept") + } +} diff --git a/internal/link/retirement.go b/internal/link/retirement.go index 2f580c1c..c0bd8330 100644 --- a/internal/link/retirement.go +++ b/internal/link/retirement.go @@ -173,6 +173,25 @@ func ModuleToolOn(module, tool, node string) string { return ToolSubject(module, // AskModuleToolOn asks one machine's instance of a module one of its tools and reads its answer. func AskModuleToolOn(ctx context.Context, conn *nats.Conn, module, tool, node string, args any, + timeout time.Duration) (Answer, error) { + return askModuleToolAt(ctx, conn, ModuleToolOn(module, tool, node), module, tool, node, args, timeout) +} + +// AskModuleToolOnAs is AskModuleToolOn in the caller's own name: on the subject that names its caller (novox/hq +// issue 365), which the bus lets only that user publish, so the tool knows who asks from the subject alone. The +// one way a tool can answer the controller and nobody else (novox/hq ADR 0305 §2). +func AskModuleToolOnAs(ctx context.Context, conn *nats.Conn, caller, module, tool, node string, args any, + timeout time.Duration) (Answer, error) { + subject := broker.CalledSubject(ModuleToolOn(module, tool, node), caller) + if subject == "" { + return Answer{}, fmt.Errorf("%q cannot ask %s.%s on %s in its own name: it is not a bus user's name", caller, + module, tool, node) + } + return askModuleToolAt(ctx, conn, subject, module, tool, node, args, timeout) +} + +// askModuleToolAt asks one machine's instance of a module one of its tools on subject and reads its answer. +func askModuleToolAt(ctx context.Context, conn *nats.Conn, subject, module, tool, node string, args any, timeout time.Duration) (Answer, error) { body, err := json.Marshal(args) if err != nil { @@ -180,7 +199,6 @@ func AskModuleToolOn(ctx context.Context, conn *nats.Conn, module, tool, node st } asking, cancel := context.WithTimeout(ctx, timeout) defer cancel() - subject := ModuleToolOn(module, tool, node) refused, stop := refusalsOf(conn, subject) defer stop() type replied struct { diff --git a/module.json b/module.json index ba7c0ad5..f3d79f41 100644 --- a/module.json +++ b/module.json @@ -77,6 +77,7 @@ "drill", "warranted", "secret-ask", + "issued", "hand-acts", "durations", "conditions", diff --git a/testdata/store-tests b/testdata/store-tests index fa3c81b5..05e02fb2 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -10,3 +10,4 @@ ./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered ./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend ./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore +./internal/inventory TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain From faf8b76b84c41529b345864b440d1c6f81dffedd Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 01:09:26 +0200 Subject: [PATCH 2/3] Cite ADR 0305's clauses by their accepted-for-review numbers (issue 526) --- cmd/mesh-controller/issued.go | 2 +- cmd/mesh-controller/issued_test.go | 4 ++-- cmd/mesh-controller/rotate.go | 2 +- cmd/mesh-controller/seatverbs.go | 2 +- internal/inventory/issued_test.go | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/mesh-controller/issued.go b/cmd/mesh-controller/issued.go index 1f7d6538..1ec1e128 100644 --- a/cmd/mesh-controller/issued.go +++ b/cmd/mesh-controller/issued.go @@ -210,7 +210,7 @@ func issuedOn(shelf map[string]catalogue.Manifest, node string, assigned []strin return out } -// issuedRefusesRotation refuses `rotate` of a provision its provider's software issues (ADR 0305 §8): a value the +// issuedRefusesRotation refuses `rotate` of a provision its provider's software issues (ADR 0305 §9): a value the // mesh made would replace one the software holds, and every consumer would hold a dead credential until the // provider next asks. func issuedRefusesRotation(shelf map[string]catalogue.Manifest, provision string) error { diff --git a/cmd/mesh-controller/issued_test.go b/cmd/mesh-controller/issued_test.go index 36fc63ae..c9b1b6c8 100644 --- a/cmd/mesh-controller/issued_test.go +++ b/cmd/mesh-controller/issued_test.go @@ -13,7 +13,7 @@ import ( ) // The `issued` verb runs `secret issued ` and nothing else, and that line is the one `secret` line -// beside rotate and the desk's that a verb may run (novox/hq ADR 0305 §3, §9, issue 526). +// beside rotate and the desk's that a verb may run (novox/hq ADR 0305 §3, §10, issue 526). func TestTheIssuedVerbRunsTheOneLineThatCarriesNoValue(t *testing.T) { argv, err := argvFor("issued", map[string]any{"provision": "plex-api"}) if err != nil { @@ -204,7 +204,7 @@ func TestTheProvidersAskedAreTheOffersThatSaySo(t *testing.T) { } } -// A change of an issued value is the mesh working as decided, never a repair (ADR 0305 §7). +// A change of an issued value is the mesh working as decided, never a repair (ADR 0305 §8). func TestAnIssuedValueIsADecisionInTheHandActLog(t *testing.T) { for _, v := range handActVerbs { if v.Verb == handActIssued { diff --git a/cmd/mesh-controller/rotate.go b/cmd/mesh-controller/rotate.go index 0bb31a93..f9168ddf 100644 --- a/cmd/mesh-controller/rotate.go +++ b/cmd/mesh-controller/rotate.go @@ -55,7 +55,7 @@ func rotateCommand(ctx context.Context, args []string) error { defer open.Close() inv := open.inventory - // A credential the provider's software issued is not the mesh's to remake (novox/hq ADR 0305 §8). + // A credential the provider's software issued is not the mesh's to remake (novox/hq ADR 0305 §9). shelf, err := inv.Catalogue(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 5c6c30ab..2b013154 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -1591,7 +1591,7 @@ func givenAtTheDesk(argv []string) bool { } // takenFromTheProvider is exactly the line the `issued` verb composes, and nothing beside it: `secret issued -// ` (novox/hq ADR 0305 §9). It carries no value — the value comes from the provider's own tool, sealed to +// ` (novox/hq ADR 0305 §10). It carries no value — the value comes from the provider's own tool, sealed to // the call — and answers none. func takenFromTheProvider(argv []string) bool { return len(argv) == 3 && argv[0] == "secret" && argv[1] == "issued" && argv[2] != "" && diff --git a/internal/inventory/issued_test.go b/internal/inventory/issued_test.go index 977eb188..171dba11 100644 --- a/internal/inventory/issued_test.go +++ b/internal/inventory/issued_test.go @@ -62,7 +62,7 @@ func TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain(t *testing.T) { before, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "") // A consumer that binds later gets a pair of its own, and no holder's value is remade for it: the offer names - // no own secret, so ADR 0158 §6's remaking on a bind does not reach it (ADR 0305 §6). + // no own secret, so ADR 0158 §6's remaking on a bind does not reach it (ADR 0305 §7). if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "overseerr", Version: "1", Requires: []string{"plex-api"}, Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil { t.Fatal(err) From 4ee131e990dd468f6faa3f06f85ddf2679857ce9 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 01:21:20 +0200 Subject: [PATCH 3/3] Let the controller ask an issued credential's tool in its own name, and seal it again over a remade pair (hq issue 526, ADR 0305) The bus left the controller out of the caller-named grants, so issued could never reach plex's tool; the controller is now granted exactly the tools of offers whose software issues the credential, composed from the records, so the installer's first user list is unchanged. A pair remade on a key change or accepted by hand kept the issued stamp and was never sealed again; those writes now clear it. --- cmd/mesh-controller/issued.go | 6 ++++ internal/broker/issued_test.go | 53 ++++++++++++++++++++++++++++++ internal/broker/nats.go | 18 +++++++++++ internal/broker/users.go | 24 +++++++++++++- internal/inventory/busrecords.go | 15 +++++++++ internal/inventory/issued_test.go | 54 +++++++++++++++++++++++++++++++ internal/inventory/secrets.go | 9 ++++-- testdata/store-tests | 1 + 8 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 internal/broker/issued_test.go diff --git a/cmd/mesh-controller/issued.go b/cmd/mesh-controller/issued.go index 1ec1e128..1f1b348c 100644 --- a/cmd/mesh-controller/issued.go +++ b/cmd/mesh-controller/issued.go @@ -27,6 +27,12 @@ import ( // (issue 365) — with a sealing key made for this one call. The tool answers only the controller, the value sealed // to that key; the controller opens it, seals it to every holder and forgets it, as the desk's value is (ADR 0277 // §23). Asking again fetches the same value again, and a value every holder already holds is not sealed anew. +// +// **What this trusts: the provider's machine.** The answer is taken from whoever answers the provider's tool +// subject on the provider's machine first. On that machine its runtime serves the subject for every module it +// carries, so anything that runtime runs, or anything else granted that subject there, could answer before the +// provider does and hand the controller a value of its own. ADR 0305 accepts that by trusting the provider's +// machine, as the provider itself is trusted; the bus keeps every other machine from answering it. // handActIssued is the verb a change of an issued value is recorded under: the mesh working as decided. const handActIssued = "secret issued" diff --git a/internal/broker/issued_test.go b/internal/broker/issued_test.go new file mode 100644 index 00000000..3e0a36d7 --- /dev/null +++ b/internal/broker/issued_test.go @@ -0,0 +1,53 @@ +package broker + +import "testing" + +// The controller asks a tool that answers a software-issued credential in its own name, and nothing else under +// `call` (novox/hq ADR 0305 §2, issue 526): the grant is composed from the records, so the installer's first user +// list, a controller with no records, carries none. +func TestTheControllerAsksAnIssuedCredentialsToolInItsOwnNameAlone(t *testing.T) { + r := Records{Nodes: []string{"n", "ace"}, Assigned: map[string][]Declared{ + "n": {{Module: "x", Issued: []string{"t"}}}, + "ace": {{Module: "plex", Issued: []string{"plex_issued_credential"}}, {Module: "kometa"}}, + }} + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + controller := users[0] + if controller.Kind != KindController { + t.Fatalf("the first user is %s", controller.Username()) + } + perms, err := PermissionsFor(controller) + if err != nil { + t.Fatal(err) + } + for _, s := range []string{ + "mesh.mod.x.call.t.n.controller", + "mesh.mod.plex.call.plex_issued_credential.ace.controller", + } { + if !MayPublish(perms, s) { + t.Errorf("the controller may not publish %s", s) + } + } + for _, s := range []string{ + "mesh.mod.x.call.t.n.ace.node-tools", + "mesh.mod.x.call.t.n." + CallerToken("ace.node-tools"), + "mesh.mod.plex.call.plex_issued_credential.ace." + CallerToken("ace.node-tools"), + "mesh.mod.plex.call.plex_status.ace.controller", + "mesh.mod.kometa.call.t.ace.controller", + "mesh.seat.mesh-controller.call.status.controller", + } { + if MayPublish(perms, s) { + t.Errorf("the controller may publish %s", s) + } + } + + bare, err := PermissionsFor(Principal{Kind: KindController}) + if err != nil { + t.Fatal(err) + } + if MayPublish(bare, "mesh.mod.x.call.t.n.controller") { + t.Fatal("a controller composed with no records is granted a caller-named call") + } +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 659a9cc8..04330596 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -123,6 +123,13 @@ type Principal struct { // permission and nothing beside it. Invokes []string + // AsksIssued are the tools the controller asks in its own name for a credential a provider's software + // issued, each `.` (novox/hq ADR 0305): on the subject that names its caller, so the tool + // knows the controller asks. Composed from the records, so the installer's first user list — a controller + // with no records — carries none, and moves with issue 464 like the rest of the controller's caller-named + // grants. + AsksIssued []string + // State is the local names of the state this principal's module keeps, and Reads the state of // others it reads as `.` (novox/hq ADR 0201): a bucket each, kept by the owner's // instances and read by whoever declares it. @@ -890,6 +897,17 @@ func PermissionsFor(p Principal) (Permissions, error) { // and moves with issue 464. if p.Kind != KindController { pub, sub = callerNamed(p.Username(), pub, sub) + } else { + // **But the tools that answer a software-issued credential** (novox/hq ADR 0305 §2): those answer the + // controller alone, and know it only from the subject's last token, so it asks them in its own name — + // those tools, on any machine, and nothing else under `call`. + for _, key := range p.AsksIssued { + module, tool, ok := strings.Cut(key, ".") + if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") { + continue + } + pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username())) + } } sort.Strings(pub) diff --git a/internal/broker/users.go b/internal/broker/users.go index 86ff3b4e..66bb1803 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -29,6 +29,9 @@ type Declared struct { Holds []Seat // Uses are the seats this module sends to. Uses []Seat + // Issued are the tools that answer a credential its software issued, one per offer that says so (novox/hq + // ADR 0305): the controller is granted to ask each in its own name, and no other tool so. + Issued []string // Watches are the seats whose events it consumes. Watches []Seat // Invokes are the tools it calls, `.` or `*` (novox/hq ADR 0152). @@ -82,7 +85,7 @@ type Records struct { // is a mesh that cannot be told anything, and there is no state of the records in which that is // correct. func Users(r Records) ([]Principal, error) { - out := []Principal{{Kind: KindController, Uses: asksSeatOf(r)}} + out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r)}} for _, node := range sortedCopy(r.Nodes) { witness := false @@ -203,6 +206,25 @@ const controllerModule = "mesh-controller" // asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller // asks the operator through it like any other user, and is granted what its declaration names for a caller. // None while nothing holds it. +// issuedOf is every tool of a module assigned anywhere that answers a credential its software issued, as +// `.` (novox/hq ADR 0305): what the controller asks in its own name, and nothing beside. +func issuedOf(r Records) []string { + seen := map[string]bool{} + var out []string + for _, node := range sortedCopy(r.Nodes) { + for _, d := range r.Assigned[node] { + for _, t := range d.Issued { + if key := d.Module + "." + t; !seen[key] { + seen[key] = true + out = append(out, key) + } + } + } + } + sort.Strings(out) + return out +} + func asksSeatOf(r Records) []Seat { for _, node := range sortedCopy(r.Nodes) { for _, d := range r.Assigned[node] { diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 68ca4f54..96684afb 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -3,6 +3,7 @@ package inventory import ( "context" "fmt" + "slices" "strings" "github.com/novox/mesh-controller/internal/broker" @@ -170,6 +171,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Checks: catalogue.HealthChecks(m), // And whether it runs as an account of its own (novox/hq ADR 0259 §8). RunsAs: m.RunsAs, + // And the tools that answer a credential its software issued, which the controller asks in its own + // name (novox/hq ADR 0305). + Issued: issuedTools(m), } // Whether it can be given an account at all: delivered as its own secret named broker, so one // that declares none has nowhere to read it (novox/hq issue 195). @@ -197,6 +201,17 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio return d } +// issuedTools are the tools through which a module answers the credentials its software issues (novox/hq ADR 0305). +func issuedTools(m catalogue.Manifest) []string { + var out []string + for _, o := range m.Provides { + if tool, issued := m.IssuedCredentialOf(o.Name); issued && !slices.Contains(out, tool) { + out = append(out, tool) + } + } + return out +} + // bucketsOf is the state a module keeps, as the bus holds it. func bucketsOf(m catalogue.Manifest) []broker.Bucket { var out []broker.Bucket diff --git a/internal/inventory/issued_test.go b/internal/inventory/issued_test.go index 171dba11..f8725f20 100644 --- a/internal/inventory/issued_test.go +++ b/internal/inventory/issued_test.go @@ -122,3 +122,57 @@ func TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain(t *testing.T) { t.Fatal("an empty value was kept") } } + +// A consumer whose sealing key changed holds a value the mesh made in place of the issued one, and the next ask +// seals the issued value to it again; so does a pair a person accepted over it (novox/hq ADR 0305 §5, the review +// of mesh-controller #239). +func TestAnIssuedValueIsSealedAgainAfterAKeyChangeOrAnAcceptance(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "kometa", Version: "1", Requires: []string{"plex-api"}, + Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil { + t.Fatal(err) + } + if _, err := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); err != nil { + t.Fatal(err) + } + if kept, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token"); err != nil || !kept.Changed { + t.Fatalf("the first ask did not seal: %+v %v", kept, err) + } + + // The consumer rejoins with a new key: the mesh remakes the pair, a value the software refuses. + newKey, opens := aSealingKey(t) + cn, _ := inv.NodeByName(ctx, "consumer") + if err := inv.RecordSealingKey(ctx, cn.ID, newKey); err != nil { + t.Fatal(err) + } + remade, err := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "") + if err != nil { + t.Fatal(err) + } + if remade.Origin != OriginMade { + t.Fatalf("a value the mesh remade says it came from %q", remade.Origin) + } + again, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if !again.Changed || len(again.Sealed) != 1 { + t.Fatalf("the issued value was not sealed again after the key changed: %+v", again) + } + got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "") + if opened, ok := opens(got.ForConsumer); !ok || string(opened) != "the-servers-own-token" { + t.Fatal("the consumer does not hold the issued value under its new key") + } + + // A person accepts another value over it: the next ask seals the software's again. + if err := inv.AcceptSecretForPair(ctx, "plex-api", "consumer", "kometa", "provider", "", "a-typed-copy"); err != nil { + t.Fatal(err) + } + after, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if !after.Changed { + t.Fatal("an accepted value over the issued one was read as still holding it") + } +} diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 80f002b5..34ee9cb1 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -58,7 +58,9 @@ const ( // **Remade when either end's sealing key changes.** A node that rejoined generated a new key and // can no longer open what was sealed to the old one, so keeping the blob would deliver something // unreadable for ever. The new secret reaches both ends in the same push, which is the only -// moment they can be changed together. +// moment they can be changed together. A remade value is the mesh's, whatever the row held: its +// origin says made and it carries no stamp, so a value a provider's software issued is sealed to it +// again at the provider's next ask (novox/hq ADR 0305 §5) rather than read as still held. func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModule, provider, local string) ( Secret, error) { consumerKey, err := i.SealingKeyOf(ctx, consumer) @@ -121,7 +123,8 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul for_consumer = excluded.for_consumer, for_provider = excluded.for_provider, consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, created_at = now(), - operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`, + operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key, + origin = 'made', generation = null`, name, consumerNode.ID, consumerModule, providerNode.ID, made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey, local) if err != nil { @@ -202,7 +205,7 @@ func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, con 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, + created_at = now(), origin = excluded.origin, generation = null, operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`, name, consumerNode.ID, consumerModule, providerNode.ID, sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey, diff --git a/testdata/store-tests b/testdata/store-tests index 05e02fb2..d604f6d9 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -11,3 +11,4 @@ ./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend ./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore ./internal/inventory TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain +./internal/inventory TestAnIssuedValueIsSealedAgainAfterAKeyChangeOrAnAcceptance