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)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/526-a-provider-delivers-what-its-software-issued delivered: every member is delivered

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.
This commit is contained in:
2026-10-12 01:21:20 +02:00
parent faf8b76b84
commit 4ee131e990
8 changed files with 176 additions and 4 deletions
+6
View File
@@ -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 // (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 // 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. // §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. // handActIssued is the verb a change of an issued value is recorded under: the mesh working as decided.
const handActIssued = "secret issued" const handActIssued = "secret issued"
+53
View File
@@ -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")
}
}
+18
View File
@@ -123,6 +123,13 @@ type Principal struct {
// permission and nothing beside it. // permission and nothing beside it.
Invokes []string Invokes []string
// AsksIssued are the tools the controller asks in its own name for a credential a provider's software
// issued, each `<module>.<tool>` (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 // State is the local names of the state this principal's module keeps, and Reads the state of
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's // others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
// instances and read by whoever declares it. // instances and read by whoever declares it.
@@ -890,6 +897,17 @@ func PermissionsFor(p Principal) (Permissions, error) {
// and moves with issue 464. // and moves with issue 464.
if p.Kind != KindController { if p.Kind != KindController {
pub, sub = callerNamed(p.Username(), pub, sub) 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) sort.Strings(pub)
+23 -1
View File
@@ -29,6 +29,9 @@ type Declared struct {
Holds []Seat Holds []Seat
// Uses are the seats this module sends to. // Uses are the seats this module sends to.
Uses []Seat 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 are the seats whose events it consumes.
Watches []Seat Watches []Seat
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152). // Invokes are the tools it calls, `<module>.<tool>` 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 // is a mesh that cannot be told anything, and there is no state of the records in which that is
// correct. // correct.
func Users(r Records) ([]Principal, error) { 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) { for _, node := range sortedCopy(r.Nodes) {
witness := false 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 // 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. // asks the operator through it like any other user, and is granted what its declaration names for a caller.
// None while nothing holds it. // None while nothing holds it.
// issuedOf is every tool of a module assigned anywhere that answers a credential its software issued, as
// `<module>.<tool>` (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 { func asksSeatOf(r Records) []Seat {
for _, node := range sortedCopy(r.Nodes) { for _, node := range sortedCopy(r.Nodes) {
for _, d := range r.Assigned[node] { for _, d := range r.Assigned[node] {
+15
View File
@@ -3,6 +3,7 @@ package inventory
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"strings" "strings"
"github.com/novox/mesh-controller/internal/broker" "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), Checks: catalogue.HealthChecks(m),
// And whether it runs as an account of its own (novox/hq ADR 0259 §8). // And whether it runs as an account of its own (novox/hq ADR 0259 §8).
RunsAs: m.RunsAs, 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 // 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). // 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 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. // bucketsOf is the state a module keeps, as the bus holds it.
func bucketsOf(m catalogue.Manifest) []broker.Bucket { func bucketsOf(m catalogue.Manifest) []broker.Bucket {
var out []broker.Bucket var out []broker.Bucket
+54
View File
@@ -122,3 +122,57 @@ func TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain(t *testing.T) {
t.Fatal("an empty value was kept") 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")
}
}
+6 -3
View File
@@ -58,7 +58,9 @@ const (
// **Remade when either end's sealing key changes.** A node that rejoined generated a new key and // **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 // 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 // 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) ( func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModule, provider, local string) (
Secret, error) { Secret, error) {
consumerKey, err := i.SealingKeyOf(ctx, consumer) 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, for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
created_at = now(), 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, name, consumerNode.ID, consumerModule, providerNode.ID,
made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey, local) made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey, local)
if err != nil { 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 on conflict (name, local, consumer, consumer_module, provider) do update set
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider, for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, 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`, operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
name, consumerNode.ID, consumerModule, providerNode.ID, name, consumerNode.ID, consumerModule, providerNode.ID,
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey, sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
+1
View File
@@ -11,3 +11,4 @@
./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend ./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend
./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore ./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore
./internal/inventory TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain ./internal/inventory TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain
./internal/inventory TestAnIssuedValueIsSealedAgainAfterAKeyChangeOrAnAcceptance