Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/owntake.go
T
jschoubben c9d8950fd0 Every module follows the assigner's new places: the contracts' go/ path, mesh-wants in the delivery contracts, the state and tool assignments (issue 1966)
The legacy key form placement.<node> is gone from the client, so the readers and their fixtures lose it. The check noplacement
now also refuses the assigner's retired names in a manifest.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 22:29:07 +01:00

418 lines
17 KiB
Go

package main
// The vault takes what runs (novox/hq issue 1126, cause C4; ADR 0550 §5: a module's own secrets are the vault's).
//
// `move-in` never replaces a value the vault holds (move.go), so its first import of a machine's own secret is fixed.
// Where the controller later made that secret anew (a rotation at start, a re-issue) without handing it here, the new
// value is what runs and what its consumers hold, and the vault keeps the old one. Only the machine the value is sealed
// to can open what runs, so its node-engine hands it in through `move-in` with replace_running: true, in the two steps
// of every intake (intake.go): name the module and the secret and why, and get a call and a key made for it; then send
// the value sealed to that key. It is move-in's, not a verb of its own, so a node-engine asks it on the subject it may
// already publish and no seat promises anything new (the lead, 2026-10-28).
//
// **From the node-engine of the machine the secret is for, about its own machine alone**: the machine is the caller's,
// as the bus proved it (`node.<machine>`), never an argument; a machine named that is not the caller's is refused, and so
// is a name that is not an own secret. **Only a value the vault holds is replaced**, and never a person's (accept) or a
// provider's software's (issued) but for a covered bus account, nor one the vault changed less than RecentlyChanged ago:
// a running value is older than a value the vault just took (a re-issue the machine has not applied yet), and is never
// put back over it. The same value again changes nothing and says so; an empty one is refused.
//
// The value replaced is kept as the record's previous generation (Record.Previous), the generation is raised,
// `secret-changed` and `own-replaced` are said (the secret, the machine, both generations and why; never a value), and the
// act is written in the record of hand-outs (secret_handed). A covered bus account so replaced has its hash written again
// (accounts.go), so the bus-accounts key is of the value the bus runs.
import (
"encoding/json"
"errors"
"fmt"
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeseat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
vaultclient "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"os"
"path/filepath"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// ReplaceRunningArg is the argument of `move-in` that makes it take a running own secret over the one the vault holds.
const ReplaceRunningArg = "replace_running"
// ReplaceRunning names the act in what the vault says, and is the kind of its open calls.
const ReplaceRunning = "move-in with " + ReplaceRunningArg
// OwnReplacedEvent is what the vault says when it replaced an own secret with what runs: never a value.
const OwnReplacedEvent = "own-replaced"
// RecentlyChanged is how long after the vault changed a secret a running value is not taken over it.
var RecentlyChanged = 15 * time.Minute
// OwnTakeOf names the secret of one `move-in` with replace_running: Module and Name (or Secret whole), Node only to be checked
// against the caller's machine, and Why in the caller's words.
type OwnTakeOf struct {
Node, Module, Name, Secret, Why string
}
// OwnReplaced is what one `move-in` with replace_running did: the secret, the generation it replaced and the one it holds now, the
// fingerprint of the value held now (what the node-engine checks against the value it handed), whether the value was
// held already. Never a value.
type OwnReplaced struct {
Secret string `json:"secret"`
Node string `json:"node"`
Module string `json:"module"`
Name string `json:"name"`
From int `json:"from_generation"`
To int `json:"to_generation"`
Unchanged bool `json:"unchanged,omitempty"`
Fingerprint string `json:"fingerprint"`
Said string `json:"said"`
}
// ownTakeSecret is the secret a node-engine names, and its machine: refused for a caller that is no node-engine, for
// another machine, and for a name that is not an own secret.
func ownTakeSecret(caller string, of OwnTakeOf) (secret, node string, err error) {
node, ok := nodeEngineOf(caller)
if !ok {
if caller == "" || caller == stdio.Unproven {
caller = "an unproven caller"
}
return "", "", fmt.Errorf("%s takes a value from a machine's node-engine alone, about its own machine; %s is "+
"not a node-engine; nothing is taken", ReplaceRunning, caller)
}
if of.Secret != "" {
parts := strings.Split(of.Secret, "/")
if len(parts) != 4 || parts[0] != "own" {
return "", "", fmt.Errorf("%s is not a module's own secret (own/<machine>/<module>/<name>): %s replaces "+
"those alone; nothing is taken", of.Secret, ReplaceRunning)
}
if of.Node != "" && of.Node != parts[1] {
return "", "", fmt.Errorf("%s is not on %s; nothing is taken", of.Secret, of.Node)
}
of.Node, of.Module, of.Name = parts[1], parts[2], parts[3]
}
if of.Node != "" && of.Node != node {
return "", "", fmt.Errorf("%s may hand the vault its own machine's secrets alone, and %s/%s is %s's: nothing "+
"is taken", caller, of.Module, of.Name, of.Node)
}
secret, err = OwnSecret(node, of.Module, of.Name)
if err != nil {
return "", "", fmt.Errorf("%w; nothing is taken", err)
}
return secret, node, nil
}
// ownTakeRefusal says why the held record rec is not replaced by a running value, or "".
func ownTakeRefusal(rec Record, now time.Time) string {
switch {
case rec.Origin == OriginAccepted:
return fmt.Sprintf("%s was given by a person, and a person alone gives it anew (accept); nothing is taken", rec.Name)
case rec.Origin == OriginIssued && !Covered(rec.Name):
return fmt.Sprintf("%s is issued by its provider's software, and `issued` is the only way it enters the vault "+
"(ADR 0305 §10); nothing is taken", rec.Name)
case now.Sub(rec.ChangedAt) < RecentlyChanged:
return fmt.Sprintf("the vault changed %s at %s (generation %d, by %s), less than %s ago: a running value is "+
"taken only over an older one, so a value the vault just took is never put back; nothing is taken",
rec.Name, rec.ChangedAt.Format(time.RFC3339), rec.Generation, rec.ChangedBy, RecentlyChanged)
}
return ""
}
// OwnTakeOpen opens a call to take the running value of one of the caller's machine's own secrets.
func (v *Vault) OwnTakeOpen(caller string, of OwnTakeOf) (Opened, error) {
secret, _, err := ownTakeSecret(caller, of)
if err != nil {
return Opened{}, err
}
why := strings.TrimSpace(of.Why)
if len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
return Opened{}, errors.New("why is not one short line; nothing is taken")
}
rec, held, err := v.Store.Read(secret)
if err != nil {
return Opened{}, err
}
refused := v.ownTakeOff()
switch {
case refused != "":
case !held:
refused = fmt.Sprintf("the vault holds no secret named %s, so there is nothing to replace; nothing is taken", secret)
default:
refused = ownTakeRefusal(rec, v.Now())
}
if refused != "" {
_ = v.recordHanding(Handing{To: caller, Name: secret, Generation: rec.Generation, Outcome: "own-take-refused", Why: refused})
return Opened{}, errors.New(refused)
}
id, key, err := v.openCall(ReplaceRunning, secret, caller, nil)
if err != nil {
return Opened{}, err
}
v.callsMu.Lock()
if c := v.pending[id]; c != nil {
c.why = why
}
v.callsMu.Unlock()
return Opened{Call: id, SealTo: key, Secret: secret}, nil
}
// OwnTakeTake opens the value sealed to the call's key and replaces the secret with it, keeping the value it replaces.
func (v *Vault) OwnTakeTake(caller, call, sealed string) (OwnReplaced, error) {
if _, ok := nodeEngineOf(caller); !ok {
return OwnReplaced{}, fmt.Errorf("%s takes a value from a machine's node-engine alone; %q is not one",
ReplaceRunning, caller)
}
c, err := v.takeCall(ReplaceRunning, call)
if err != nil {
return OwnReplaced{}, err
}
if c.by != caller {
return OwnReplaced{}, fmt.Errorf("the %s call %q was opened by another caller; nothing was taken", ReplaceRunning, call)
}
value, err := c.open(sealed)
if err != nil {
return OwnReplaced{}, err
}
defer zero(value)
parts := strings.Split(c.secret, "/")
out := OwnReplaced{Secret: c.secret, Node: parts[1], Module: parts[2], Name: parts[3]}
if strings.TrimSpace(string(value)) == "" {
why := "the value handed is empty; nothing was taken"
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: why})
return OwnReplaced{}, errors.New(why)
}
now := v.Now()
changed, err := v.keepWith(c.secret, string(value), caller, "", "", keepOptions{replaceOnly: true, keepPrevious: true,
refuse: func(before Record) string { return ownTakeRefusal(before, now) }})
if err != nil {
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: err.Error()})
return OwnReplaced{}, err
}
out.Fingerprint = Fingerprint(string(value))
if changed.Unchanged {
out.From, out.To, out.Unchanged = changed.Generation, changed.Generation, true
out.Said = fmt.Sprintf("the vault holds this value of %s already (generation %d): nothing changed", c.secret, changed.Generation)
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Generation: changed.Generation, Outcome: "own-same", Why: c.why})
v.log("%s handed %s's running value, the one the vault holds (generation %d): nothing changed", caller, c.secret,
changed.Generation)
return out, nil
}
out.From, out.To = changed.Generation-1, changed.Generation
out.Said = fmt.Sprintf("%s is now the value that runs on %s, generation %d; generation %d is kept as the previous",
c.secret, out.Node, out.To, out.From)
if err := v.recordHanding(Handing{To: caller, Name: c.secret, Generation: out.To, Outcome: "own-replaced", Why: c.why}); err != nil {
out.Said += "; the record of hand-outs could not be written: " + err.Error()
}
v.log("replaced %s (generation %d → %d) with the value that runs, handed by %s: %s", c.secret, out.From, out.To,
caller, someoneOr(c.why, "no reason given"))
if v.Emit != nil {
body := map[string]any{"secret": c.secret, "node": out.Node, "module": out.Module, "name": out.Name,
"from_generation": out.From, "to_generation": out.To, "by": caller, "why": c.why,
"at": changed.At.Format(time.RFC3339Nano)}
if err := v.Emit(OwnReplacedEvent, body); err != nil {
v.log("%s is replaced at generation %d, but %s could not be said: %v", c.secret, out.To, OwnReplacedEvent, err)
}
}
return out, nil
}
// ---- own_drift ----------------------------------------------------------------------------------------------
// DriftRow is one own secret a node's latest shadow answer says the vault holds another value of than runs there.
type DriftRow struct {
Secret string `json:"secret"`
Node string `json:"node"`
Module string `json:"module"`
Name string `json:"name"`
Groups []string `json:"groups"`
Held bool `json:"held"`
Generation int `json:"generation,omitempty"`
Changed time.Time `json:"changed,omitzero"`
ChangedBy string `json:"changed_by,omitempty"`
Origin string `json:"origin,omitempty"`
Previous int `json:"previous_generation,omitempty"`
// Takeable says move-in with replace_running would take the running value now; Refused why not.
Takeable bool `json:"takeable"`
Refused string `json:"refused,omitempty"`
}
// DriftNode is one node's answers as read: whose compositions it applies, and per group how many values differ.
type DriftNode struct {
Node string `json:"node"`
Composes string `json:"composes,omitempty"`
ValuesDiffer map[string]int `json:"values_differ,omitempty"`
Unread string `json:"unread,omitempty"`
}
// Drift is own_drift's answer.
type Drift struct {
Switch OwnTakeSwitch `json:"switch"`
Secrets []DriftRow `json:"secrets"`
Nodes []DriftNode `json:"nodes"`
Note string `json:"note"`
}
// OwnDrift reads every node's own have key (`<node>.node`) and lists, from its latest answers to the assignments
// candidates, every own secret whose shown bodies say another value (vaultclient.OwnValuesDiffer), with what the vault holds
// of it and whether move-in with replace_running would take the running value now.
func (v *Vault) OwnDrift() (Drift, error) {
if v.Haves == nil || v.HaveKeys == nil {
return Drift{}, errors.New("the vault reads no node's have, so it cannot say what drifted")
}
keys, err := v.HaveKeys()
if err != nil {
return Drift{}, fmt.Errorf("the keys of %s could not be listed: %w", HaveRead, err)
}
sort.Strings(keys)
rows := map[string]*DriftRow{}
out := Drift{Secrets: []DriftRow{}, Nodes: []DriftNode{},
Note: "from each node's latest shadow answers; a value is never shown: move-in with replace_running from that node replaces it"}
for _, key := range keys {
node, isNode := strings.CutSuffix(key, "."+engineclient.NodeWord)
if !isNode || !namePart.MatchString(node) {
continue
}
dn := DriftNode{Node: node}
raw, err := v.Haves(key)
if err != nil || raw == nil {
dn.Unread = fmt.Sprintf("its have could not be read: %v", err)
out.Nodes = append(out.Nodes, dn)
continue
}
have, err := engineclient.DecodeHaveNode(raw)
if err != nil {
dn.Unread = err.Error()
out.Nodes = append(out.Nodes, dn)
continue
}
dn.Composes = have.Composes
for group, a := range have.Candidates {
if n := vaultclient.ValuesDiffer(bodiesOf(a)); n > 0 {
if dn.ValuesDiffer == nil {
dn.ValuesDiffer = map[string]int{}
}
dn.ValuesDiffer[group] = n
}
for _, o := range vaultclient.OwnValuesDiffer(bodiesOf(a)) {
secret, err := OwnSecret(node, o.Module, o.Name)
if err != nil {
continue
}
r := rows[secret]
if r == nil {
r = &DriftRow{Secret: secret, Node: node, Module: o.Module, Name: o.Name}
rows[secret] = r
}
r.Groups = append(r.Groups, group)
}
}
out.Nodes = append(out.Nodes, dn)
}
names := make([]string, 0, len(rows))
for n := range rows {
names = append(names, n)
}
sort.Strings(names)
if out.Switch, err = v.Store.OwnTakeSwitch(); err != nil {
return Drift{}, err
}
off := v.ownTakeOff()
for _, n := range names {
r := rows[n]
sort.Strings(r.Groups)
rec, held, err := v.Store.Read(n)
switch {
case err != nil:
r.Refused = err.Error()
case !held:
r.Refused = "the vault holds no secret named " + n
default:
r.Held, r.Generation, r.Changed, r.ChangedBy, r.Origin = true, rec.Generation, rec.ChangedAt, rec.ChangedBy, rec.Origin
if rec.Previous != nil {
r.Previous = rec.Previous.Generation
}
r.Refused = ownTakeRefusal(rec, v.Now())
if off != "" {
r.Refused = off
}
r.Takeable = r.Refused == ""
}
out.Secrets = append(out.Secrets, *r)
}
return out, nil
}
// ---- own_take_switch ----------------------------------------------------------------------------------------
// OwnTakeSwitch is whether move-in with replace_running takes values, who switched it last, when and why. On until a person
// switches it off; kept in the vault's own store, so it holds over a restart.
type OwnTakeSwitch struct {
On bool `json:"on"`
By string `json:"by,omitempty"`
At time.Time `json:"at,omitzero"`
Why string `json:"why,omitempty"`
}
func (s *Store) ownTakeSwitchPath() string { return filepath.Join(s.dir, "replace-running.json") }
// OwnTakeSwitch is the switch as kept: on when nothing was ever switched.
func (s *Store) OwnTakeSwitch() (OwnTakeSwitch, error) {
raw, err := os.ReadFile(s.ownTakeSwitchPath())
if errors.Is(err, os.ErrNotExist) {
return OwnTakeSwitch{On: true}, nil
}
if err != nil {
return OwnTakeSwitch{}, err
}
var sw OwnTakeSwitch
if err := json.Unmarshal(raw, &sw); err != nil {
return OwnTakeSwitch{}, fmt.Errorf("the switch of %s is not readable: %w", ReplaceRunning, err)
}
return sw, nil
}
// SwitchOwnTake switches move-in with replace_running on or off, a person's act with why; the caller is recorded as the bus
// proved it.
func (v *Vault) SwitchOwnTake(caller string, on bool, why string) (OwnTakeSwitch, error) {
why = strings.TrimSpace(why)
if why == "" || len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
return OwnTakeSwitch{}, errors.New("say why in one short line; nothing was switched")
}
sw := OwnTakeSwitch{On: on, By: someoneOr(caller, stdio.Unproven), At: v.Now().UTC(), Why: why}
raw, _ := json.MarshalIndent(sw, "", " ")
if err := writeWhole(v.Store.ownTakeSwitchPath(), append(raw, '\n')); err != nil {
return OwnTakeSwitch{}, err
}
v.log("%s is switched %s by %s: %s", ReplaceRunning, map[bool]string{true: "on", false: "off"}[on], sw.By, why)
return sw, nil
}
// ownTakeOff says why move-in with replace_running takes nothing now, or "".
func (v *Vault) ownTakeOff() string {
sw, err := v.Store.OwnTakeSwitch()
if err != nil {
return err.Error() + "; nothing is taken"
}
if !sw.On {
return fmt.Sprintf("%s is switched off (by %s at %s: %s); nothing is taken", ReplaceRunning, sw.By,
sw.At.Format(time.RFC3339), sw.Why)
}
return ""
}
// bodiesOf is the shown bodies of the modules a node's answer to a candidate says differ, in the form the vault client
// reads them (the node-engine's answer is the seat's type, the reading of masked values the vault's).
func bodiesOf(a nodeseat.CandidateAnswer) []vaultclient.ModuleBodies {
out := make([]vaultclient.ModuleBodies, 0, len(a.Differs))
for _, d := range a.Differs {
m := vaultclient.ModuleBodies{Module: d.Module}
for _, b := range d.Bodies {
m.Bodies = append(m.Bodies, vaultclient.Body{ID: b.ID, Composed: b.Composed, Running: b.Running})
}
out = append(out, m)
}
return out
}