From 4ee131e990dd468f6faa3f06f85ddf2679857ce9 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 01:21:20 +0200 Subject: [PATCH] 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