From dadf824c3e33dc5f6be3d6c6e42dc5d8ef8c6a68 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 01:39:49 +0000 Subject: [PATCH] mesh-vault: tests of the bound, the retry's real path and a hand-out without its record; the README corrected (review 255) Co-Authored-By: Claude Opus 5.5 --- modules/mesh-vault/README.md | 27 +++++- .../mesh-vault/cmd/mesh-vault/vault_test.go | 83 ++++++++++++++++++- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index e7021e69..b34b94c0 100644 --- a/modules/mesh-vault/README.md +++ b/modules/mesh-vault/README.md @@ -96,7 +96,16 @@ the bus user the runtime proved — never an argument. Every answer and every refusal is recorded in the vault's own store (`handed.jsonl`: who, which secret, which generation, when, and why a refusal), synced before the answer is given — as ADR 0277 §25 records -asks. `secret_handed` reads it. +asks; a secret whose hand-out cannot be recorded is not handed out. A refusal is recorded only for a caller +of a bus user's shape and a well-formed secret name, so an arbitrary argument fills nothing. The record is +bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, each roll said loudly in the +journal and counted in `vault_status`. `secret_handed` reads it. + +`vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in +the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health +check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so +nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person +through the journal and this tool only. **Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither @@ -126,9 +135,19 @@ ten minutes; more than three at once, or more than half of those held, waits for only by `provisioner_delete`, a person's act with a why. `provisioner_retirement` shows it all. The vault is mark-only: it holds no access to switch off. -What the TypeScript vault did and this one does not: it consumed its own events only to print an audit -line, which the pass now prints itself; and it withdrew a holder on the first pass that missed it, with its -history, and said `deprovisioned` — that is gone for ADR 0230's retirement. +The words are the controller's: the `provisioner.retirement` event is mesh-controller's `link.Retirement` +(`provider-node`, `consumers` as objects) and the `provisioner_retirement` answer its `link.RetirementState` +(`held`, `bound`, `retires_by: mark-only`), so `retire approve`, `retire reject` and `cleanup list` read the +vault and a set waiting for a person raises the controller's urgent condition. The acts take what the SDK's +harness takes: approve and reject repeat the set in `consumers` and refuse any other set; delete repeats the +name in `confirm`; each records the `by` and `via` the controller sends. `testdata/retirement-*.json` are +those types marshalled by the controller (feat/57 at dc36536), and the tests hold the vault's words to them. + +What the TypeScript vault did and this one does not: it consumed its own events only to print an audit line, +which the pass now prints itself. Its retirement ran on the SDK's harness, which this one follows: mark-only, +and a holder's ledger file removed only through a person's delete, as here. The difference is where the mark +lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger +file. ## What comes later diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 561229a0..6c7d43e0 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -560,11 +560,12 @@ func TestANewOperatorKeyFailingPartwayIsFinished(t *testing.T) { t.Fatalf("%d copies left on the earlier key, not 1", left) } v.Store.failWrite = nil - if n, err := v.Reseal(); err != nil || n != 1 { - t.Fatalf("the retry sealed %d again (%v), not the 1 left", n, err) + // The path the minute takes while the export holds the key: the same key again. + if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 1 { + t.Fatalf("the same key again sealed %d again (%v), not the 1 left", n, err) } if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 0 { - t.Fatalf("the same key again sealed %d (%v)", n, err) + t.Fatalf("the same key a third time sealed %d (%v)", n, err) } all, _ := v.Store.List() for _, rec := range all { @@ -651,3 +652,79 @@ func TestTheOperatorRecoversAVaultSecretFromItsExport(t *testing.T) { t.Error("the export holds the value in the clear") } } + +// With no key in the export the minute calls Reseal, which finishes what is pending too. +func TestResealFinishesWhatIsPending(t *testing.T) { + v := newVault(t, nil) + if _, err := v.Mint("a/one", "the test"); err != nil { + t.Fatal(err) + } + pub, _ := operatorKey(t) + v.Store.failWrite = func(Record) error { return os.ErrPermission } + if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err == nil { + t.Fatal("a failing write was not said") + } + v.Store.failWrite = nil + if n, err := v.Reseal(); err != nil || n != 1 { + t.Fatalf("%d %v", n, err) + } +} + +// A secret is not handed out when who was handed it cannot be recorded. +func TestNoHandOutWithoutItsRecord(t *testing.T) { + v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}}) + if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(v.dir, "store", "handed.jsonl"), 0o700); err != nil { + t.Fatal(err) + } + asker, _ := seal.GenerateKeyPair() + if a, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err == nil { + t.Fatalf("handed out with no record of it: %+v", a) + } +} + +// What an asker cannot shape fills no record: a name that is no secret's name, a caller that is no bus user. +func TestAnArbitraryAskFillsNoRecord(t *testing.T) { + v := newVault(t, nil) + asker, _ := seal.GenerateKeyPair() + for _, ask := range [][2]string{{"g14.x", strings.Repeat("junk ", 1000)}, {"g14.x", "../../etc"}, + {"not a bus user!", "pair/a"}, {strings.Repeat("a", 300), "pair/a"}} { + _, _ = v.Get(ask[0], ask[1], asker.PublicKey) + } + if h, _ := v.Store.Handed(10); len(h) != 0 { + t.Errorf("recorded %d arbitrary asks: %+v", len(h), h) + } +} + +// The record of hand-outs is bounded: rolled at its size into a fixed number of files, the oldest dropped. +func TestTheRecordOfHandOutsIsBounded(t *testing.T) { + v := newVault(t, nil) // refuses everyone, as the running vault does + v.Store.HandedMax = 2000 + asker, _ := seal.GenerateKeyPair() + for i := 0; i < 500; i++ { + _, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey) + } + var total int64 + files, _ := filepath.Glob(filepath.Join(v.dir, "store", "handed.jsonl*")) + if len(files) != HandedKept+1 { + t.Errorf("%d files of the record, not %d: %v", len(files), HandedKept+1, files) + } + for _, f := range files { + info, _ := os.Stat(f) + if info.Size() > 2000 { + t.Errorf("%s is %d bytes, over its bound", f, info.Size()) + } + total += info.Size() + } + if total > int64(HandedKept+1)*2000 { + t.Errorf("the record holds %d bytes", total) + } + if v.Store.Rolled() == 0 { + t.Error("rolling was not counted") + } + if h, _ := v.Store.Handed(5); len(h) != 5 || h[0].To != "g14.flood" { + t.Errorf("the newest are not read back: %+v", h) + } +}