Promise the vault's intake verbs and let the controller ask them in its own name (issue 675)
The vault takes a value only from the controller, which it knows from the subject that names its caller. The grant is composed from the records, as the issued tools' is, so the installer's first user list is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,3 +96,47 @@ func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// **The controller may ask the vault's intake verbs in its own name** (design 57's V2, issue 675): the vault
|
||||
// takes a value only from the controller, which it knows from the subject that names its caller — so the
|
||||
// controller is granted that subject for the verbs the vault's holder serves, and no more of the vault's seat.
|
||||
// Composed from the records, as the issued tools are: the installer's first user list carries none.
|
||||
func TestTheControllerMayAskTheVaultInItsOwnName(t *testing.T) {
|
||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault",
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {vault}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := users[0]
|
||||
if strings.Join(controller.AsksTheVault, ",") != "accept,issued,rotate,secret-ask" {
|
||||
t.Fatalf("composed %v", controller.AsksTheVault)
|
||||
}
|
||||
p, err := PermissionsFor(controller)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, verb := range []string{"accept", "secret-ask", "issued", "rotate"} {
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, ControllerName)) {
|
||||
t.Errorf("the controller may not ask the vault's %s in its own name", verb)
|
||||
}
|
||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, "anchor.node-tools")) {
|
||||
t.Errorf("the controller may ask the vault's %s in another's name", verb)
|
||||
}
|
||||
}
|
||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.get", ControllerName)) {
|
||||
t.Error("the controller may ask the vault for a secret: it seals nothing the vault holds")
|
||||
}
|
||||
// A vault that serves none of them yet — the running one — composes no grant, nor does genesis.
|
||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get"}}}}
|
||||
users, _ = Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {older}}})
|
||||
if len(users[0].AsksTheVault) != 0 {
|
||||
t.Errorf("a vault serving none composed %v", users[0].AsksTheVault)
|
||||
}
|
||||
genesis, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
for _, s := range genesis.Publish {
|
||||
if strings.Contains(s, "mesh-vault") {
|
||||
t.Errorf("the installer's controller may publish %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -130,6 +131,11 @@ type Principal struct {
|
||||
// grants.
|
||||
AsksIssued []string
|
||||
|
||||
// AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault,
|
||||
// issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the
|
||||
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
|
||||
AsksTheVault []string
|
||||
|
||||
// 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
|
||||
// instances and read by whoever declares it.
|
||||
@@ -269,6 +275,14 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery",
|
||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
|
||||
{Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}}
|
||||
|
||||
// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq
|
||||
// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk
|
||||
// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a
|
||||
// rotation. The vault takes these from the controller alone, which it knows from the subject that names its
|
||||
// caller; granted as Principal.AsksTheVault says.
|
||||
var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"},
|
||||
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"}}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
||||
@@ -930,6 +944,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
|
||||
}
|
||||
// **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known
|
||||
// from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat.
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
if slices.Contains(p.AsksTheVault, v.Verb) {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(pub)
|
||||
|
||||
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
@@ -87,7 +88,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), AsksIssued: issuedOf(r)}}
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r)}}
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
@@ -242,3 +243,24 @@ func asksSeatOf(r Records) []Seat {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
|
||||
// is granted to ask it in its own name. None while no holder serves them.
|
||||
func vaultVerbsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
|
||||
seen[v.Verb] = true
|
||||
out = append(out, v.Verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -129,9 +129,11 @@ var defaultSeats = append([]Seat{
|
||||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
||||
// second claimant, refused by name, rather than a candidate for a pin.
|
||||
//
|
||||
// Its one verb, `get`, hands a secret on request and reply, sealed to the key the asker sends and only
|
||||
// to an asker the vault allows (novox/hq ADR 0349 §11, issue 674). Optional until the vault that runs
|
||||
// serves it (design 33 §7).
|
||||
// Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an
|
||||
// asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate`
|
||||
// take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from
|
||||
// the controller alone, which carries the value sealed to a key the vault made for the call and cannot open
|
||||
// it. All optional until the vault that runs serves them (design 33 §7).
|
||||
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
|
||||
Serves: []Verb{
|
||||
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
|
||||
@@ -140,6 +142,30 @@ var defaultSeats = append([]Seat{
|
||||
Input: schema(map[string]string{"name": "the secret's name",
|
||||
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
|
||||
[]string{"name", "public_key"})},
|
||||
{Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " +
|
||||
"controller alone: name the secret and get a call and a key made for it, then send the call and the " +
|
||||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
||||
Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret",
|
||||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
||||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
||||
"by": "who gave it at the terminal"}), nil)},
|
||||
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
|
||||
"from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " +
|
||||
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
|
||||
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
|
||||
"module": "the module", "name": "the secret's name in the module",
|
||||
"desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller",
|
||||
"outcome": "instead of sealed: how the prompt ended"}), nil)},
|
||||
{Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " +
|
||||
"provision, from the controller alone, which asks the provider's tool with a key the vault made for " +
|
||||
"the call; kept as the provision's one credential, and the same value again writes nothing.",
|
||||
Input: schema(vaultIntake(map[string]string{"provision": "the provision",
|
||||
"provider": "the provider's machine", "module": "the provider's module"}), nil)},
|
||||
{Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " +
|
||||
"software issued and for an accepted credential. Never answers the value.",
|
||||
Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module",
|
||||
"name": "the secret's name in the module", "provider": "the provider's machine",
|
||||
"provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)},
|
||||
}},
|
||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
||||
@@ -822,3 +848,13 @@ func buildAgentVerbs() []Verb {
|
||||
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
|
||||
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
|
||||
const BrokerSeat = "mesh-broker"
|
||||
|
||||
// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed.
|
||||
func vaultIntake(first map[string]string) map[string]string {
|
||||
out := map[string]string{"call": "the second step: the call the first step answered",
|
||||
"sealed": "the second step: the value sealed to the call's key — never the value"}
|
||||
for k, v := range first {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -54,3 +54,46 @@ func TestTheVaultsSeatPromisesGet(t *testing.T) {
|
||||
t.Fatalf("the vault that does not serve get yet was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises
|
||||
// accept, secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted,
|
||||
// and, optional while the vault that runs does not, so is one that does not yet.
|
||||
func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
seat, _ := SeatNamed("mesh-vault")
|
||||
want := map[string][]string{
|
||||
"accept": {"node", "module", "name", "provider", "provision", "by", "call", "sealed"},
|
||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
||||
"issued": {"provision", "provider", "module", "call", "sealed"},
|
||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == name {
|
||||
verb = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if verb == nil {
|
||||
t.Errorf("the vault's seat does not promise %s", name)
|
||||
continue
|
||||
}
|
||||
if !verb.Optional {
|
||||
t.Errorf("%s is not optional while the vault that runs does not serve it", name)
|
||||
}
|
||||
props, _ := verb.Input["properties"].(map[string]any)
|
||||
for _, arg := range args {
|
||||
if props[arg] == nil {
|
||||
t.Errorf("%s takes no %s", name, arg)
|
||||
}
|
||||
}
|
||||
if props["value"] != nil {
|
||||
t.Errorf("%s takes a value in the clear", name)
|
||||
}
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -609,13 +609,29 @@ func PausedSaid(js *broker.JetStream, seat string, nodes []string) (map[string]H
|
||||
// (design 33 §4: a mesh seat's verb carries no machine), and reads its answer (novox/hq ADR 0239: the
|
||||
// delivery's owner's `stalled` and `close`).
|
||||
func AskMeshSeatTool(ctx context.Context, conn *nats.Conn, seat, verb string, args any, timeout time.Duration) (Answer, error) {
|
||||
return askMeshSeatToolAt(ctx, conn, SeatToolSubject(seat, verb), seat, verb, args, timeout)
|
||||
}
|
||||
|
||||
// AskMeshSeatToolAs is AskMeshSeatTool 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 holder knows who asks from the subject alone —
|
||||
// how the vault takes a value from the controller and nobody else (design 57's V2, issue 675).
|
||||
func AskMeshSeatToolAs(ctx context.Context, conn *nats.Conn, caller, seat, verb string, args any,
|
||||
timeout time.Duration) (Answer, error) {
|
||||
subject := broker.CalledSubject(SeatToolSubject(seat, verb), caller)
|
||||
if subject == "" {
|
||||
return Answer{}, fmt.Errorf("%q cannot ask %s.%s in its own name: it is not a bus user's name", caller, seat, verb)
|
||||
}
|
||||
return askMeshSeatToolAt(ctx, conn, subject, seat, verb, args, timeout)
|
||||
}
|
||||
|
||||
func askMeshSeatToolAt(ctx context.Context, conn *nats.Conn, subject, seat, verb string, args any,
|
||||
timeout time.Duration) (Answer, error) {
|
||||
body, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
asking, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
subject := SeatToolSubject(seat, verb)
|
||||
refused, stop := refusalsOf(conn, subject)
|
||||
defer stop()
|
||||
type replied struct {
|
||||
|
||||
Reference in New Issue
Block a user